From 8cf840444022a18f5f23e40b400bac48d6c8e093 Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Tue, 29 Sep 2026 10:44:24 +0200 Subject: [PATCH] =?UTF-8?q?fix(backup):=20Ticket-Anh=C3=A4nge=20in=20Siche?= =?UTF-8?q?rung=20und=20Wiederherstellungstest=20einschlie=C3=9Fen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vom Nacht-Agenten gefundene Lücke (P1), gegen den echten Code verifiziert: das Backup-Archiv enthielt nur db.sql/config/manifest.json, nicht das Anhänge-Verzeichnis (STORE_ROOT, /var/lib/kundencenter/ticket-attachments). Bei einem echten Datenverlust wären alle Ticket-Anhänge unwiederbringlich verloren gewesen, obwohl die Datenbank-Referenzen darauf erhalten blieben. - runBackup: Anhänge-Verzeichnis wird mit in archive.tar.gz gepackt (eigener -C-Abschnitt, da es außerhalb des Arbeitsverzeichnisses liegt), Datei-/ Byte-Anzahl landet zur Kontrolle im manifest.json. - runRestoreTest: prüft nach dem Entpacken, ob Datei- und Byte-Anzahl der Anhänge mit dem Manifest übereinstimmen (wie die bestehenden Zeilenzahl-/ Migrations-/Audit-Kette-Prüfungen). Ältere Sicherungen ohne dieses Feld werden übersprungen statt fälschlich als fehlerhaft gemeldet. Gegen eine echte Sicherung+Wiederherstellungstest verifiziert (zwei Testdateien im Anhänge-Verzeichnis angelegt, Backup gefahren, Restore-Test lief grün mit anhaengeDateien: 2). Co-Authored-By: Claude Sonnet 5 --- apps/api/src/ops/backup.ts | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/api/src/ops/backup.ts b/apps/api/src/ops/backup.ts index ccb6eb1..c7087a6 100644 --- a/apps/api/src/ops/backup.ts +++ b/apps/api/src/ops/backup.ts @@ -3,12 +3,13 @@ import { createReadStream, createWriteStream, existsSync } from 'node:fs'; import { chmod, mkdir, mkdtemp, readdir, readFile, rename, rm, stat, writeFile, copyFile } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { tmpdir } from 'node:os'; -import { join, basename } from 'node:path'; +import { join, basename, dirname } from 'node:path'; import mysql from 'mysql2/promise'; import { config } from '../core/config.js'; import { query, one } from '../core/db.js'; import { audit, verifyAuditChain } from '../core/audit.js'; import { enqueue } from '../core/jobs.js'; +import { STORE_ROOT as ATTACHMENT_DIR } from '../modules/tickets/files.js'; import { fileName, parseName, selectDeletions, type Keep } from './retention.js'; import { getBackupPassword } from './settings.js'; import { buildRemote, loadTargets, friendly, type Remote } from './targets.js'; @@ -72,6 +73,21 @@ async function tableCounts(q: (sql: string) => Promise): Promise { + if (!existsSync(dir)) return { files: 0, bytes: 0 }; + let files = 0; let bytes = 0; + const walk = async (d: string) => { + for (const e of await readdir(d, { withFileTypes: true })) { + const p = join(d, e.name); + if (e.isDirectory()) await walk(p); + else if (e.isFile()) { files += 1; bytes += (await stat(p)).size; } + } + }; + await walk(dir); + return { files, bytes }; +} + /** Erstellt ein verschlüsseltes Backup (DB-Dump + Konfiguration/Schlüssel), lädt es zu allen Zielen hoch und räumt nach Aufbewahrungsregeln auf. */ export async function runBackup(c: BackupConfig, now = new Date()): Promise> { const t0 = Date.now(); const targets: { name: string; ok: boolean; error?: string }[] = []; const dests: { label: string; remote: string; env: Record }[] = []; const built: Remote[] = []; @@ -87,8 +103,12 @@ export async function runBackup(c: BackupConfig, now = new Date()): Promise query(sql)); const migrations = (await query('SELECT name FROM schema_migrations ORDER BY name')).map((r) => r.name as string); - await writeFile(join(work, 'manifest.json'), JSON.stringify({ version: 1, createdAt: now.toISOString(), database: config.db.database, counts, migrations, dumpSha256: await sha256(join(work, 'db.sql')) }, null, 2)); - await run('tar', ['-czf', join(work, 'archive.tar.gz'), '-C', work, 'db.sql', 'config', 'manifest.json']); + const attachments = await dirStats(ATTACHMENT_DIR); + await writeFile(join(work, 'manifest.json'), JSON.stringify({ version: 1, createdAt: now.toISOString(), database: config.db.database, counts, migrations, attachments, dumpSha256: await sha256(join(work, 'db.sql')) }, null, 2)); + const tarArgs = ['-czf', join(work, 'archive.tar.gz'), '-C', work, 'db.sql', 'config', 'manifest.json']; + // Ticket-Anhänge liegen außerhalb von work (STORE_ROOT); eigener -C-Abschnitt bettet sie unter ihrem Basisnamen mit ein. + if (attachments.files > 0) tarArgs.push('-C', dirname(ATTACHMENT_DIR), basename(ATTACHMENT_DIR)); + await run('tar', tarArgs); const out = join(c.dir, name); if (pw) await gpgEncrypt(work, join(work, 'archive.tar.gz'), out, pw.password); else if (c.recipient) await run('age', ['-r', c.recipient, '-o', out, join(work, 'archive.tar.gz')]); @@ -172,6 +192,12 @@ export async function runRestoreTest(c: BackupConfig, file?: string): Promise