// ClawdDotNet — Datensicherung & Wiederherstellung (#530) // // Jede Sicherung, die der Instanzmanager anlegt, ist als Ganzes mit seinem // Lizenzschlüssel verschlüsselt (.clawdbak). Stammt eine Sicherung von einer anderen // Installation, fragt die Wiederherstellung nach deren Lizenzschlüssel. // Dazu der optionale FTP-Upload. // Strikt DOM API, kein innerHTML. import { api, holeAktiveInstanz } from "../modules/api.js"; import { erstelleElement, erstelleKarte, erstellePlakette, erstelleIcon, erstelleModal, PFADE, formatiereDatumZeit, zeigeMeldung } from "../modules/dom.js"; function formatiereBytes(bytes) { if (bytes === 0) return "0 B"; if (!bytes) return "—"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; } /** Was eine Sicherung schützt — als Plakette. */ function schutzPlakette(b) { if (b.fremdeLizenz) return erstellePlakette("Fremde Lizenz", "warnung"); if (b.secrets === "Unlesbar") return erstellePlakette("Unlesbar", "gefahr"); if (b.verschluesselt) return erstellePlakette(`Lizenz (${b.secretCount} Zugangsdaten)`, "erfolg"); if (b.secrets === "Passphrase") return erstellePlakette(`Passphrase (${b.secretCount})`, "neutral"); return erstellePlakette("Unverschlüsselt, ohne Zugangsdaten", "neutral"); } function checkbox(id, text, an) { const gruppe = erstelleElement("div", { klasse: "form-gruppe-checkbox" }); const box = erstelleElement("input", { type: "checkbox", id, checked: an }); gruppe.append(box, erstelleElement("label", { for: id, text })); return { gruppe, box }; } function feld(label, eingabe, hilfe) { const gruppe = erstelleElement("div", { klasse: "form-gruppe" }); gruppe.append(erstelleElement("label", { text: label }), eingabe); if (hilfe) gruppe.append(erstelleElement("p", { klasse: "form-hilfe", text: hilfe })); return gruppe; } export async function rendereSicherung() { const container = document.getElementById("ansicht-inhalt"); if (!container) return; container.replaceChildren(); const instanzId = holeAktiveInstanz(); const pfadInstanz = encodeURIComponent(instanzId || ""); const layout = erstelleElement("div", { klasse: "sicherung-ansicht" }); // ─── Kopf ─── const kopf = erstelleElement("div", { klasse: "ansicht-kopf" }); kopf.append( erstelleElement("div", { klasse: "gruppe-horizontal" }, erstelleElement("h1", { text: "Datensicherung & Wiederherstellung" })), erstelleElement("div", { klasse: "gruppe-horizontal" }, erstelleElement("button", { klasse: "schaltflaeche flach", title: "Sicherungen neu laden", disabled: !instanzId, onclick: () => ladeSicherungen() }, erstelleIcon(PFADE.aktualisieren, 16), erstelleElement("span", { text: " Aktualisieren" })), erstelleElement("button", { klasse: "schaltflaeche primaer", title: "Neue Sicherung erstellen", disabled: !instanzId, onclick: () => oeffneSicherungsDialog() }, erstelleIcon(PFADE.plus, 16), erstelleElement("span", { text: " Neue Sicherung" })) ) ); layout.append(kopf); const infoBereich = erstelleElement("div", { klasse: "sicherung-info-bereich" }); const inhaltBereich = erstelleElement("div", { klasse: "sicherung-inhalt-bereich" }); const ftpBereich = erstelleElement("div", { klasse: "sicherung-ftp-bereich" }); if (!instanzId) { inhaltBereich.append( erstelleKarte("Keine Instanz ausgewählt", [ erstelleElement("p", { klasse: "hinweis", text: "Bitte wähle oben im Kopfbereich eine Instanz aus, um deren Sicherungen zu verwalten." }) ]) ); } layout.append(infoBereich, inhaltBereich, ftpBereich); container.append(layout); // Der FTP-Upload gilt für alle Instanzen und ist deshalb auch ohne Auswahl da. rendereFtp(ftpBereich); if (!instanzId) return; // ─── Liste ─── async function ladeSicherungen() { inhaltBereich.replaceChildren( erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Sicherungen…" }) ); try { const daten = await api(`/verwaltung/instanzen/${pfadInstanz}/backups`); const backups = daten?.backups ?? []; infoBereich.replaceChildren( erstelleKarte("Sicherungsverzeichnis", [ erstelleElement("p", { klasse: "gedaempft", text: "Sicherungen werden als Ganzes mit dem Lizenzschlüssel des Instanzmanagers " + "verschlüsselt und enthalten die Zugangsdaten. Auf einer anderen Maschine lassen " + "sie sich mit demselben Lizenzschlüssel zurückspielen." }), erstelleElement("div", { klasse: "ordner-pfad-anzeige" }, erstelleIcon(PFADE.server, 14), erstelleElement("code", { text: daten?.directory ?? "—" })) ]) ); if (backups.length === 0) { inhaltBereich.replaceChildren( erstelleKarte("Vorhandene Sicherungen", [ erstelleElement("p", { klasse: "gedaempft", text: "Für diese Instanz gibt es noch keine Sicherung. Lege über die Schaltfläche oben eine an." }) ]) ); return; } const tbody = erstelleElement("tbody"); for (const b of backups) { const aktionen = erstelleElement("td", { klasse: "spalte-aktionen" }, erstelleElement("button", { klasse: "schaltflaeche klein sekundär", title: "Diese Sicherung wiederherstellen", disabled: b.secrets === "Unlesbar", onclick: () => oeffneRestoreDialog(b) }, erstelleIcon(PFADE.abspielen, 14), erstelleElement("span", { text: " Wiederherstellen" })), erstelleElement("button", { klasse: "schaltflaeche klein gefahr flach", title: "Sicherungsdatei unwiderruflich löschen", onclick: () => oeffneLoeschenDialog(b.fileName) }, erstelleIcon(PFADE.muelleimer, 14)) ); tbody.append(erstelleElement("tr", {}, erstelleElement("td", { klasse: "dateiname-zelle" }, erstelleIcon(PFADE.sicherung, 16), erstelleElement("strong", { text: b.fileName })), erstelleElement("td", { text: formatiereDatumZeit(b.createdAt) }), erstelleElement("td", { text: formatiereBytes(b.sizeBytes) }), erstelleElement("td", {}, schutzPlakette(b)), erstelleElement("td", { text: b.fileCount > 0 ? `${b.fileCount} Dateien` : "—" }), aktionen )); } const tabelle = erstelleElement("table", { klasse: "tabelle sicherungen-tabelle" }, erstelleElement("thead", {}, erstelleElement("tr", {}, erstelleElement("th", { text: "Archivdatei" }), erstelleElement("th", { text: "Erstellt am" }), erstelleElement("th", { text: "Größe" }), erstelleElement("th", { text: "Schutz" }), erstelleElement("th", { text: "Dateien" }), erstelleElement("th", { klasse: "spalte-aktionen", text: "Aktionen" }))), tbody); inhaltBereich.replaceChildren(erstelleKarte(`Vorhandene Sicherungen (${backups.length})`, [tabelle])); } catch (err) { inhaltBereich.replaceChildren( erstelleKarte("Fehler", [ erstelleElement("p", { klasse: "fehler-meldung", text: `Sicherungen konnten nicht geladen werden: ${err.message}` }) ]) ); } } // ─── Neue Sicherung ─── function oeffneSicherungsDialog() { const form = erstelleElement("form", { klasse: "formular" }); const chats = checkbox("backup-chk-chats", "Chatverläufe sichern", true); const logs = checkbox("backup-chk-logs", "Protokolldateien (Logs) einschließen", false); form.append( erstelleElement("p", { klasse: "form-hilfe", text: "Die Sicherung wird mit dem Lizenzschlüssel des Instanzmanagers verschlüsselt und " + "enthält die Zugangsdaten. Ist ein FTP-Upload eingerichtet, wird sie anschließend hochgeladen." }), chats.gruppe, logs.gruppe ); let modal; const speichernBtn = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer", text: "Sicherung erstellen" }); form.addEventListener("submit", async (e) => { e.preventDefault(); speichernBtn.disabled = true; speichernBtn.textContent = "Erstelle Sicherung…"; try { const res = await api(`/verwaltung/instanzen/${pfadInstanz}/backups`, { method: "POST", body: { includeLogs: logs.box.checked, includeChatHistory: chats.box.checked } }); // Ein gescheiterter Upload steht in der Meldung; die Sicherung liegt trotzdem lokal. const warnt = (res.meldung || "").includes("gescheitert"); zeigeMeldung(res.meldung || "Sicherung erstellt.", warnt ? "warnung" : "erfolg", warnt ? 8000 : undefined); modal.schliessen(); ladeSicherungen(); } catch (err) { zeigeMeldung(`Fehler beim Sichern: ${err.message}`, "gefahr"); speichernBtn.disabled = false; speichernBtn.textContent = "Sicherung erstellen"; } }); modal = erstelleModal("Neue Sicherung erstellen", form, [ erstelleElement("button", { type: "button", klasse: "schaltflaeche", text: "Abbrechen", onclick: () => modal.schliessen() }), speichernBtn ]); modal.oeffnen(); } // ─── Wiederherstellen ─── async function stelleWiederHer(backup, { passphrase = null, lizenzschluessel = null } = {}) { return api(`/verwaltung/instanzen/${pfadInstanz}/backups/${encodeURIComponent(backup.fileName)}/restore`, { method: "POST", body: { passphrase, lizenzschluessel } }); } function oeffneRestoreDialog(backup) { const form = erstelleElement("form", { klasse: "formular" }); form.append(erstelleElement("div", { klasse: "hinweis-box warnung" }, erstelleIcon(PFADE.ausrufezeichen, 18), erstelleElement("div", {}, erstelleElement("strong", { text: "Achtung: Überschreiben bestehender Daten" }), erstelleElement("p", { text: `Die Wiederherstellung überschreibt die aktuellen Konfigurationen und Daten der Instanz mit dem Stand vom ${formatiereDatumZeit(backup.createdAt)}.` }), erstelleElement("p", { text: "Die Instanz wird dafür geordnet angehalten und danach wieder gestartet." })))); // Nur ältere, unverschlüsselte Sicherungen mit Passphrase brauchen sie noch. let passInput = null; if (backup.secrets === "Passphrase" && !backup.verschluesselt) { passInput = erstelleElement("input", { type: "password", klasse: "eingabe", placeholder: "Passphrase der Sicherung…", required: true }); form.append(feld("Passphrase für Zugangsdaten", passInput)); } let modal; const restoreBtn = erstelleElement("button", { type: "submit", klasse: "schaltflaeche warnung", text: "Jetzt wiederherstellen" }); form.addEventListener("submit", async (e) => { e.preventDefault(); const passphrase = passInput?.value?.trim() || null; if (passInput && !passphrase) { zeigeMeldung("Bitte gib die Passphrase an.", "warnung"); passInput.focus(); return; } restoreBtn.disabled = true; restoreBtn.textContent = "Stelle wieder her…"; try { const res = await stelleWiederHer(backup, { passphrase }); zeigeMeldung(res.meldung || "Wiederherstellung abgeschlossen.", "erfolg"); modal.schliessen(); ladeSicherungen(); } catch (err) { if (err.code === "fremde_lizenz") { // Der eigene Schlüssel passt nicht - die Sicherung stammt von einer anderen // Installation. Angehalten wurde dafür nichts; das prüft der Server vorher. modal.schliessen(); frageLizenzschluessel(backup, err.message); return; } zeigeMeldung(`Wiederherstellung fehlgeschlagen: ${err.message}`, "gefahr"); restoreBtn.disabled = false; restoreBtn.textContent = "Jetzt wiederherstellen"; } }); modal = erstelleModal(`Sicherung wiederherstellen: ${backup.fileName}`, form, [ erstelleElement("button", { type: "button", klasse: "schaltflaeche", text: "Abbrechen", onclick: () => modal.schliessen() }), restoreBtn ]); modal.oeffnen(); } /** * Das Fenster, das nach dem Lizenzschlüssel der Quellinstallation fragt. * Erscheint, wenn der eigene Schlüssel die Sicherung nicht öffnet. */ function frageLizenzschluessel(backup, grund) { const form = erstelleElement("form", { klasse: "formular" }); const eingabe = erstelleElement("input", { type: "password", klasse: "eingabe", placeholder: "Lizenzschlüssel der Installation, von der die Sicherung stammt…", autocomplete: "off", required: true }); form.append( erstelleElement("p", { text: grund }), feld("Lizenzschlüssel der Quelle", eingabe, "Groß- und Kleinschreibung sowie Leerzeichen spielen keine Rolle.") ); let modal; const okBtn = erstelleElement("button", { type: "submit", klasse: "schaltflaeche warnung", text: "Mit diesem Schlüssel wiederherstellen" }); form.addEventListener("submit", async (e) => { e.preventDefault(); const lizenzschluessel = eingabe.value.trim(); if (!lizenzschluessel) { eingabe.focus(); return; } okBtn.disabled = true; okBtn.textContent = "Stelle wieder her…"; try { const res = await stelleWiederHer(backup, { lizenzschluessel }); zeigeMeldung(res.meldung || "Wiederherstellung abgeschlossen.", "erfolg"); modal.schliessen(); ladeSicherungen(); } catch (err) { zeigeMeldung(err.code === "fremde_lizenz" ? "Dieser Lizenzschlüssel passt nicht zu der Sicherung." : `Wiederherstellung fehlgeschlagen: ${err.message}`, "gefahr"); okBtn.disabled = false; okBtn.textContent = "Mit diesem Schlüssel wiederherstellen"; eingabe.select(); } }); modal = erstelleModal("Lizenzschlüssel der Quelle erforderlich", form, [ erstelleElement("button", { type: "button", klasse: "schaltflaeche", text: "Abbrechen", onclick: () => modal.schliessen() }), okBtn ]); modal.oeffnen(); eingabe.focus(); } // ─── Löschen ─── function oeffneLoeschenDialog(fileName) { let modal; const loeschenBtn = erstelleElement("button", { type: "button", klasse: "schaltflaeche gefahr", text: "Löschen", onclick: async () => { loeschenBtn.disabled = true; try { const res = await api(`/verwaltung/instanzen/${pfadInstanz}/backups/${encodeURIComponent(fileName)}`, { method: "DELETE" }); zeigeMeldung(res.meldung || "Sicherung gelöscht.", "erfolg"); modal.schliessen(); ladeSicherungen(); } catch (err) { zeigeMeldung(`Löschen fehlgeschlagen: ${err.message}`, "gefahr"); loeschenBtn.disabled = false; } } }); modal = erstelleModal("Sicherung löschen", erstelleElement("p", { text: `Soll die Sicherungsdatei '${fileName}' wirklich unwiderruflich gelöscht werden?` }), [ erstelleElement("button", { type: "button", klasse: "schaltflaeche", text: "Abbrechen", onclick: () => modal.schliessen() }), loeschenBtn ]); modal.oeffnen(); } ladeSicherungen(); } // ─── FTP-Upload (für alle Instanzen) ─── async function rendereFtp(bereich) { bereich.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade FTP-Einstellungen…" })); let stand; try { stand = await api("/verwaltung/sicherung/ftp"); } catch (err) { bereich.replaceChildren(erstelleKarte("FTP-Upload", [ erstelleElement("p", { klasse: "fehler-meldung", text: `Einstellungen nicht ladbar: ${err.message}` }) ])); return; } const form = erstelleElement("form", { klasse: "formular" }); const an = checkbox("ftp-an", "Fertige Sicherungen automatisch hochladen", stand.enabled); const host = erstelleElement("input", { type: "text", klasse: "eingabe", value: stand.host ?? "", placeholder: "ftp.example.org" }); const port = erstelleElement("input", { type: "number", klasse: "eingabe", value: String(stand.port ?? 21), min: "1", max: "65535" }); const benutzer = erstelleElement("input", { type: "text", klasse: "eingabe", value: stand.username ?? "", autocomplete: "off" }); const kennwort = erstelleElement("input", { type: "password", klasse: "eingabe", autocomplete: "new-password", placeholder: stand.kennwortGesetzt ? "(gespeichert — leer lassen für unverändert)" : "Kennwort" }); const ordner = erstelleElement("input", { type: "text", klasse: "eingabe", value: stand.remoteDirectory ?? "/clawd-backups" }); const behalten = erstelleElement("input", { type: "number", klasse: "eingabe", value: String(stand.keepCount ?? 14), min: "1", max: "365" }); const tls = checkbox("ftp-tls", "Nur mit TLS verbinden", stand.requireTls); form.append( erstelleElement("p", { klasse: "form-hilfe", text: "Die Sicherungen sind verschlüsselt — wer den Server liest, liest nichts Brauchbares. " + "Das Kennwort des Servers geht ohne TLS aber im Klartext über die Leitung." }), an.gruppe, feld("Server", host), feld("Port", port), feld("Benutzer", benutzer), feld("Kennwort", kennwort), feld("Zielordner", ordner, "Darunter bekommt jede Instanz einen eigenen Ordner."), feld("Sicherungen je Instanz behalten", behalten, "Ältere werden auf dem Server gelöscht."), tls.gruppe ); const speichernBtn = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer", text: "Speichern" }); const testBtn = erstelleElement("button", { type: "button", klasse: "schaltflaeche", text: "Verbindung testen", onclick: async () => { testBtn.disabled = true; try { const res = await api("/verwaltung/sicherung/ftp/test", { method: "POST" }); zeigeMeldung(res.meldung, res.ok ? "erfolg" : "warnung", 8000); } catch (err) { zeigeMeldung(`Test fehlgeschlagen: ${err.message}`, "gefahr"); } finally { testBtn.disabled = false; } } }); form.addEventListener("submit", async (e) => { e.preventDefault(); speichernBtn.disabled = true; try { const res = await api("/verwaltung/sicherung/ftp", { method: "PUT", body: { enabled: an.box.checked, host: host.value.trim(), port: Number(port.value) || 21, username: benutzer.value.trim(), password: kennwort.value || null, remoteDirectory: ordner.value.trim(), requireTls: tls.box.checked, keepCount: Number(behalten.value) || 14 } }); zeigeMeldung(res.meldung || "Gespeichert.", "erfolg"); kennwort.value = ""; rendereFtp(bereich); } catch (err) { zeigeMeldung(`Speichern fehlgeschlagen: ${err.message}`, "gefahr"); } finally { speichernBtn.disabled = false; } }); form.append(erstelleElement("div", { klasse: "gruppe-horizontal" }, testBtn, speichernBtn)); bereich.replaceChildren(erstelleKarte("FTP-Upload der Sicherungen", [form])); }