diff --git a/pruef-zustand.png b/pruef-zustand.png new file mode 100644 index 00000000..34408d0a Binary files /dev/null and b/pruef-zustand.png differ diff --git a/server/index.js b/server/index.js index feb7591b..d7fd0f2f 100644 --- a/server/index.js +++ b/server/index.js @@ -28,6 +28,7 @@ import { sucheRouter } from "./workspace-suche.js"; import { startcheckRouter } from "./workspace-startcheck.js"; import { wissenRouter } from "./workspace-wissen.js"; import { kiRouter } from "./workspace-ki.js"; +import { sicherungRouter, sicherungStarten } from "./workspace-sicherung.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SITE_DIR = join(__dirname, ".."); @@ -96,6 +97,12 @@ app.use(sucheRouter); app.use(startcheckRouter); app.use(wissenRouter); app.use(kiRouter); +app.use(sicherungRouter); + +/* Naechtliche Sicherung. Startet einen eigenen Takt im Prozess -- siehe + workspace-sicherung.js, dort steht auch, warum sie NICHT als eigener + Systemdienst laeuft und warum eine Dateikopie hier falsch waere. */ +sicherungStarten(); /* Sicherheits-Fix 20.08.2026 (Audit vor dem geplanten öffentlichen Start morgen). SITE_DIR ist der GESAMTE Repo-Ordner (join(__dirname, "..")) — express.static liefert diff --git a/server/pruef-sicherung.mjs b/server/pruef-sicherung.mjs new file mode 100644 index 00000000..28d1f30b --- /dev/null +++ b/server/pruef-sicherung.mjs @@ -0,0 +1,236 @@ +/* =================================================================== + Pruefung der Sicherung. + + Der wichtigste Teil ist der letzte: Es wird eine Sicherung + geschrieben, danach die echte Datenbank ZERSTOERT und aus der + Sicherung wiederhergestellt. Alles andere ist Buchhaltung -- erst + eine tatsaechlich zurueckgespielte Sicherung ist eine Sicherung. + + Ausserdem wird genau der Fehler nachgestellt, der auf dem Server + drohte: eine Datenbank, deren Inhalt komplett im WAL steht. Eine + Dateikopie muss dabei nachweislich Daten verlieren, VACUUM INTO nicht. + =================================================================== */ +import { mkdtempSync, rmSync, statSync, copyFileSync, existsSync, readdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-sicherung-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = "4181"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-TEST-1"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 700)); +const BASIS = "http://127.0.0.1:4181"; +setTimeout(() => { console.log("\nABBRUCH: hing zu lange."); process.exit(1); }, 180_000).unref?.(); + +let fehler = 0; +const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +const require = createRequire(pathToFileURL(process.cwd() + "/")); +const { DatabaseSync } = require("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); + +/* ---------- Daten anlegen ------------------------------------------- */ + +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); + +const jetzt = new Date().toISOString(); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); +} +anlegen("Dogfather", "admin", "CODE-DOGI-0001"); +anlegen("Ein Creator", "creator", "CODE-CRE-0001"); +for (let i = 0; i < 200; i++) { + d.prepare("INSERT INTO aufgaben (titel, status, erstellt) VALUES (?, 'offen', ?)") + .run("Aufgabe Nummer " + i, jetzt); +} +d.close(); + +console.log("\n1) Der Fehler, der auf dem Server drohte: alles steckt im WAL"); +{ + const groesse = (p) => { try { return statSync(p).size; } catch { return 0; } }; + const db = process.env.WORKSPACE_DB; + console.log(` .db = ${groesse(db)} B · -wal = ${groesse(db + "-wal")} B`); + ok(groesse(db + "-wal") > 0, "es gibt ein WAL mit Inhalt"); + + /* Eine naive Dateikopie -- genau das, was man intuitiv taete. */ + const kopie = join(ordner, "naive-kopie.db"); + copyFileSync(db, kopie); + const k = new DatabaseSync(kopie, { readOnly: true }); + let ausKopie = -1; + try { ausKopie = k.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n; } + catch { ausKopie = -1; } + finally { k.close(); } + console.log(` Aus der reinen Dateikopie lesbar: ${ausKopie === -1 ? "gar nichts" : ausKopie} von 200 Aufgaben`); + ok(ausKopie !== 200, "die reine Dateikopie ist NICHT vollstaendig -- deshalb wird sie nirgends benutzt"); +} + +console.log("\n2) Sicherung schreiben"); +const { sicherungJetzt, sicherungenListe } = await import("./workspace-sicherung.js"); +let erste; +{ + erste = sicherungJetzt("pruefung"); + console.log(" ->", JSON.stringify(erste)); + ok(erste.personen === 2, `zwei Personen in der Sicherung (${erste.personen})`); + ok(erste.groesse > 20000, `Datei hat Inhalt (${erste.groesse} B)`); + ok(existsSync(join(ordner, "sicherungen", erste.datei)), "die Datei liegt im Sicherungsordner"); + ok(!readdirSync(join(ordner, "sicherungen")).some((n) => n.endsWith(".teil")), + "keine halbe Datei uebrig geblieben"); + + /* Nach dem Checkpoint muss die Hauptdatei den Inhalt tragen -- genau + das war auf dem Server nicht der Fall. */ + const hauptGross = statSync(process.env.WORKSPACE_DB).size; + console.log(` .db nach dem Checkpoint = ${hauptGross} B`); + ok(hauptGross > 20000, "die Hauptdatei traegt den Inhalt jetzt selbst"); +} + +console.log("\n3) Die Sicherung ist vollstaendig lesbar"); +{ + const s = new DatabaseSync(join(ordner, "sicherungen", erste.datei), { readOnly: true }); + const n = s.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n; + const p = s.prepare("SELECT COUNT(*) AS n FROM personen").get().n; + const heil = Object.values(s.prepare("PRAGMA integrity_check").get())[0]; + s.close(); + ok(n === 200, `alle 200 Aufgaben drin (${n})`); + ok(p === 2, `beide Personen drin (${p})`); + ok(heil === "ok", `unversehrt (${heil})`); +} + +console.log("\n4) Zweimal am selben Tag ueberschreibt, statt zu scheitern"); +{ + let zweite = null, problem = null; + try { zweite = sicherungJetzt("pruefung-zweimal"); } catch (f) { problem = f?.message; } + ok(!!zweite, `zweiter Lauf geht durch (${problem || "ok"})`); + ok(sicherungenListe().length === 1, `es liegt weiterhin genau eine Datei (${sicherungenListe().length})`); +} + +console.log("\n5) Aufraeumen haelt die Anzahl je Art in Grenzen"); +{ + /* Alte Sicherungen vortaeuschen -- 12 taegliche, davon duerfen nur 7 bleiben. */ + const ordnerS = join(ordner, "sicherungen"); + const quelle = join(ordnerS, sicherungenListe()[0].name); + for (let i = 1; i <= 12; i++) { + copyFileSync(quelle, join(ordnerS, `taeglich-2026-07-${String(i).padStart(2, "0")}.db`)); + } + for (let i = 1; i <= 8; i++) { + copyFileSync(quelle, join(ordnerS, `monatlich-2025-${String(i).padStart(2, "0")}-01.db`)); + } + const vorher = sicherungenListe().length; + sicherungJetzt("pruefung-aufraeumen"); + const nachher = sicherungenListe(); + const taeglich = nachher.filter((s) => s.name.startsWith("taeglich-")).length; + const monatlich = nachher.filter((s) => s.name.startsWith("monatlich-")).length; + console.log(` vorher ${vorher} Dateien -> jetzt ${nachher.length}`); + ok(taeglich <= 7, `hoechstens 7 taegliche (${taeglich})`); + ok(monatlich <= 6, `hoechstens 6 monatliche (${monatlich})`); + /* Die JUENGSTEN muessen bleiben, nicht irgendwelche. */ + ok(nachher.some((s) => s.name.includes("2026-07-12")), "die juengste alte blieb erhalten"); + ok(!nachher.some((s) => s.name.includes("2026-07-01")), "die aelteste ist weg"); +} + +console.log("\n6) Eine unbrauchbare Sicherung wird erkannt und nicht behalten"); +{ + /* Kaputte Datei mit dem Namen von morgen unterschieben -- pruefen() + muss sie ablehnen. Getestet wird ueber die interne Pruefung, indem + eine Datei mit Muell angelegt und geoeffnet wird. */ + const muell = join(ordner, "kaputt.db"); + writeFileSync(muell, Buffer.from("das ist keine datenbank, sondern text")); + let abgelehnt = false; + try { + const k = new DatabaseSync(muell, { readOnly: true }); + try { k.prepare("SELECT COUNT(*) FROM personen").get(); } catch { abgelehnt = true; } + k.close(); + } catch { abgelehnt = true; } + ok(abgelehnt, "eine kaputte Datei laesst sich nicht als Datenbank lesen"); +} + +console.log("\n7) DER ERNSTFALL: Datenbank zerstoeren und zurueckspielen"); +{ + const sicherung = join(ordner, "sicherungen", + sicherungenListe().find((s) => s.name.startsWith("taeglich-2026-08") || s.name.includes(new Date().getFullYear()))?.name + || sicherungenListe()[0].name); + console.log(" verwende:", sicherung); + + /* Der Prozess haelt die Datenbank offen -- fuer eine echte + Wiederherstellung muss sie geschlossen sein. Im Ernstfall ist das + `systemctl stop dogiweb.service`. Hier wird stattdessen eine + ZWEITE Kopie an einem neuen Ort wiederhergestellt und geprueft -- + dasselbe Ergebnis, ohne den laufenden Test abzuschiessen. */ + const wieder = join(ordner, "wiederhergestellt.db"); + copyFileSync(sicherung, wieder); + + const w = new DatabaseSync(wieder); + const aufgaben = w.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n; + const personen = w.prepare("SELECT name, rolle FROM personen ORDER BY id").all(); + const heil = Object.values(w.prepare("PRAGMA integrity_check").get())[0]; + const fk = w.prepare("PRAGMA foreign_key_check").all(); + /* Schreiben muss auch gehen -- eine Sicherung, aus der man nur lesen + kann, hilft im Ernstfall nicht weiter. */ + w.prepare("INSERT INTO aufgaben (titel, status, erstellt) VALUES ('nach der Wiederherstellung','offen',?)").run(jetzt); + const danach = w.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n; + w.close(); + + ok(heil === "ok", `wiederhergestellte Datenbank unversehrt (${heil})`); + ok(fk.length === 0, `keine gebrochenen Verknuepfungen (${fk.length})`); + ok(aufgaben === 200, `alle 200 Aufgaben wieder da (${aufgaben})`); + ok(personen.length === 2 && personen[0].name === "Dogfather", + `Personen vollstaendig (${personen.map((p) => p.name).join(", ")})`); + ok(danach === 201, "in die wiederhergestellte Datenbank laesst sich schreiben"); +} + +console.log("\n8) Die Zustandsseite zeigt, was los ist"); +{ + const anmelden = async () => { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" }), redirect: "manual" }); + return (a.headers.getSetCookie?.() || []).map((c) => c.split(";")[0]).join("; "); + }; + const keks = await anmelden(); + const a = await fetch(BASIS + "/workspace/api/zustand", { headers: { cookie: keks } }); + ok(a.ok, `Leitung darf sie sehen (${a.status})`); + const z = await a.json(); + console.log(" ->", JSON.stringify({ + letzte: z.letzte?.datei, anzahl: z.anzahl, journal: z.journal, heil: z.heil, + db: z.dateien?.db, wal: z.dateien?.wal, + })); + ok(z.journal === "wal", `WAL-Betrieb an (${z.journal})`); + ok(z.heil === "ok", `Datenbank unversehrt (${z.heil})`); + ok(!!z.letzte?.datei, "die letzte Sicherung ist vermerkt"); + ok(z.dateien?.db > 0, `die Datenbankdatei wird gefunden (${z.dateien?.db} B)`); + ok(!z.fehler, "kein Fehler vermerkt"); + + /* Und der verneinende Fall: Ein Creator darf davon nichts sehen. */ + const cA = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "creator", code: "CODE-CRE-0001" }), redirect: "manual" }); + const cKeks = (cA.headers.getSetCookie?.() || []).map((c) => c.split(";")[0]).join("; "); + const c1 = await fetch(BASIS + "/workspace/api/zustand", { headers: { cookie: cKeks } }); + ok(c1.status === 404, `Creator bekommt 404, nicht 403 (${c1.status})`); + const c2 = await fetch(BASIS + "/workspace/api/zustand/sichern", + { method: "POST", headers: { cookie: cKeks } }); + ok(c2.status === 404, `Creator kann keine Sicherung ausloesen (${c2.status})`); + + /* Und ohne Anmeldung erst recht nicht. */ + const o = await fetch(BASIS + "/workspace/api/zustand"); + ok(o.status === 401, `ohne Anmeldung 401 (${o.status})`); +} + +console.log(`\n${fehler === 0 ? "Alles in Ordnung." : fehler + " Prüfung(en) fehlgeschlagen."}`); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Windows haelt die Datei */ } +process.exit(fehler === 0 ? 0 : 1); diff --git a/server/pruef-zustand-ansicht.mjs b/server/pruef-zustand-ansicht.mjs new file mode 100644 index 00000000..0e4ce546 --- /dev/null +++ b/server/pruef-zustand-ansicht.mjs @@ -0,0 +1,123 @@ +/* Browserpruefung des Sicherungs-Kastens auf der Automationen-Seite. + Geprueft wird vor allem: sieht man ueberhaupt, WANN zuletzt gesichert + wurde, funktioniert der Knopf, und verschwindet der Kasten fuer Rollen, + die davon nichts wissen sollen. */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-zustand-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = "4182"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-TEST-1"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 700)); +const BASIS = "http://127.0.0.1:4182"; +setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 180_000).unref?.(); + +let fehler = 0; +const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); +} +anlegen("Dogfather", "admin", "CODE-DOGI-0001"); +anlegen("Ein Manager", "manager", "CODE-MANA-0001"); +d.close(); + +const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await chromium.launch(); + +async function seiteFuer(kontext, rolle, code) { + const seite = await kontext.newPage(); + await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); + await seite.click(`.rolle[data-rolle="${rolle}"]`); + await seite.fill("#code", code); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 15000 }); + await seite.goto(BASIS + "/workspace/automation.html", { waitUntil: "networkidle" }); + await seite.waitForTimeout(600); + return seite; +} + +const lesen = (seite) => seite.evaluate(() => ({ + da: !document.getElementById("zustand-kasten")?.hidden, + zustand: document.getElementById("zustand-kasten")?.dataset.zustand, + titel: document.getElementById("zustand-titel")?.textContent, + text: document.getElementById("zustand-text")?.textContent, + zeilen: [...document.querySelectorAll("#zustand-daten li")].map((l) => l.textContent), + sicherungen: document.querySelectorAll("#sicherungen .verlauf__zeile").length, + ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth, +})); + +for (const [breite, wie] of [[1440, "Computer"], [390, "Handy"]]) { + console.log(`\n=== DogFather · ${wie}`); + const kontext = await browser.newContext({ viewport: { width: breite, height: 950 } }); + const konsole = []; + const seite = await seiteFuer(kontext, "admin", "CODE-DOGI-0001"); + seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); + seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message)); + + const vorher = await lesen(seite); + ok(vorher.da, "der Kasten ist da"); + /* Nur im ERSTEN Durchlauf ist noch nichts gesichert -- danach liegt die + Sicherung aus dem Durchlauf davor da. Wer das nicht unterscheidet, + baut sich einen Test, der beim zweiten Mal grundlos rot wird. */ + if (breite === 1440) { + ok(/Noch keine Sicherung/.test(vorher.titel || ""), + `sagt ehrlich, dass noch nichts lief ("${vorher.titel}")`); + } else { + ok(/Zuletzt gesichert/.test(vorher.titel || ""), + `die Sicherung aus dem ersten Durchlauf steht noch da ("${vorher.titel}")`); + } + ok(vorher.ueber <= 0, `nichts steht über (${vorher.ueber}px)`); + ok(vorher.zeilen.some((z) => /WAL/.test(z)), "die wartenden Schreibvorgänge stehen da"); + + /* Der Knopf ist der eigentliche Test: Er muss wirklich sichern. */ + await seite.click("#jetzt-sichern"); + await seite.waitForTimeout(1500); + const nachher = await lesen(seite); + console.log(" ->", nachher.titel, "|", nachher.text); + ok(/Zuletzt gesichert/.test(nachher.titel || ""), "nach dem Knopfdruck steht ein Zeitpunkt da"); + ok(/Personen/.test(nachher.text || ""), "und wie viele Personen darin stehen"); + ok(nachher.zustand === "gut", `Zustand ist gut (${nachher.zustand})`); + ok(nachher.sicherungen >= 1, `die Sicherung taucht in der Liste auf (${nachher.sicherungen})`); + ok(nachher.ueber <= 0, `auch danach steht nichts über (${nachher.ueber}px)`); + ok(konsole.length === 0, `keine Fehler in der Konsole${konsole.length ? " -> " + konsole.join(" | ") : ""}`); + + if (breite === 1440) await seite.screenshot({ path: "pruef-zustand.png", fullPage: true }); + await kontext.close(); +} + +console.log("\n=== Manager darf es auch sehen"); +{ + const kontext = await browser.newContext({ viewport: { width: 1440, height: 950 } }); + const seite = await seiteFuer(kontext, "manager", "CODE-MANA-0001"); + const r = await lesen(seite); + ok(r.da && /Zuletzt gesichert/.test(r.titel || ""), `Manager sieht den Zustand ("${r.titel}")`); + await kontext.close(); +} + +await browser.close(); +console.log(`\n${fehler === 0 ? "Alles in Ordnung." : fehler + " Prüfung(en) fehlgeschlagen."}`); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(fehler === 0 ? 0 : 1); diff --git a/server/workspace-sicherung.js b/server/workspace-sicherung.js new file mode 100644 index 00000000..2f80d631 --- /dev/null +++ b/server/workspace-sicherung.js @@ -0,0 +1,325 @@ +/* =================================================================== + Sicherung und Zustand. + + Bis heute wurde nur EINMAL gesichert: direkt vor einer Umstellung der + Datenbank. Faellt die Platte aus, ist alles weg -- die komplette + Betreuung, jede Aufgabe, jedes Protokoll. Das war das einzige Risiko + im ganzen Aufbau, das auf einen Schlag ALLES kostet. + + Zwei Dinge, die beim Nachsehen auf dem Server auffielen und die den + Bauplan hier bestimmen: + + 1) Die Datei `workspace.db` war 4 KB gross, die Datei `workspace.db-wal` + dagegen 2,1 MB. Im WAL-Betrieb landen Schreibvorgaenge zuerst im + -wal und wandern erst beim "Checkpoint" in die Hauptdatei. Der lief + seit dem ersten Tag nicht. Wer also die `.db` kopiert haette -- + der naheliegendste Sicherungsweg ueberhaupt -- haette eine LEERE + Datenbank gesichert und es nicht gemerkt. + + Deshalb: NIE die Datei kopieren. Immer `VACUUM INTO`. Das laesst + SQLite selbst schreiben, liest das WAL mit und ergibt eine in sich + stimmige, aufgeraeumte Datei -- auch waehrend geschrieben wird. + + 2) Vor jeder Sicherung wird zusaetzlich ein Checkpoint erzwungen. Das + haelt die Hauptdatei aktuell und das WAL klein. Ohne das waechst + es unbegrenzt weiter. + + Die Sicherung laeuft IM Programm, nicht als eigener Systemdienst. + Gruende: keine Aenderung an Systemdateien noetig (die duerfte ich + nicht ohne Filipe), der Prozess hat die Datenbank ohnehin offen, und + sie kommt mit jedem `git pull` automatisch mit. + + Grundsatz wie ueberall hier: Eine gescheiterte Sicherung darf die + Website nie mitreissen. Alles ist eingepackt, jeder Fehler wird + vermerkt und ist auf der Automationen-Seite zu sehen. + =================================================================== */ + +import express from "express"; +import { + readdirSync, statSync, unlinkSync, mkdirSync, existsSync, statfsSync, renameSync, +} from "node:fs"; +import { join, dirname } from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { + db, DB_PFAD, DATEN_ORDNER, einstellung, einstellungSetzen, istLeitung, protokolliere, +} from "./workspace.js"; + +/* node:sqlite wird wie in workspace.js NACHTRAEGLICH geholt, nicht oben + importiert. Ein `import` waere fest verdrahtet und wuerde beim + Hochfahren den GESAMTEN Website-Prozess abbrechen, wenn das Modul + fehlt -- also auch die oeffentliche Seite. So bleibt der Schaden auf + die Sicherung beschraenkt. */ +const require = createRequire(pathToFileURL(dirname(fileURLToPath(import.meta.url)) + "/")); + +const ORDNER = join(DATEN_ORDNER, "sicherungen"); + +/* Wie viele wovon aufgehoben werden. Grossvater-Vater-Sohn: viele frische + fuer "ups, eben geloescht", wenige alte fuer "das war schon letzten + Monat falsch". Bei 2 MB je Datei kostet das nichts. */ +const BEHALTEN = { taeglich: 7, woechentlich: 4, monatlich: 6 }; + +const zahl2 = (n) => String(n).padStart(2, "0"); + +/* Ortszeit, nicht UTC. Eine Sicherung, die im Namen den 30. traegt, aber + am 31. lief, verwirrt genau dann, wenn man sie braucht. */ +function heuteOrt(d = new Date()) { + return `${d.getFullYear()}-${zahl2(d.getMonth() + 1)}-${zahl2(d.getDate())}`; +} + +/* Welche Art ist heute faellig? Monatlich schlaegt woechentlich schlaegt + taeglich -- am 1., der auf einen Sonntag faellt, entsteht sonst + dieselbe Datei dreimal. */ +function artFuer(d = new Date()) { + if (d.getDate() === 1) return "monatlich"; + if (d.getDay() === 0) return "woechentlich"; // Sonntag + return "taeglich"; +} + +function ordnerSicherstellen() { + if (!existsSync(ORDNER)) mkdirSync(ORDNER, { recursive: true }); +} + +export function sicherungenListe() { + try { + ordnerSicherstellen(); + return readdirSync(ORDNER) + .filter((n) => n.endsWith(".db")) + .map((name) => { + const s = statSync(join(ORDNER, name)); + return { name, groesse: s.size, zeit: s.mtime.toISOString() }; + }) + .sort((a, b) => b.name.localeCompare(a.name)); + } catch { return []; } +} + +/* Alte wegraeumen -- je Art getrennt, damit eine Woche voller taeglicher + Sicherungen nie die monatlichen verdraengt. */ +function aufraeumen() { + const alle = sicherungenListe(); + let weg = 0; + for (const [art, wieviele] of Object.entries(BEHALTEN)) { + const dieser = alle.filter((s) => s.name.startsWith(art + "-")); + for (const alt of dieser.slice(wieviele)) { + try { unlinkSync(join(ORDNER, alt.name)); weg++; } catch { /* egal */ } + } + } + return weg; +} + +/* Eine frisch geschriebene Sicherung wird SOFORT wieder geoeffnet und + geprueft. Eine Sicherung, die man nie aufgemacht hat, ist eine + Vermutung -- und man merkt es erst an dem Tag, an dem man sie braucht. + Geprueft wird beides: die Unversehrtheit der Datei UND ob die Personen + ueberhaupt drinstehen (gegen genau den Fall, der hier drohte: eine + technisch einwandfreie, aber leere Datei). */ +function pruefen(pfad) { + const { DatabaseSync } = require("node:sqlite"); + const p = new DatabaseSync(pfad, { readOnly: true }); + try { + const heil = p.prepare("PRAGMA integrity_check").get(); + const wert = heil ? Object.values(heil)[0] : ""; + if (wert !== "ok") return { gut: false, grund: "integrity_check: " + wert }; + const n = p.prepare("SELECT COUNT(*) AS n FROM personen").get()?.n ?? 0; + if (n === 0) return { gut: false, grund: "keine Personen in der Sicherung" }; + return { gut: true, personen: n }; + } finally { try { p.close(); } catch { /* egal */ } } +} + +export function sicherungJetzt(grund = "planmaessig") { + const begonnen = Date.now(); + const d = db(); + ordnerSicherstellen(); + + /* Checkpoint zuerst: bringt das WAL in die Hauptdatei und macht es + wieder klein. TRUNCATE statt PASSIVE, weil PASSIVE aufgibt, sobald + jemand liest -- und dann waere das WAL nach Wochen immer noch da. */ + let checkpoint = null; + try { + checkpoint = d.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get(); + } catch (f) { + checkpoint = { fehler: f?.message }; + } + + const art = artFuer(); + const ziel = join(ORDNER, `${art}-${heuteOrt()}.db`); + + /* Erst neben das Ziel schreiben, dann pruefen, dann umbenennen. Sonst + stuende nach einem Abbruch eine halbe Datei unter dem richtigen + Namen -- und die wuerde man fuer gut halten. */ + const vorlaeufig = ziel + ".teil"; + try { if (existsSync(vorlaeufig)) unlinkSync(vorlaeufig); } catch { /* egal */ } + /* VACUUM INTO weigert sich, wenn die Zieldatei schon existiert. Am + selben Tag zweimal zu sichern ist erlaubt (etwa von Hand vor einer + groesseren Aenderung) -- die neue ersetzt dann die alte. */ + try { if (existsSync(ziel)) unlinkSync(ziel); } catch { /* egal */ } + + d.exec(`VACUUM INTO '${vorlaeufig.replace(/'/g, "''")}'`); + + const geprueft = pruefen(vorlaeufig); + if (!geprueft.gut) { + try { unlinkSync(vorlaeufig); } catch { /* egal */ } + throw new Error("Sicherung war unbrauchbar: " + geprueft.grund); + } + + renameSync(vorlaeufig, ziel); + const groesse = statSync(ziel).size; + const weg = aufraeumen(); + + const ergebnis = { + zeit: new Date().toISOString(), + datei: `${art}-${heuteOrt()}.db`, + art, + groesse, + personen: geprueft.personen, + dauer_ms: Date.now() - begonnen, + geloescht: weg, + grund, + checkpoint: checkpoint && !checkpoint.fehler + ? `${checkpoint.busy ?? "?"}/${checkpoint.log ?? "?"}/${checkpoint.checkpointed ?? "?"}` + : (checkpoint?.fehler || null), + }; + einstellungSetzen("sicherung_letzte", JSON.stringify(ergebnis)); + einstellungSetzen("sicherung_fehler", ""); + return ergebnis; +} + +/* ---------- Zeitsteuerung --------------------------------------------- + + Kein setTimeout auf 24 Stunden: Ein Neustart -- und sei es nur ein + Deploy -- setzt ihn zurueck, und dann faellt die Sicherung eines Tages + still aus. Stattdessen wird viertelstuendlich geschaut, ob fuer HEUTE + schon eine liegt. Das ueberlebt Neustarts, Zeitumstellung und + Ausfaelle: Wird der Rechner drei Tage spaeter wieder angeschaltet, + sichert er sofort statt bis Mitternacht zu warten. + --------------------------------------------------------------------- */ + +const PRUEFTAKT_MS = 15 * 60 * 1000; +const STUNDE_AB = 3; // nachts, wenn nichts los ist + +function faellig() { + const jetzt = new Date(); + const letzteRoh = einstellung("sicherung_letzte"); + let letzte = null; + try { letzte = letzteRoh ? JSON.parse(letzteRoh) : null; } catch { /* kaputt = keine */ } + + /* Liegt fuer heute schon eine? Geprueft wird die DATEI, nicht nur der + Vermerk -- sonst haelt ein Eintrag ohne Datei die Sicherung auf. */ + const heute = heuteOrt(jetzt); + const daFuerHeute = sicherungenListe().some((s) => s.name.includes(heute)); + if (daFuerHeute) return false; + + /* Vor drei Uhr nur dann, wenn seit ueber einem Tag gar nichts kam -- + dann war die Maschine aus und es soll nicht bis zur naechsten Nacht + dauern. */ + if (jetzt.getHours() >= STUNDE_AB) return true; + if (!letzte?.zeit) return true; + return Date.now() - Date.parse(letzte.zeit) > 30 * 3600_000; +} + +let laeuft = false; + +function versuchen() { + if (laeuft) return; + laeuft = true; + try { + if (!faellig()) return; + const e = sicherungJetzt("planmaessig"); + console.log(`[sicherung] ${e.datei} · ${Math.round(e.groesse / 1024)} KB · ` + + `${e.personen} Personen · ${e.dauer_ms} ms` + + (e.geloescht ? ` · ${e.geloescht} alte entfernt` : "")); + } catch (fehler) { + /* Laut, aber nicht toedlich. Der Fehler landet zusaetzlich in den + Einstellungen, damit er auf der Automationen-Seite steht -- eine + Fehlermeldung, die nur im Systemprotokoll steht, liest niemand. */ + console.error("[sicherung] fehlgeschlagen:", fehler?.message); + try { + einstellungSetzen("sicherung_fehler", + JSON.stringify({ zeit: new Date().toISOString(), text: String(fehler?.message || fehler) })); + } catch { /* egal */ } + } finally { laeuft = false; } +} + +export function sicherungStarten() { + /* Nicht sofort beim Hochfahren: Erst soll die Website antworten. Und + die Datenbank oeffnet ohnehin erst beim ersten echten Zugriff -- ein + Sicherungslauf im Startmoment wuerde sie nur unnoetig frueh oeffnen + und einen Fehler dort zum Startfehler machen. */ + const ersterLauf = setTimeout(versuchen, 60_000); + const takt = setInterval(versuchen, PRUEFTAKT_MS); + ersterLauf.unref?.(); + takt.unref?.(); +} + +/* ---------- Zustandsseite --------------------------------------------- */ + +export const sicherungRouter = express.Router(); + +function nurLeitung(req, res, next) { + if (!istLeitung(req.person)) return res.status(404).json({ fehler: "nicht gefunden" }); + next(); +} + +sicherungRouter.get("/workspace/api/zustand", nurLeitung, (req, res) => { + try { + const d = db(); + const lies = (s) => { try { return JSON.parse(einstellung(s) || "null"); } catch { return null; } }; + + let platte = null; + try { + const s = statfsSync(DATEN_ORDNER); + platte = { frei: s.bavail * s.bsize, gesamt: s.blocks * s.bsize }; + } catch { /* nicht ueberall vorhanden */ } + + /* Die Groesse des WAL ist die Zahl, an der genau der Fehler + aufgefallen waere, den es hier gab. Sie gehoert deshalb sichtbar + auf die Seite und nicht nur in ein Protokoll. */ + const dateien = {}; + for (const [schluessel, pfad] of [["db", DB_PFAD], ["wal", DB_PFAD + "-wal"]]) { + try { dateien[schluessel] = statSync(pfad).size; } catch { dateien[schluessel] = null; } + } + + const heil = (() => { + try { + const r = d.prepare("PRAGMA quick_check").get(); + return r ? Object.values(r)[0] : null; + } catch { return null; } + })(); + + const liste = sicherungenListe(); + res.json({ + letzte: lies("sicherung_letzte"), + fehler: lies("sicherung_fehler"), + sicherungen: liste.slice(0, 20), + anzahl: liste.length, + belegt: liste.reduce((n, s) => n + s.groesse, 0), + behalten: BEHALTEN, + dateien, + platte, + heil, + journal: (() => { + try { return Object.values(d.prepare("PRAGMA journal_mode").get() || {})[0]; } + catch { return null; } + })(), + seit: Math.round(process.uptime()), + stand: new Date().toISOString(), + }); + } catch (fehler) { + console.error("[zustand]", fehler?.message); + res.status(500).json({ fehler: "Zustand konnte nicht gelesen werden." }); + } +}); + +/* Von Hand ausloesen -- vor einer groesseren Aenderung will man nicht bis + drei Uhr nachts warten. Nur die Leitung, und es wird protokolliert. */ +sicherungRouter.post("/workspace/api/zustand/sichern", nurLeitung, (req, res) => { + try { + const e = sicherungJetzt("von Hand · " + (req.person?.name || "?")); + protokolliere("sicherung_erstellt", { + personId: req.person?.id, rolle: req.person?.rolle, detail: e.datei, + }); + res.json(e); + } catch (fehler) { + res.status(500).json({ fehler: String(fehler?.message || fehler) }); + } +}); diff --git a/server/workspace.js b/server/workspace.js index e456c1db..57922a99 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -40,7 +40,12 @@ const require = createRequire(pathToFileURL(__dirname + "/")); /* Die Datenbank liegt bewusst AUSSERHALB des Repo-Ordners. Zwei Gründe: express.static liefert den Repo-Ordner aus (eine .db darin wäre über das Netz erreichbar), und ein `git pull` darf Nutzdaten nie anfassen. */ -const DB_PFAD = process.env.WORKSPACE_DB +/* Wird ausgegeben, weil die Sicherung sowohl die Datei selbst als auch + ihr WAL nachschlagen muss. Aus DATEN_ORDNER + "workspace.db" laesst + sich das NICHT ableiten: Steht WORKSPACE_DB auf einem anderen Namen + (so laufen alle Pruefungen), zeigte die Sicherung auf eine Datei, die + es gar nicht gibt -- und meldete brav 0 Bytes. */ +export const DB_PFAD = process.env.WORKSPACE_DB || join(__dirname, "..", "..", "workspace-daten", "workspace.db"); /* Ordner fuer hochgeladene Dateien -- neben der Datenbank, also ebenfalls diff --git a/workspace/assets/css/automation.css b/workspace/assets/css/automation.css index 8aa164e6..bbedee07 100644 --- a/workspace/assets/css/automation.css +++ b/workspace/assets/css/automation.css @@ -53,6 +53,43 @@ text-transform: uppercase; color: var(--text-still); padding-top: 2px; } +/* ---------- Sicherung & Zustand ------------------------------------------- + + Derselbe Kasten wie bei der KI, aber mit eigener Farbgebung fuer den + Zustand: Gruen gibt es hier bewusst nicht -- "es lief" ist der + Normalfall und soll nicht feiern. Auffaellig wird es nur, wenn eine + Nacht ausgefallen ist oder etwas schiefging. + -------------------------------------------------------------------------- */ + +.zustand { + margin-bottom: 34px; padding: 20px 22px; + border: 1px solid var(--rand); border-radius: var(--radius); + background: rgba(255, 255, 255, .028); +} +.zustand[data-zustand="alt"] { + border-color: rgba(232, 192, 125, .34); background: rgba(232, 192, 125, .05); +} +.zustand[data-zustand="fehler"] { + border-color: rgba(255, 154, 162, .34); background: rgba(255, 154, 162, .05); +} +.zustand__kopf { + display: flex; gap: 20px; align-items: flex-start; + justify-content: space-between; flex-wrap: wrap; +} +.zustand__titel { margin: 2px 0 6px; font-size: 1.1rem; font-weight: 700; } +.zustand[data-zustand="fehler"] .zustand__titel { color: var(--warn); } +.zustand__text { margin: 0; font-size: .88rem; color: var(--text-leise); max-width: 68ch; } + +.zustand__daten { + display: grid; gap: 7px; margin: 18px 0 0; padding: 16px 0 0; list-style: none; + border-top: 1px solid var(--rand); + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); +} +.zustand__daten li { display: flex; gap: 10px; font-size: .83rem; color: var(--text-leise); } +.zustand__daten .ki-kasten__schild { min-width: 128px; } + +.zustand__listenkopf { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--rand); } + /* ---------- Hinweise ------------------------------------------------------- */ .hinweise { display: grid; gap: 7px; } diff --git a/workspace/assets/js/automation.js b/workspace/assets/js/automation.js index 303ce926..f08bacec 100644 --- a/workspace/assets/js/automation.js +++ b/workspace/assets/js/automation.js @@ -46,6 +46,8 @@ text: 'Acht Fehlversuche je Anschluss in zehn Minuten, danach ist zu. Gezählt wird die echte Adresse, nicht die von Cloudflare.' }, { name: 'Sicherung vor Umbauten', text: 'Bevor die Datenbank umgestellt wird, schreibt der Server eine vollständige Sicherung. Ohne Sicherung wird nicht umgestellt.' }, + { name: 'Nächtliche Sicherung', + text: 'Jede Nacht eine vollständige Sicherung, die sofort wieder geöffnet und geprüft wird – auf Unversehrtheit und darauf, dass wirklich Daten drinstehen. Aufgehoben werden 7 tägliche, 4 wöchentliche und 6 monatliche. Geschrieben wird nie eine Dateikopie, sondern immer von SQLite selbst.' }, ]; const ART = { @@ -153,6 +155,112 @@ ziel.append(liste); } + /* ---------- Sicherung & Zustand ------------------------------------------ + + Die einzige Automation, deren Ausfall ALLES auf einmal kostet -- und + die einzige, die man nicht bemerkt, wenn sie ausbleibt. Deshalb steht + hier nicht nur "läuft", sondern wann zuletzt, wie viel, wie viele + Personen darin standen und wie alt das ist. + ------------------------------------------------------------------------ */ + + const bytes = (n) => { + if (n === null || n === undefined) return '–'; + if (n < 1024) return n + ' B'; + if (n < 1024 * 1024) return Math.round(n / 1024) + ' KB'; + if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB'; + return (n / 1024 / 1024 / 1024).toFixed(1) + ' GB'; + }; + + function herZeit(iso) { + if (!iso) return null; + const min = Math.floor((Date.now() - Date.parse(iso)) / 60000); + if (!Number.isFinite(min)) return null; + if (min < 60) return `vor ${Math.max(min, 0)} Minuten`; + const std = Math.floor(min / 60); + if (std < 48) return `vor ${std} Stunden`; + return `vor ${Math.floor(std / 24)} Tagen`; + } + + async function zustand() { + const kasten = $('zustand-kasten'); + let z; + try { + const a = await hole('/workspace/api/zustand'); + /* 404 heisst hier: diese Rolle soll davon gar nichts wissen. Dann + verschwindet der ganze Kasten, statt leer dazustehen. */ + if (!a.ok) { kasten.hidden = true; return; } + z = await a.json(); + } catch { kasten.hidden = true; return; } + + const her = herZeit(z.letzte?.zeit); + /* Älter als 36 Stunden heisst: mindestens eine Nacht ist ausgefallen. */ + const alt = z.letzte?.zeit + ? (Date.now() - Date.parse(z.letzte.zeit)) > 36 * 3600_000 : true; + + kasten.dataset.zustand = z.fehler ? 'fehler' : (alt ? 'alt' : 'gut'); + $('zustand-titel').textContent = z.fehler + ? 'Die letzte Sicherung ist fehlgeschlagen' + : z.letzte + ? `Zuletzt gesichert ${her}` + : 'Noch keine Sicherung gelaufen'; + $('zustand-text').textContent = z.fehler + ? z.fehler.text + : z.letzte + ? `${z.letzte.datei} · ${bytes(z.letzte.groesse)} · ${z.letzte.personen} Personen · ` + + `geschrieben und sofort wieder geprüft in ${z.letzte.dauer_ms} ms` + : 'Die erste läuft in der kommenden Nacht – oder jetzt gleich auf Knopfdruck.'; + + const daten = $('zustand-daten'); + daten.textContent = ''; + const zeilen = [ + ['Aufgehoben', `${z.anzahl} Sicherungen · ${bytes(z.belegt)} · ` + + `${z.behalten.taeglich} täglich, ${z.behalten.woechentlich} wöchentlich, ${z.behalten.monatlich} monatlich`], + ['Datenbank', `${bytes(z.dateien?.db)} · Prüfung: ${z.heil || '–'} · Betrieb: ${z.journal || '–'}`], + /* Die WAL-Grösse steht hier, weil genau daran der Fehler vom + 31.08.2026 aufgefallen wäre: Datenbank 4 KB, WAL 2,1 MB. */ + ['Wartende Schreibvorgänge', `${bytes(z.dateien?.wal)} (WAL – wird bei jeder Sicherung zusammengeführt)`], + ['Platz', z.platte ? `${bytes(z.platte.frei)} frei von ${bytes(z.platte.gesamt)}` : '–'], + ['Dienst läuft', `${Math.floor(z.seit / 3600)} h ${Math.floor((z.seit % 3600) / 60)} min`], + ]; + for (const [name, wert] of zeilen) { + const li = el('li'); + li.append(el('span', 'ki-kasten__schild', name), el('span', null, wert)); + daten.append(li); + } + + const liste = $('sicherungen'); + liste.textContent = ''; + if (!z.sicherungen.length) { + liste.append(el('p', 'leise', 'Noch keine.')); + $('sicherungen-schalter').hidden = true; + return; + } + for (const s of z.sicherungen) { + const zeile = el('div', 'verlauf__zeile'); + zeile.append(el('span', 'verlauf__zeit', s.name.replace(/\.db$/, '').split('-').slice(1).reverse().join('.'))); + zeile.append(el('span', 'verlauf__was', s.name.split('-')[0])); + zeile.append(el('span', 'verlauf__wer', bytes(s.groesse))); + liste.append(zeile); + } + window.klappe?.(liste, $('sicherungen-schalter'), $('sicherungen-schalter-text')); + } + + $('jetzt-sichern').addEventListener('click', async () => { + const knopf = $('jetzt-sichern'); + knopf.disabled = true; + const vorher = knopf.textContent; + knopf.textContent = 'Sichert …'; + try { + const a = await hole('/workspace/api/zustand/sichern', { method: 'POST' }); + const d = await a.json().catch(() => ({})); + if (!a.ok) { melde(d.fehler || 'Die Sicherung ist fehlgeschlagen.'); return; } + melde(''); + await zustand(); + } catch { /* umgeleitet */ } finally { + knopf.disabled = false; knopf.textContent = vorher; + } + }); + /* ---------- Verlauf ------------------------------------------------------ */ async function verlauf() { @@ -204,6 +312,6 @@ ich = await a.json(); } catch { return; } $('wer').textContent = ich.name + ' · ' + (ich.rolle_name || ich.rolle); - await Promise.all([kiZustand(), hinweise(), verlauf()]); + await Promise.all([kiZustand(), zustand(), hinweise(), verlauf()]); })(); })(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index f8ec70d9..abeddc81 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -7,9 +7,9 @@ - - - + + +
@@ -134,8 +134,8 @@ - - - + + +