/** * Wiederherstellung aus einer Sicherung. * * npm run wiederherstellen -- --quelle sicherungen/2026-09-05T14-00-00 * npm run wiederherstellen -- --quelle ... --wirklich * * Ohne `--wirklich` wird nur angezeigt, was geschehen würde. Das ist keine * Bequemlichkeit: dieses Skript **überschreibt den gesamten Bestand**, und es * gibt keinen Weg zurück außer einer weiteren Sicherung. * * Der eigentliche Zweck ist nicht der Notfall, sondern die Probe. Eine * Sicherung, die nie zurückgespielt wurde, ist eine Vermutung — der Ordner * kann leer sein, der Dump abgeschnitten, das Passwort falsch. Man erfährt es * an dem Tag, an dem man es nicht erfahren will. Deshalb gehört dieser Lauf * einmal durchgeführt und im Sicherungskonzept vermerkt. */ import "dotenv/config"; import { spawnSync } from "node:child_process"; import { existsSync, readFileSync, readdirSync, statSync, createReadStream } from "node:fs"; import { join, relative, sep } from "node:path"; import { Client as MinioClient } from "minio"; import { zugangAus } from "./sicherung-zugang"; import { SPERREN, sperrenSql, verboteneRechte } from "./sicherung-sperren"; function log(nachricht: string): void { console.info(`[wiederherstellen] ${nachricht}`); } function argument(name: string): string | undefined { const stelle = process.argv.indexOf(`--${name}`); return stelle >= 0 ? process.argv[stelle + 1] : undefined; } const wirklich = process.argv.includes("--wirklich"); /** Alle Dateien unterhalb eines Verzeichnisses, rekursiv. */ function alleDateien(wurzel: string): string[] { if (!existsSync(wurzel)) return []; const gefunden: string[] = []; for (const eintrag of readdirSync(wurzel)) { const pfad = join(wurzel, eintrag); if (statSync(pfad).isDirectory()) gefunden.push(...alleDateien(pfad)); else gefunden.push(pfad); } return gefunden; } function datenbankZurueck(quelle: string): void { const { benutzer, passwort, datenbank } = zugangAus( process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL, ); const dump = join(quelle, "datenbank.dump"); if (!existsSync(dump)) throw new Error(`In ${quelle} liegt keine datenbank.dump.`); log("Spiele die Datenbank zurück ..."); /** * `--clean --if-exists` räumt vorher auf. * * Ohne das Aufräumen schlüge jede `CREATE TABLE` fehl, und übrig bliebe ein * Mischbestand aus alten und neuen Zeilen — das Schlimmste, was bei einer * Wiederherstellung herauskommen kann, weil es auf den ersten Blick * funktioniert. */ const lauf = spawnSync( "docker", [ "compose", "exec", "-T", "postgres", "pg_restore", "--clean", "--if-exists", "--no-owner", "--username", benutzer, "--dbname", datenbank, ], { input: readFileSync(dump), encoding: "buffer", maxBuffer: 2_000_000_000, shell: true, env: { ...process.env, PGPASSWORD: passwort }, }, ); // pg_restore meldet auch dann einen Fehlercode, wenn nur einzelne DROP- // Anweisungen ins Leere liefen. Das ist bei `--if-exists` normal, deshalb // wird die Ausgabe angesehen statt nur der Rückgabewert. const ausgabe = lauf.stderr?.toString() ?? ""; const echteFehler = ausgabe .split("\n") .filter((zeile) => zeile.includes("error:") && !zeile.includes("does not exist")); if (echteFehler.length > 0) { throw new Error(`pg_restore ist fehlgeschlagen:\n${echteFehler.join("\n")}`); } log("Datenbank zurückgespielt."); } async function dateienZurueck(quelle: string): Promise { const bucket = process.env.S3_BUCKET ?? "florian"; const ordner = join(quelle, "dateien"); const client = new MinioClient({ endPoint: process.env.S3_ENDPOINT ?? "localhost", port: Number(process.env.S3_PORT ?? 9000), useSSL: (process.env.S3_USE_SSL ?? "false") === "true", accessKey: process.env.S3_ACCESS_KEY ?? "", secretKey: process.env.S3_SECRET_KEY ?? "", }); if (!(await client.bucketExists(bucket))) { await client.makeBucket(bucket); log(`Bucket "${bucket}" angelegt.`); } const dateien = alleDateien(ordner); let hochgeladen = 0; for (const pfad of dateien) { // Der Objektschlüssel ist der Pfad unterhalb von "dateien/" — mit // Schrägstrichen, auch unter Windows. const schluessel = relative(ordner, pfad).split(sep).join("/"); await client.putObject(bucket, schluessel, createReadStream(pfad), statSync(pfad).size); hochgeladen += 1; if (hochgeladen % 50 === 0) log(` ${hochgeladen} Dateien ...`); } return hochgeladen; } /** * Die Sperren wieder setzen — und danach nachsehen, ob es gewirkt hat. * * Warum das nötig ist, steht in `sicherung-sperren.ts`: die * Vorgabeprivilegierung des Init-Skripts gibt jeder von `pg_restore` neu * angelegten Tabelle wieder alle Rechte, und die `GRANT`-Zeilen aus dem Dump * können nur hinzufügen, nie wegnehmen. * * Erst setzen, dann prüfen — und nicht nur prüfen. Ein Skript, das ein * bekanntes Problem meldet, statt es zu beheben, verlagert die Arbeit auf den * ungünstigsten Zeitpunkt: den Notfall, in dem jemand gerade eine Sicherung * zurückspielt. */ async function sperrenSetzen(): Promise { const { PrismaClient } = await import("@prisma/client"); const prisma = new PrismaClient({ datasources: { db: { url: process.env.DIRECT_DATABASE_URL ?? "" } }, }); try { const rolleDa = await prisma.$queryRawUnsafe>( "SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'florian_app') AS da", ); if (!rolleDa[0]?.da) { log("Die Rolle florian_app gibt es nicht — Sperren übersprungen."); return; } for (const anweisung of sperrenSql()) { await prisma.$executeRawUnsafe(anweisung); } log(`Sperren gesetzt: ${SPERREN.length} Tabellen.`); // Gegenprobe. Sie fragt die Datenbank, nicht das eigene Vorhaben ab. const zeilen = await prisma.$queryRawUnsafe< Array<{ table_name: string; privilege_type: string }> >( `SELECT table_name, privilege_type FROM information_schema.role_table_grants WHERE grantee = 'florian_app'`, ); const vorhanden = new Set(zeilen.map((z) => `${z.table_name}:${z.privilege_type}`)); const befunde = verboteneRechte() .filter(({ tabelle, recht }) => vorhanden.has(`${tabelle}:${recht}`)) .map(({ tabelle, recht }) => ` ${tabelle}: ${recht}`); if (befunde.length > 0) { log(""); log("ACHTUNG: Die Anwendungsrolle hat Rechte, die sie nicht haben darf:"); befunde.forEach((zeile) => log(zeile)); log("Die Unveraenderlichkeit von Protokollen ist damit aufgehoben."); process.exitCode = 1; return; } log("Gegenprobe: keine Tabelle hat mehr Rechte als vorgesehen."); } finally { await prisma.$disconnect(); } } async function main(): Promise { const quelle = argument("quelle"); if (!quelle) { console.error("[wiederherstellen] --quelle fehlt."); process.exit(1); } const manifestPfad = join(quelle, "manifest.json"); if (!existsSync(manifestPfad)) { console.error( `[wiederherstellen] In ${quelle} liegt keine manifest.json. ` + "Ist das wirklich ein Sicherungsverzeichnis?", ); process.exit(1); } const manifest = JSON.parse(readFileSync(manifestPfad, "utf-8")) as { erstelltAm: string; version: string; datenbank: { bytes: number }; dateien: { anzahl: number; bytes: number }; }; const alter = Math.round( (Date.now() - new Date(manifest.erstelltAm).getTime()) / 1000 / 60 / 60 / 24, ); log(""); log(`Sicherung vom ${manifest.erstelltAm.slice(0, 16).replace("T", " ")} (${alter} Tage alt)`); log(`Version: ${manifest.version}`); log(`Datenbank: ${(manifest.datenbank.bytes / 1024 / 1024).toFixed(1)} MB`); log(`Dateien: ${manifest.dateien.anzahl} Stück`); log(""); if (!wirklich) { log("PROBELAUF — es wird nichts geändert."); log(""); log("Zum wirklichen Zurückspielen dieselbe Zeile mit --wirklich am Ende."); log("Achtung: dabei wird der gesamte vorhandene Bestand überschrieben."); return; } if (alter > 30) { log(`WARNUNG: Diese Sicherung ist ${alter} Tage alt.`); } log("Spiele zurück — der vorhandene Bestand wird überschrieben."); datenbankZurueck(quelle); const dateien = await dateienZurueck(quelle); log(`Dateien: ${dateien} zurückgespielt.`); await sperrenSetzen(); log(""); log("Fertig. Bitte jetzt von Hand prüfen:"); log(" 1. Anmelden — kommt jemand hinein?"); log(" 2. Ein Prüfprotokoll öffnen — ist das PDF da?"); log(" 3. Die Mitgliederzahl mit der Erwartung vergleichen."); log(""); log("Erst danach gilt die Sicherung als geprüft."); } main().catch((fehler) => { console.error("[wiederherstellen] Abbruch:", fehler); process.exit(1); });