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