Files
ssh-jumphost/static/js/terminal.js
2026-09-02 21:38:22 +02:00

796 lines
36 KiB
JavaScript

/*
* SSH-Terminal-Baustein: xterm.js <-> /ws/ssh/{host_id}.
*
* F1 (Umsetzungsauftrag_Sonnet5.md Teil F.3.1): vorher ein Dokument-Singleton
* (ein Skript pro Seite, feste Element-IDs). Jetzt eine Klasse
* (SshTerminalSession) mit genau einer Instanz pro Sitzung: eigenes,
* dynamisch gebautes DOM-Fragment (this.element), connect(), dispose(),
* setActive(bool) und ein Statusrueckruf (options.onStatusChange) fuer eine
* kuenftige Seitenleiste (Teil F.3: /workspace, noch nicht Teil dieser
* Stufe). Die eigenstaendige Seite /terminal/{host_id} (templates/
* terminal.html) ist ab jetzt nur noch ein duenner Rahmen, der GENAU EINE
* Instanz erzeugt -- keine zweite Implementierung (F.2, letzter Absatz).
*
* Vollbild: native Browser-Fullscreen-API auf dem Instanz-Wurzelelement
* (Konzept 4.3).
* Copy & Paste: xterm.js liefert Markieren-zum-Kopieren + Strg+Umschalt+V
* bereits nativ. Aufgabe #11 (vom Nutzer nachtraeglich hinzugefuegt, siehe
* FORTSETZUNG_Aufgabe11.md) ergaenzt zwei konkret gewuenschte Wege:
* `copyOnSelect: true` kopiert eine Markierung sofort in die
* System-Zwischenablage (kein Strg+C/Cmd+C mehr noetig -- Strg+C bleibt
* ohne Markierung weiterhin SIGINT, xterm.js' Standardverhalten). Rechtsklick
* fuegt ein: zuerst wird direkt versucht, ueber die Clipboard-API einzufuegen
* (funktioniert in den meisten Chromium-Browsern, da ein Rechtsklick eine
* "transiente Aktivierung" ist); schlaegt das fehl (Firefox/Safari ohne
* Berechtigung, unsicherer Kontext), erscheint ersatzweise ein winziges
* Kontextmenue mit einem "Einfuegen"-Eintrag -- der Klick darauf ist selbst
* eine Benutzergeste und hat dadurch nochmal bessere Chancen auf die
* Berechtigung (siehe _wireClipboard() unten). Keine serverseitige
* Sonderbehandlung noetig, im Gegensatz zu RDP (siehe rdp.js).
* Markieren mit gedrueckter Umschalttaste: xterm.js reicht Mausklicks an ein
* Vollbild-Zielprogramm mit eigener Maussteuerung (tmux, vim, htop, mc, ...)
* weiter, sobald dieses per ANSI-Escape-Sequenz Maus-Tracking anfordert --
* dann faengt ein normaler Linksklick+Ziehen KEINE lokale Textmarkierung
* mehr ein (Standardverhalten jedes echten Terminals, kein Bug). Umschalt
* (Shift)+Ziehen erzwingt die lokale Markierung trotzdem, xterm.js
* unterstuetzt das nativ, ohne Zusatzcode. Als `title`-Tooltip auf
* `.session-terminal` dokumentiert (siehe _buildElement()), da das sonst
* leicht wie ein kaputtes Kopieren aussieht.
* Farbschema: per <select> in der Toolbar waehlbar (TERMINAL_THEMES unten),
* Wahl wird in localStorage gemerkt (schluessel THEME_STORAGE_KEY) und beim
* naechsten Sitzungsaufbau (auch in anderen Tabs) als Vorgabe uebernommen --
* kein Server-Roundtrip, rein clientseitige Praeferenz wie ueblich fuer
* Darstellungseinstellungen dieser Art. AENDERT NICHTS an bereits anderen,
* gleichzeitig offenen Sitzungen (F4: mehrere Instanzen) -- dieselbe Klasse
* von Eventual-Consistency wie an anderer Stelle im Projekt (kein Live-Sync
* zwischen Instanzen, nur beim naechsten Aufbau wirksam).
* Tastatur: xterm.js haengt seine Tastatur-Erfassung bereits an sein EIGENES
* Containerelement (nicht an document) -- die in F.3.2 beschriebene Gefahr
* ("Tastendruck landet auf drei Servern") betrifft daher nur rdp.js
* (Guacamole.Keyboard(document)), nicht dieses Modul. window resize/
* fullscreenchange bleiben dokumentweite Listener (siehe dispose()) -- bei
* genau einer aktiven Instanz (heutiger Stand: Stufe F1/F2/F3) unveraendert
* zum bisherigen Verhalten; die Mehrfach-Sitzungs-Feinheiten aus F.3.2/F.3.3
* (Refit erst beim Sichtbarwerden, Fokusmodell) folgen in Stufe F4.
*
* Dateitransfer: eigenes Panel (statt frueherem prompt()-basiertem Upload)
* mit Formularen fuer Upload UND Download in beide Richtungen. Der Download
* laeuft ueber fetch() + Blob + synthetischen <a download>-Link, damit
* Fehler inline im Panel angezeigt werden koennen statt die Seite zu
* verlassen (siehe GET /ssh/{host_id}/files/download).
*
* F5-Nachtrag (Umsetzungsauftrag_Sonnet5.md Teil F.3.4): periodischer
* Leerlauf-Ping (this._pingTimer, alle 4 Minuten, siehe connect()) haelt
* eine wirklich untaetige Hintergrundsitzung am Leben -- ohne ihn trennt
* der server-seitige Idle-Timeout (E10, IDLE_TIMEOUT_SECONDS, Standard 15
* Minuten) eine Sitzung ohne jede Ein-/Ausgabe, obwohl Betreiberentscheidung
* #7 (FORTSETZUNG_Teil_F.md) "voll verbunden bleiben" verlangt. Betrifft nur
* SSH -- RDP (ws_tunnel.py) hat keinen Idle-Timeout.
*/
(() => {
"use strict";
function b64encode(str) {
return btoa(unescape(encodeURIComponent(str)));
}
// Bugfix: NICHT ueber decodeURIComponent(escape(atob(...))) als JS-String
// dekodieren. Jedes "output"-Frame entspricht genau einem Chunk aus
// process.stdout.read() auf dem Server (app/ssh_proxy/terminal_ws.py) --
// ein Mehrbyte-UTF-8-Zeichen (Umlaute, Box-Drawing-Zeichen, Emoji in
// Banner/Prompt/htop etc.) kann dabei GENAU AN DER CHUNK-GRENZE
// auseinandergerissen werden. decodeURIComponent() wirft dann einen
// "URI malformed"-Fehler fuer diesen einen Chunk (siehe
// static/js/admin.js::createSshPlayer fuer die noch schwerwiegendere
// Auswirkung bei der Wiedergabe -- dort blieb die gesamte Wiedergabe an
// genau dieser Stelle haengen). xterm.js' term.write() nimmt daher direkt
// die rohen Bytes als Uint8Array entgegen -- xterm.js ist ein echter
// Terminal-Emulator mit eigenem, chunk-uebergreifend korrektem
// UTF-8-Parser, genau fuer diesen Fall gebaut.
function b64decodeBytes(b64) {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// --- Farbschemata (Aufgabe #11) -----------------------------------------
//
// Bewusst eine kleine, feste Auswahl statt eines vollen Theme-Editors --
// deckt den geaeusserten Wunsch ("vom Benutzer waehlbar") ab, ohne eine
// neue Einstellungsseite o.ae. einzufuehren. Felder folgen xterm.js'
// ITheme-Schnittstelle (siehe vendor/xterm.js, OptionsService/"theme").
const TERMINAL_THEMES = {
standard: {
label: "Standard (dunkel)",
theme: { background: "#000000", foreground: "#e6e8eb", cursor: "#e6e8eb" },
},
dunkel_kontrastreich: {
label: "Dunkel, kontrastreich",
theme: {
background: "#0d1117", foreground: "#c9d1d9", cursor: "#58a6ff",
selectionBackground: "#264f78",
black: "#484f58", red: "#ff7b72", green: "#3fb950", yellow: "#d29922",
blue: "#58a6ff", magenta: "#bc8cff", cyan: "#39c5cf", white: "#b1bac4",
brightBlack: "#6e7681", brightRed: "#ffa198", brightGreen: "#56d364",
brightYellow: "#e3b341", brightBlue: "#79c0ff", brightMagenta: "#d2a8ff",
brightCyan: "#56d4dd", brightWhite: "#f0f6fc",
},
},
solarized_dark: {
label: "Solarized Dark",
theme: {
background: "#002b36", foreground: "#839496", cursor: "#93a1a1",
selectionBackground: "#073642",
black: "#073642", red: "#dc322f", green: "#859900", yellow: "#b58900",
blue: "#268bd2", magenta: "#d33682", cyan: "#2aa198", white: "#eee8d5",
brightBlack: "#002b36", brightRed: "#cb4b16", brightGreen: "#586e75",
brightYellow: "#657b83", brightBlue: "#839496", brightMagenta: "#6c71c4",
brightCyan: "#93a1a1", brightWhite: "#fdf6e3",
},
},
solarized_light: {
label: "Solarized Light",
theme: {
background: "#fdf6e3", foreground: "#657b83", cursor: "#586e75",
selectionBackground: "#eee8d5",
black: "#073642", red: "#dc322f", green: "#859900", yellow: "#b58900",
blue: "#268bd2", magenta: "#d33682", cyan: "#2aa198", white: "#eee8d5",
brightBlack: "#002b36", brightRed: "#cb4b16", brightGreen: "#586e75",
brightYellow: "#657b83", brightBlue: "#839496", brightMagenta: "#6c71c4",
brightCyan: "#93a1a1", brightWhite: "#fdf6e3",
},
},
};
const DEFAULT_THEME_NAME = "standard";
const THEME_STORAGE_KEY = "jumphost_terminal_theme";
function loadStoredThemeName() {
try {
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
if (stored && TERMINAL_THEMES[stored]) return stored;
} catch (_) { /* localStorage blockiert (privater Modus o.ae.) -- Standard */ }
return DEFAULT_THEME_NAME;
}
function storeThemeName(name) {
try {
window.localStorage.setItem(THEME_STORAGE_KEY, name);
} catch (_) { /* siehe oben -- Auswahl gilt dann nur fuer diese Instanz */ }
}
let _nextInstanceId = 1;
class SshTerminalSession {
/**
* @param {number|string} hostId
* @param {object} [options]
* @param {(text: string, isError: boolean) => void} [options.onStatusChange]
* Statusrueckruf fuer eine kuenftige Seitenleiste (Teil F.3).
* @param {() => void} [options.onExitRequested]
* Wird aufgerufen, NACHDEM der interne Exit-Knopf dispose()
* bereits ausgeloest hat -- die Seite entscheidet, was danach
* passiert (Tab schliessen bei /terminal/{id}, Kachel aus der
* Seitenleiste entfernen bei /workspace).
*/
constructor(hostId, options = {}) {
this.hostId = hostId;
this.options = options;
this._uid = _nextInstanceId++;
this._active = false;
this._disposed = false;
this._lastErrorMessage = null;
this._transfers = [];
this._resizeDebounce = null;
this._pasteMenuEl = null;
this.element = this._buildElement();
this._cacheRefs();
this._setupTerminal();
this._wireToolbar();
this._wireClipboard();
this._wireFileTransfer();
}
// --- Aufbau -------------------------------------------------------
_buildElement() {
const uploadPathId = `ssh-ft-upload-path-${this._uid}`;
const downloadPathId = `ssh-ft-download-path-${this._uid}`;
const themeOptions = Object.keys(TERMINAL_THEMES)
.map((key) => `<option value="${key}">${TERMINAL_THEMES[key].label}</option>`)
.join("");
const wrapper = document.createElement("div");
wrapper.className = "session-instance";
wrapper.innerHTML = `
<div class="session-shell">
<div class="session-toolbar">
<button type="button" class="fullscreen-btn">Vollbild</button>
<button type="button" class="filetransfer-btn">Dateitransfer</button>
<select class="theme-select" title="Farbschema">${themeOptions}</select>
<span class="spacer"></span>
<span class="status">Verbinde ...</span>
<button type="button" class="exit-btn btn-danger btn-small" title="Sitzung beenden">Exit</button>
</div>
<div class="session-terminal" title="Markieren: Klicken+Ziehen. In Vollbild-Programmen mit eigener Maussteuerung (tmux, vim, htop, mc, weniger/less --mouse, ...) Umschalt+Ziehen verwenden, sonst geht der Klick an das Programm statt an die Markierung."></div>
</div>
<div class="ft-overlay hidden">
<div class="ft-panel">
<h2>Dateitransfer
<button type="button" class="btn-secondary btn-small ft-close-btn">Schliessen</button>
</h2>
<h3>Hochladen</h3>
<label for="${uploadPathId}">Zielpfad auf dem Server</label>
<input type="text" id="${uploadPathId}" class="ft-upload-remote-path" placeholder="/tmp/datei.txt">
<input type="file" class="ft-upload-file-input hidden">
<div class="ft-btn-row">
<button type="button" class="btn-secondary btn-small ft-upload-pick-btn">Datei auswaehlen</button>
<span class="hint ft-upload-filename">Keine Datei ausgewaehlt</span>
</div>
<button type="button" class="btn-small ft-upload-submit-btn">Hochladen</button>
<h3>Herunterladen</h3>
<label for="${downloadPathId}">Pfad auf dem Server</label>
<input type="text" id="${downloadPathId}" class="ft-download-remote-path" placeholder="/etc/hosts">
<button type="button" class="btn-small ft-download-submit-btn">Herunterladen</button>
<div class="error ft-status"></div>
<h3>Letzte Transfers</h3>
<div class="ft-list"></div>
</div>
</div>
`;
return wrapper;
}
_cacheRefs() {
const el = this.element;
this.shellEl = el.querySelector(".session-shell");
this.statusEl = el.querySelector(".status");
this.terminalContainer = el.querySelector(".session-terminal");
this.fullscreenBtn = el.querySelector(".fullscreen-btn");
this.filetransferBtn = el.querySelector(".filetransfer-btn");
this.themeSelect = el.querySelector(".theme-select");
this.exitBtn = el.querySelector(".exit-btn");
this.ftOverlay = el.querySelector(".ft-overlay");
this.ftStatus = el.querySelector(".ft-status");
this.ftList = el.querySelector(".ft-list");
this.ftCloseBtn = el.querySelector(".ft-close-btn");
this.uploadRemotePathInput = el.querySelector(".ft-upload-remote-path");
this.uploadFileInput = el.querySelector(".ft-upload-file-input");
this.uploadFilenameLabel = el.querySelector(".ft-upload-filename");
this.uploadPickBtn = el.querySelector(".ft-upload-pick-btn");
this.uploadSubmitBtn = el.querySelector(".ft-upload-submit-btn");
this.downloadRemotePathInput = el.querySelector(".ft-download-remote-path");
this.downloadSubmitBtn = el.querySelector(".ft-download-submit-btn");
}
_setStatus(text, isError) {
this.statusEl.textContent = text;
this.statusEl.classList.toggle("error", !!isError);
if (this.options.onStatusChange) {
try {
this.options.onStatusChange(text, !!isError);
} catch (_) { /* Aufrufer-Fehler duerfen die Sitzung nicht stoeren */ }
}
}
// --- Terminal/WebSocket --------------------------------------------
_setupTerminal() {
this._themeName = loadStoredThemeName();
this.term = new Terminal({
cursorBlink: true,
fontFamily: "Menlo, Consolas, monospace",
fontSize: 14,
theme: TERMINAL_THEMES[this._themeName].theme,
// Aufgabe #11: eine Markierung wird sofort in die System-
// Zwischenablage kopiert -- kein zusaetzliches Strg+C/Cmd+C noetig.
// Strg+C OHNE Markierung bleibt unveraendert SIGINT (xterm.js'
// Standardverhalten, wird von copyOnSelect nicht beruehrt).
copyOnSelect: true,
});
this.fitAddon = new FitAddon.FitAddon();
this.term.loadAddon(this.fitAddon);
// Bug-Fix (Live-Test, F.6/Aufgabe#11): xterm.js' Standard-DOM-Renderer
// faerbt Zellen (inkl. Markierungs-Hintergrund) teils per dynamisch
// erzeugtem <style>-Element, teils per element.setAttribute("style", ...).
// Beides sind "inline styles" im Sinne der CSP (style-src 'self', kein
// 'unsafe-inline'/Nonce/Hash) -- der Browser verwirft diese Regeln
// stillschweigend (element.sheet bleibt null), wodurch u.a. die
// Text-Markierung unsichtbar bleibt, obwohl die Auswahl-Logik selbst
// funktioniert. Der Canvas-Renderer zeichnet Zeichen/Farben stattdessen
// direkt auf ein <canvas> (Pixel statt CSS) und ist von style-src nicht
// betroffen -- CSP bleibt dadurch unveraendert strikt.
try {
this.canvasAddon = new CanvasAddon.CanvasAddon();
this.term.loadAddon(this.canvasAddon);
} catch (err) {
// Faellt der Canvas-Renderer aus irgendeinem Grund aus (z.B. sehr
// altes/eingeschraenktes Canvas-2D-Umfeld), bleibt der DOM-Renderer
// als Fallback aktiv -- Faerbung/Markierung waeren dann wieder vom
// CSP-Fix abhaengig, aber die Sitzung funktioniert weiterhin.
console.warn("CanvasAddon konnte nicht geladen werden, Fallback auf DOM-Renderer:", err);
}
this.term.open(this.terminalContainer);
if (this.themeSelect) this.themeSelect.value = this._themeName;
this.term.onData((data) => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: "input", data: b64encode(data) }));
}
});
}
/** Verbindungsaufbau -- getrennt vom Konstruktor, damit eine kuenftige
* Seitenleiste (Teil F.3) eine Kachel erst anzeigen und DANACH verbinden
* kann (z.B. um zuerst die Ressourcen-/Obergrenzen-Pruefung serverseitig
* abzuwarten). */
connect() {
if (this.ws) return; // bereits verbunden/am Verbinden
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
this.ws = new WebSocket(`${proto}//${window.location.host}/ws/ssh/${this.hostId}`);
this.ws.addEventListener("open", () => {
this._setStatus("Verbunden", false);
this.sendResize();
// F5-Nachtrag (Umsetzungsauftrag Teil F.3.4): E10 (terminal_ws.py)
// trennt eine Sitzung nach IDLE_TIMEOUT_SECONDS (Standard 15 Min.)
// ohne JEDE Ein-/Ausgabe. Vor F4 war das unproblematisch -- es gab
// immer nur eine Sitzung, entweder aktiv genutzt oder der Benutzer
// war ohnehin weg. Seit mehrere Sitzungen still im Hintergrund
// liegen koennen (F.3.4: "duerfen nicht sterben"), reicht ein
// wirklich untaetiger Hintergrund-Prompt (kein tail -f, kein Build)
// aus, um das F.6-Abnahmekriterium ("nach 30 Minuten alle vier noch
// verbunden") zu verfehlen. Ein periodischer, inhaltsloser Ping
// haelt jede offene Sitzung am Leben -- der Server zaehlt JEDE
// empfangene Nachricht als Aktivitaet (terminal_ws.py, noch vor der
// type-Weiche), ein unbekannter type wird danach folgenlos
// ignoriert. Laeuft unabhaengig von _active, auch im Hintergrund.
this._pingTimer = window.setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: "ping" }));
}
}, 240000); // 4 Minuten, deutlich unter IDLE_TIMEOUT_SECONDS (Standard 900s)
});
this.ws.addEventListener("close", (ev) => {
if (this._pingTimer) {
window.clearInterval(this._pingTimer);
this._pingTimer = null;
}
let text;
if (this._lastErrorMessage) {
text = "Verbindung beendet: " + this._lastErrorMessage;
} else if (ev.reason) {
text = `Verbindung beendet (Code ${ev.code}): ${ev.reason}`;
} else if (ev.code && ev.code !== 1000) {
text = `Verbindung beendet (Code ${ev.code})`;
} else {
text = "Verbindung beendet";
}
this._setStatus(text, true);
console.error("SSH-Sitzung beendet:", { code: ev.code, reason: ev.reason, lastErrorMessage: this._lastErrorMessage });
});
this.ws.addEventListener("error", (ev) => {
this._setStatus("Verbindungsfehler (WebSocket) -- Details siehe Browser-Konsole", true);
console.error("SSH-WebSocket-Fehler:", ev);
});
this.ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === "output") {
this.term.write(b64decodeBytes(msg.data));
} else if (msg.type === "error") {
this._lastErrorMessage = msg.message;
this._setStatus("Fehler: " + msg.message, true);
} else if (msg.type === "warning") {
// E11 (Umsetzungsauftrag Teil E): Vorwarnung vor dem automatischen
// Trennen bei Erreichen der maximalen Sitzungsdauer. Nicht
// destruktiv -- der Statustext wird nach kurzer Zeit wieder auf
// den vorherigen Wert zurueckgesetzt, damit er nicht dauerhaft
// "Verbunden" verdraengt.
const previousStatus = this.statusEl.textContent;
const previousWasError = this.statusEl.classList.contains("error");
this.statusEl.textContent = "Hinweis: " + msg.message;
this.statusEl.classList.add("warning");
window.setTimeout(() => {
if (this._disposed) return;
this.statusEl.textContent = previousStatus;
this.statusEl.classList.remove("warning");
if (!previousWasError) this.statusEl.classList.remove("error");
}, 10000);
}
});
// F4 (Umsetzungsauftrag_Sonnet5.md Teil F.3.3): mehrere Instanzen
// koennen jetzt gleichzeitig existieren (Arbeitsflaeche /workspace) --
// eine ausgeblendete Instanz hat Groesse 0 (display:none); fitAddon.fit()
// wuerde dort mit Cols/Rows nahe 0 enden. Sichtbarwerden holt den Refit
// ueber setActive(true) -> sendResize() ohnehin nach.
this._onWindowResize = () => { if (this._active) this.sendResize(); };
window.addEventListener("resize", this._onWindowResize);
document.addEventListener("fullscreenchange", this._onWindowResize);
}
sendResize() {
this.fitAddon.fit();
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: "resize", cols: this.term.cols, rows: this.term.rows }));
}
}
/** Wird von einer kuenftigen Seitenleiste (Teil F.3/F.4) beim Umschalten
* aufgerufen. Bei genau einer Instanz (heutiger Stand) ohne sichtbaren
* Effekt ausser einem Refit+Fokus. */
setActive(active) {
this._active = active;
if (active) {
this.sendResize();
this.term.focus();
} else {
this._closePasteMenu();
}
}
// --- Toolbar --------------------------------------------------------
_wireToolbar() {
this.fullscreenBtn.addEventListener("click", () => {
if (!document.fullscreenElement) {
this.shellEl.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen();
}
});
this.themeSelect.addEventListener("change", () => {
const name = TERMINAL_THEMES[this.themeSelect.value] ? this.themeSelect.value : DEFAULT_THEME_NAME;
this._themeName = name;
this.term.options.theme = TERMINAL_THEMES[name].theme;
storeThemeName(name);
this.term.focus();
});
this.exitBtn.addEventListener("click", () => {
// Sofortiges sichtbares Feedback, damit erkennbar ist, dass der Klick
// ueberhaupt ankam -- unabhaengig davon, was der Aufrufer danach tut
// (Tab schliessen bei /terminal/{id}, Kachel entfernen bei /workspace).
this._setStatus("Sitzung wird beendet ...", false);
this.dispose();
if (this.options.onExitRequested) this.options.onExitRequested();
});
}
// --- Zwischenablage (Aufgabe #11) --------------------------------------
//
// Kopieren laeuft ueber copyOnSelect (siehe _setupTerminal()) -- hier nur
// Einfuegen per Rechtsklick. Zwei Stufen, wie im Datei-Kopfkommentar
// beschrieben: direkter Versuch, dann ein winziges Kontextmenue als
// Ruecksicherung, wenn der Browser die Berechtigung ohne explizite
// Benutzergeste verweigert.
_wireClipboard() {
this.terminalContainer.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
this._attemptDirectPaste(ev.clientX, ev.clientY);
});
}
async _attemptDirectPaste(x, y) {
if (!navigator.clipboard || !navigator.clipboard.readText) {
this._showPasteMenu(x, y);
return;
}
try {
const text = await navigator.clipboard.readText();
this.term.paste(text);
} catch (_) {
// Kein Zugriff ohne eine Benutzergeste, die NACH diesem Handler
// beginnt (z.B. Firefox/Safari) -- Ruecksicherung unten, deren
// Klick selbst wieder eine frische Geste ist.
this._showPasteMenu(x, y);
}
}
_closePasteMenu() {
if (this._pasteMenuEl) {
this._pasteMenuEl.remove();
this._pasteMenuEl = null;
}
if (this._pasteMenuCleanup) {
this._pasteMenuCleanup();
this._pasteMenuCleanup = null;
}
}
_showPasteMenu(x, y) {
this._closePasteMenu();
// CSP (style-src 'self', kein 'unsafe-inline', siehe app.css-
// Kopfkommentar zu .hidden) verbietet JS-gesetzte element.style-
// Aenderungen genauso wie style="..."-Attribute -- daher KEINE
// pixelgenaue Positionierung ueber this.menu.style.left/top, sondern
// vier feste CSS-Klassen fuer das Viertel von .session-shell, in dem
// rechtsgeklickt wurde (siehe app.css .term-paste-menu-*).
const rect = this.shellEl.getBoundingClientRect();
const isRight = x > rect.left + rect.width / 2;
const isBottom = y > rect.top + rect.height / 2;
const menu = document.createElement("div");
menu.className =
"term-paste-menu " +
(isBottom ? "term-paste-menu-bottom" : "term-paste-menu-top") + " " +
(isRight ? "term-paste-menu-right" : "term-paste-menu-left");
const pasteBtn = document.createElement("button");
pasteBtn.type = "button";
pasteBtn.textContent = "Einfuegen";
pasteBtn.addEventListener("click", async () => {
this._closePasteMenu();
try {
const text = await navigator.clipboard.readText();
this.term.paste(text);
} catch (err) {
console.error("Einfuegen aus der Zwischenablage nicht moeglich:", err);
this._setStatus(
"Einfuegen nicht moeglich -- Browser verweigert Zwischenablage-Zugriff. Bitte Strg+Umschalt+V verwenden.",
true
);
}
this.term.focus();
});
menu.appendChild(pasteBtn);
// Als Kind von .session-shell (nicht .session-terminal) angehaengt --
// dort mischt sich nichts mit xterm.js' eigenem DOM-Teilbaum, das
// dieses Element seit term.open() exklusiv verwaltet.
this.shellEl.appendChild(menu);
this._pasteMenuEl = menu;
// Schliessen bei Klick daneben, Escape oder Scrollen -- Standard-
// verhalten fuer ein solches Mini-Kontextmenue (siehe auch
// workspace.js fuer dasselbe Muster bei anderen Overlays).
const onDocClick = (ev) => {
if (!menu.contains(ev.target)) this._closePasteMenu();
};
const onKeydown = (ev) => {
if (ev.key === "Escape") this._closePasteMenu();
};
const onScroll = () => this._closePasteMenu();
// capture:true fuer den Klick-Listener, damit er VOR einem moeglichen
// contextmenu-Listener eines anderen Elements greift; im naechsten
// Tick registriert, sonst schliesst der Rechtsklick, der das Menue
// gerade erst geoeffnet hat, es sofort wieder (derselbe Event).
window.setTimeout(() => {
document.addEventListener("click", onDocClick, true);
document.addEventListener("keydown", onKeydown);
window.addEventListener("scroll", onScroll, true);
}, 0);
this._pasteMenuCleanup = () => {
document.removeEventListener("click", onDocClick, true);
document.removeEventListener("keydown", onKeydown);
window.removeEventListener("scroll", onScroll, true);
};
}
// --- Dateitransfer-Panel ---------------------------------------------
_wireFileTransfer() {
const openFt = () => {
this.ftOverlay.classList.remove("hidden");
this.ftStatus.textContent = "";
};
const closeFt = () => {
this.ftOverlay.classList.add("hidden");
};
this.filetransferBtn.addEventListener("click", openFt);
this.ftCloseBtn.addEventListener("click", closeFt);
// Klick auf den abgedunkelten Hintergrund (nicht auf das Panel selbst)
// schliesst das Overlay ebenfalls -- Standardverhalten fuer solche Panels.
this.ftOverlay.addEventListener("click", (ev) => {
if (ev.target === this.ftOverlay) closeFt();
});
this._renderTransfers();
this.uploadPickBtn.addEventListener("click", () => this.uploadFileInput.click());
this.uploadFileInput.addEventListener("change", () => {
const file = this.uploadFileInput.files[0];
this.uploadFilenameLabel.textContent = file ? file.name : "Keine Datei ausgewaehlt";
if (file && !this.uploadRemotePathInput.value) {
this.uploadRemotePathInput.value = `/tmp/${file.name}`;
}
});
this.uploadSubmitBtn.addEventListener("click", () => this._doUpload());
this.downloadSubmitBtn.addEventListener("click", () => this._doDownload());
}
_recordTransfer(direction, filename, size, ok, message) {
this._transfers.unshift({ direction, filename, size, ok, message });
if (this._transfers.length > 20) this._transfers.length = 20;
this._renderTransfers();
}
_renderTransfers() {
this.ftList.innerHTML = "";
if (this._transfers.length === 0) {
const empty = document.createElement("div");
empty.className = "hint";
empty.textContent = "Noch keine Transfers in dieser Sitzung.";
this.ftList.appendChild(empty);
return;
}
for (const t of this._transfers) {
const row = document.createElement("div");
row.className = "ft-list-row";
const dir = document.createElement("span");
dir.className = "badge " + (t.ok ? "ok" : "danger");
dir.textContent = t.direction === "upload" ? "↑ Upload" : "↓ Download";
row.appendChild(dir);
const name = document.createElement("span");
name.className = "name";
name.textContent = t.ok ? t.filename : `${t.filename} -- ${t.message}`;
row.appendChild(name);
if (t.ok) {
const size = document.createElement("span");
size.className = "size";
size.textContent = formatSize(t.size);
row.appendChild(size);
}
this.ftList.appendChild(row);
}
}
async _doUpload() {
const file = this.uploadFileInput.files[0];
const remotePath = this.uploadRemotePathInput.value.trim();
this.ftStatus.textContent = "";
if (!file) {
this.ftStatus.textContent = "Bitte zuerst eine Datei auswaehlen.";
return;
}
if (!remotePath) {
this.ftStatus.textContent = "Bitte einen Zielpfad angeben.";
return;
}
const formData = new FormData();
formData.append("file", file);
this.ftStatus.textContent = `Lade ${file.name} hoch ...`;
try {
const res = await fetch(
`/ssh/${this.hostId}/files/upload?remote_path=${encodeURIComponent(remotePath)}`,
{ method: "POST", credentials: "same-origin", body: formData }
);
// Robust gegen Nicht-JSON-Antworten (z.B. ein unbehandelter serverseitiger
// Fehler, den Starlette als Klartext "Internal Server Error" statt JSON
// ausliefert) -- sonst crasht hier res.json() mit einer verwirrenden
// "Unexpected token 'I' ..."-Meldung statt der eigentlichen Fehlerursache.
let data = null;
try {
data = await res.json();
} catch (_) { /* Antwort war kein JSON */ }
if (!res.ok) throw new Error((data && data.detail) || `Upload fehlgeschlagen (HTTP ${res.status})`);
this.ftStatus.textContent = `Upload abgeschlossen (AV: ${data.av_scan_result})`;
this._recordTransfer("upload", file.name, data.size, true, "");
this.uploadFileInput.value = "";
this.uploadFilenameLabel.textContent = "Keine Datei ausgewaehlt";
this.uploadRemotePathInput.value = "";
} catch (err) {
this.ftStatus.textContent = "Upload-Fehler: " + err.message;
this._recordTransfer("upload", file.name, 0, false, err.message);
}
}
async _doDownload() {
const remotePath = this.downloadRemotePathInput.value.trim();
this.ftStatus.textContent = "";
if (!remotePath) {
this.ftStatus.textContent = "Bitte einen Pfad angeben.";
return;
}
const filename = remotePath.split("/").filter(Boolean).pop() || "download";
this.ftStatus.textContent = `Lade ${filename} herunter ...`;
try {
const res = await fetch(
`/ssh/${this.hostId}/files/download?remote_path=${encodeURIComponent(remotePath)}`,
{ method: "GET", credentials: "same-origin" }
);
if (!res.ok) {
let message = "Download fehlgeschlagen";
try {
const data = await res.json();
message = data.detail || message;
} catch (_) { /* Antwort war kein JSON */ }
throw new Error(message);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
this.ftStatus.textContent = `Download abgeschlossen: ${filename}`;
this._recordTransfer("download", filename, blob.size, true, "");
this.downloadRemotePathInput.value = "";
} catch (err) {
this.ftStatus.textContent = "Download-Fehler: " + err.message;
this._recordTransfer("download", filename, 0, false, err.message);
}
}
// --- Abbau ------------------------------------------------------------
/** Idempotent: schliesst WebSocket + Terminal, entfernt dokumentweite
* Listener und das eigene DOM-Element. Sicher mehrfach aufrufbar (z.B.
* einmal ueber den Exit-Knopf, danach nochmal defensiv vom Aufrufer). */
dispose() {
if (this._disposed) return;
this._disposed = true;
if (this._pingTimer) {
window.clearInterval(this._pingTimer);
this._pingTimer = null;
}
this._closePasteMenu();
try {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
this.ws.close(1000, "Sitzung durch Benutzer beendet");
}
} catch (_) { /* bereits getrennt */ }
if (this._onWindowResize) {
window.removeEventListener("resize", this._onWindowResize);
document.removeEventListener("fullscreenchange", this._onWindowResize);
}
try {
this.term.dispose();
} catch (_) { /* ignorieren */ }
if (this.element.parentNode) this.element.parentNode.removeChild(this.element);
}
}
window.SshTerminalSession = SshTerminalSession;
// --- Bootstrap fuer die eigenstaendige Seite /terminal/{host_id} --------
//
// F1: templates/terminal.html liefert nur noch einen leeren Container mit
// data-host-id -- diese Seite bleibt als duenner Rahmen um denselben
// Baustein bestehen (F.2), keine zweite Implementierung. Die kuenftige
// Arbeitsflaeche /workspace (Teil F.3, spaetere Stufe) hat KEIN Element
// mit dieser ID und erzeugt ihre Instanzen stattdessen selbst -- dieser
// Bootstrap-Block bleibt dafuer wirkungslos, wenn terminal.js dort
// eingebunden wird.
const container = document.getElementById("session-container");
if (container) {
const hostId = container.dataset.hostId;
const session = new SshTerminalSession(hostId, {
onExitRequested: () => {
window.close();
// window.close() schliesst den Tab nur, wenn er per Skript/Link mit
// target="_blank" geoeffnet wurde (siehe dashboard.js) UND noch keine
// weitere Navigation darin stattfand -- sonst verweigern Browser das
// lautlos, ohne Fehler oder Rueckgabewert. Deshalb IMMER zusaetzlich
// nach kurzer Wartezeit zum Dashboard weiterleiten: war das Schliessen
// erfolgreich, existiert die Seite dann laengst nicht mehr und der
// Timeout laeuft ins Leere; sonst bleibt so ein Weg zurueck.
window.setTimeout(() => { window.location.href = "/dashboard"; }, 300);
},
});
container.appendChild(session.element);
session.connect();
session.setActive(true);
}
})();