// 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 { t } from "../modules/i18n.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(t("backup.foreignLicense"), "warnung"); if (b.secrets === "Unlesbar") return erstellePlakette(t("backup.unreadable"), "gefahr"); if (b.verschluesselt) return erstellePlakette(t("backup.licenseProtection", { count: b.secretCount }), "erfolg"); if (b.secrets === "Passphrase") return erstellePlakette(t("backup.passphraseProtection", { count: b.secretCount }), "neutral"); return erstellePlakette(t("backup.unencryptedProtection"), "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: t("backup.title") })), erstelleElement("div", { klasse: "gruppe-horizontal" }, erstelleElement("button", { klasse: "schaltflaeche flach", title: t("backup.refreshTooltip"), disabled: !instanzId, onclick: () => ladeSicherungen() }, erstelleIcon(PFADE.aktualisieren, 16), erstelleElement("span", { text: ` ${t("backup.refresh")}` })), erstelleElement("button", { klasse: "schaltflaeche primaer", title: t("backup.newBackupTooltip"), disabled: !instanzId, onclick: () => oeffneSicherungsDialog() }, erstelleIcon(PFADE.plus, 16), erstelleElement("span", { text: ` ${t("backup.newBackup")}` })) ) ); 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(t("backup.noInstance"), [ erstelleElement("p", { klasse: "hinweis", text: t("backup.selectInstancePrompt") }) ]) ); } // #764: the schedule of the active instance, and importing a backup as a new instance. const scheduleArea = erstelleElement("div", { klasse: "sicherung-ftp-bereich" }); const importArea = erstelleElement("div", { klasse: "sicherung-ftp-bereich" }); layout.append(infoBereich, inhaltBereich, scheduleArea, ftpBereich, importArea); container.append(layout); // Der FTP-Upload gilt für alle Instanzen und ist deshalb auch ohne Auswahl da. rendereFtp(ftpBereich); renderImport(importArea); if (instanzId) renderSchedule(scheduleArea); if (!instanzId) return; // ─── Liste ─── async function ladeSicherungen() { inhaltBereich.replaceChildren( erstelleElement("div", { klasse: "hinweis-zentriert", text: t("backup.loading") }) ); try { const daten = await api(`/verwaltung/instanzen/${pfadInstanz}/backups`); const backups = daten?.backups ?? []; infoBereich.replaceChildren( erstelleKarte(t("backup.backupDir"), [ erstelleElement("p", { klasse: "gedaempft", text: t("backup.backupDirHint") }), erstelleElement("div", { klasse: "ordner-pfad-anzeige" }, erstelleIcon(PFADE.server, 14), erstelleElement("code", { text: daten?.directory ?? "—" })) ]) ); if (backups.length === 0) { inhaltBereich.replaceChildren( erstelleKarte(t("backup.existingBackups", { count: 0 }), [ erstelleElement("p", { klasse: "gedaempft", text: t("backup.noBackups") }) ]) ); 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: t("backup.restoreTooltip"), disabled: b.secrets === "Unlesbar", onclick: () => oeffneRestoreDialog(b) }, erstelleIcon(PFADE.abspielen, 14), erstelleElement("span", { text: ` ${t("backup.restore")}` })), erstelleElement("button", { klasse: "schaltflaeche klein gefahr flach", title: t("backup.deleteTooltip"), 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 ? t("backup.fileCount", { count: b.fileCount }) : "—" }), aktionen )); } const tabelle = erstelleElement("table", { klasse: "tabelle sicherungen-tabelle" }, erstelleElement("thead", {}, erstelleElement("tr", {}, erstelleElement("th", { text: t("backup.archiveFile") }), erstelleElement("th", { text: t("backup.createdAt") }), erstelleElement("th", { text: t("backup.size") }), erstelleElement("th", { text: t("backup.protection") }), erstelleElement("th", { text: t("backup.files") }), erstelleElement("th", { klasse: "spalte-aktionen", text: t("backup.actions") }))), tbody); inhaltBereich.replaceChildren(erstelleKarte(t("backup.existingBackups", { count: 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", t("backup.includeChats"), true); const logs = checkbox("backup-chk-logs", t("backup.includeLogs"), false); form.append( erstelleElement("p", { klasse: "form-hilfe", text: t("backup.dialogHint") }), chats.gruppe, logs.gruppe ); let modal; const speichernBtn = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer", text: t("backup.createButton") }); form.addEventListener("submit", async (e) => { e.preventDefault(); speichernBtn.disabled = true; speichernBtn.textContent = t("backup.creating"); 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 || t("backup.created"), warnt ? "warnung" : "erfolg", warnt ? 8000 : undefined); modal.schliessen(); ladeSicherungen(); } catch (err) { zeigeMeldung(t("backup.createFailed", { error: err.message }), "gefahr"); speichernBtn.disabled = false; speichernBtn.textContent = t("backup.createButton"); } }); modal = erstelleModal(t("backup.dialogTitle"), form, [ erstelleElement("button", { type: "button", klasse: "schaltflaeche", text: t("common.cancel"), 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: t("backup.overwriteWarningTitle") }), erstelleElement("p", { text: t("backup.overwriteWarningText", { date: formatiereDatumZeit(backup.createdAt) }) }), erstelleElement("p", { text: t("backup.instanceStopNotice") })))); // 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: t("backup.passphrasePlaceholder"), required: true }); form.append(feld(t("backup.passphraseLabel"), passInput)); } let modal; const restoreBtn = erstelleElement("button", { type: "submit", klasse: "schaltflaeche warnung", text: t("backup.restoreNow") }); form.addEventListener("submit", async (e) => { e.preventDefault(); const passphrase = passInput?.value?.trim() || null; if (passInput && !passphrase) { zeigeMeldung(t("backup.passphraseRequired"), "warnung"); passInput.focus(); return; } restoreBtn.disabled = true; restoreBtn.textContent = t("backup.restoring"); try { const res = await stelleWiederHer(backup, { passphrase }); zeigeMeldung(res.meldung || t("backup.restoreSuccess"), "erfolg"); modal.schliessen(); ladeSicherungen(); } catch (err) { // Our own key does not match — the backup comes from another installation. // Nothing was stopped for this; the server checks that beforehand. if (err.code === "fremde_lizenz") { modal.schliessen(); frageLizenzschluessel(backup, err.message); return; } zeigeMeldung(t("backup.restoreFailed", { error: err.message }), "gefahr"); restoreBtn.disabled = false; restoreBtn.textContent = t("backup.restoreNow"); } }); modal = erstelleModal(t("backup.restoreTitle", { file: backup.fileName }), form, [ erstelleElement("button", { type: "button", klasse: "schaltflaeche", text: t("common.cancel"), 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: t("backup.sourceLicensePlaceholder"), autocomplete: "off", required: true }); form.append( erstelleElement("p", { text: grund }), feld(t("backup.sourceLicenseLabel"), eingabe, t("backup.sourceLicenseHint")) ); let modal; const okBtn = erstelleElement("button", { type: "submit", klasse: "schaltflaeche warnung", text: t("backup.restoreWithKey") }); form.addEventListener("submit", async (e) => { e.preventDefault(); const lizenzschluessel = eingabe.value.trim(); if (!lizenzschluessel) { eingabe.focus(); return; } okBtn.disabled = true; okBtn.textContent = t("backup.restoring"); try { const res = await stelleWiederHer(backup, { lizenzschluessel }); zeigeMeldung(res.meldung || t("backup.restoreSuccess"), "erfolg"); modal.schliessen(); ladeSicherungen(); } catch (err) { zeigeMeldung(err.code === "fremde_lizenz" ? t("backup.keyMismatch") : t("backup.restoreFailed", { error: err.message }), "gefahr"); okBtn.disabled = false; okBtn.textContent = t("backup.restoreWithKey"); eingabe.select(); } }); modal = erstelleModal(t("backup.foreignLicenseTitle"), form, [ erstelleElement("button", { type: "button", klasse: "schaltflaeche", text: t("common.cancel"), 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: t("common.delete"), onclick: async () => { loeschenBtn.disabled = true; try { const res = await api(`/verwaltung/instanzen/${pfadInstanz}/backups/${encodeURIComponent(fileName)}`, { method: "DELETE" }); zeigeMeldung(res.meldung || t("backup.deleted"), "erfolg"); modal.schliessen(); ladeSicherungen(); } catch (err) { zeigeMeldung(t("backup.deleteFailed", { error: err.message }), "gefahr"); loeschenBtn.disabled = false; } } }); modal = erstelleModal(t("backup.deleteTitle"), erstelleElement("p", { text: t("backup.deleteConfirm", { file: fileName }) }), [ erstelleElement("button", { type: "button", klasse: "schaltflaeche", text: t("common.cancel"), 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: t("backup.ftpLoading") })); let stand; try { stand = await api("/verwaltung/sicherung/ftp"); } catch (err) { bereich.replaceChildren(erstelleKarte(t("backup.ftpTitle"), [ erstelleElement("p", { klasse: "fehler-meldung", text: `Einstellungen nicht ladbar: ${err.message}` }) ])); return; } const form = erstelleElement("form", { klasse: "formular" }); const an = checkbox("ftp-an", t("backup.ftpAutoUpload"), 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 ? t("backup.ftpPasswordSaved") : t("backup.ftpPassword") }); 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", t("backup.ftpRequireTls"), stand.requireTls); form.append( erstelleElement("p", { klasse: "form-hilfe", text: t("backup.ftpHint") }), an.gruppe, feld(t("backup.ftpServer"), host), feld(t("backup.ftpPort"), port), feld(t("backup.ftpUser"), benutzer), feld(t("backup.ftpPassword"), kennwort), feld(t("backup.ftpDirectory"), ordner, t("backup.ftpDirectoryHint")), feld(t("backup.ftpKeepCount"), behalten, t("backup.ftpKeepCountHint")), tls.gruppe ); const speichernBtn = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer", text: t("common.save") }); const testBtn = erstelleElement("button", { type: "button", klasse: "schaltflaeche", text: t("backup.ftpTest"), 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(t("backup.ftpTestFailed", { error: 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 || t("backup.ftpSaved"), "erfolg"); kennwort.value = ""; rendereFtp(bereich); } catch (err) { zeigeMeldung(t("backup.ftpSaveFailed", { error: err.message }), "gefahr"); } finally { speichernBtn.disabled = false; } }); form.append(erstelleElement("div", { klasse: "gruppe-horizontal" }, testBtn, speichernBtn)); bereich.replaceChildren(erstelleKarte(t("backup.ftpTitle"), [form])); } // ─── Automatic backup of the active instance (#764) ─── // // The manager makes the daily backup and reads switch, time and retention from the instance // file. That file belongs to the instance process, so it is written through the instance // (/api/settings) — which is why the instance has to be running to change it. async function renderSchedule(area) { let settings; try { settings = await api("/settings"); } catch { area.replaceChildren(erstelleKarte(t("backup.scheduleTitle"), [ erstelleElement("p", { klasse: "hinweis", text: t("backup.scheduleNeedsRunning") }) ])); return; } const form = erstelleElement("form", { klasse: "formular" }); const enabled = checkbox("backup-auto", t("backup.scheduleEnabled"), settings.autoBackupEnabled); const time = erstelleElement("input", { type: "time", value: settings.autoBackupTime || "03:00" }); const keep = erstelleElement("input", { type: "number", min: "0", max: "365", value: String(settings.backupKeepCount ?? 14) }); const save = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer", text: t("common.save") }); form.append( enabled.gruppe, feld(t("backup.scheduleTime"), time), feld(t("backup.scheduleKeep"), keep, t("backup.scheduleKeepHint")), erstelleElement("div", { klasse: "gruppe-horizontal" }, save)); form.addEventListener("submit", async (e) => { e.preventDefault(); save.disabled = true; try { const result = await api("/settings", { method: "PUT", body: { autoBackupEnabled: enabled.box.checked, autoBackupTime: time.value, backupKeepCount: Number(keep.value) } }); zeigeMeldung(result?.message ?? t("common.saved"), "erfolg"); } catch (err) { zeigeMeldung(err.message, "gefahr"); } finally { save.disabled = false; } }); area.replaceChildren(erstelleKarte(t("backup.scheduleTitle"), [form])); } // ─── Import a backup as a new instance (#764) ─── // // The move to a fresh machine: there is no instance yet, so no backup list to pick from. // A backup from another installation needs that installation's license key; the server says // so (code "fremde_lizenz") and the field appears. function renderImport(area) { const form = erstelleElement("form", { klasse: "formular" }); const file = erstelleElement("input", { type: "file", accept: ".clawdbak,.zip", required: true }); const name = erstelleElement("input", { type: "text", required: true, placeholder: t("backup.importNamePlaceholder") }); const key = erstelleElement("input", { type: "password", autocomplete: "off", placeholder: t("backup.sourceLicensePlaceholder") }); const keyField = feld(t("backup.sourceLicenseLabel"), key, t("backup.sourceLicenseHint")); keyField.hidden = true; const submit = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer", text: t("backup.importButton") }); form.append( erstelleElement("p", { klasse: "gedaempft", text: t("backup.importHint") }), feld(t("backup.importFile"), file), feld(t("backup.importName"), name), keyField, erstelleElement("div", { klasse: "gruppe-horizontal" }, submit)); form.addEventListener("submit", async (e) => { e.preventDefault(); const chosen = file.files?.[0]; if (!chosen) return; const headers = { "Content-Type": "application/octet-stream" }; if (key.value.trim()) headers["X-Source-License-Key"] = key.value.trim(); submit.disabled = true; submit.textContent = t("backup.importing"); try { const result = await api(`/verwaltung/backups/import?name=${encodeURIComponent(name.value.trim())}`, { method: "POST", headers, body: chosen }); zeigeMeldung(result?.meldung ?? result?.message ?? t("backup.imported"), "erfolg", 8000); form.reset(); keyField.hidden = true; } catch (err) { if (err.code === "fremde_lizenz") keyField.hidden = false; zeigeMeldung(t("backup.importFailed", { error: err.message }), "gefahr", 8000); } finally { submit.disabled = false; submit.textContent = t("backup.importButton"); } }); area.replaceChildren(erstelleKarte(t("backup.importTitle"), [form])); }