import { spawn } from 'child_process' import { createWriteStream, createReadStream, promises as fsp } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { randomBytes, scryptSync, createCipheriv } from 'crypto' // archiver nutzt `export =` (CommonJS); require umgeht die Default-Import-Inkompatibilität. // eslint-disable-next-line @typescript-eslint/no-var-requires const archiver = require('archiver') as (format: string, opts?: any) => any import { minioClient, BUCKET } from '@/lib/minio' import { loadBackupConfig, saveBackupConfig, type BackupConfig } from './config' import { plainDestFromConfig, getPassphrase } from './secrets' import { uploadBackup, listBackups, deleteBackup } from './destinations' const NAME_PREFIX = 'lageplan_' const NAME_RE = /^lageplan_(\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2})\.tar\.gz\.enc$/ function tsName(d = new Date()): string { // 2026-07-23-22-30-00 (UTC, sortierbar) return d.toISOString().replace('T', '-').replace(/:/g, '-').slice(0, 19) } function fmtSize(n: number): string { if (n > 1e9) return (n / 1e9).toFixed(1) + ' GB' if (n > 1e6) return (n / 1e6).toFixed(1) + ' MB' return (n / 1e3).toFixed(0) + ' KB' } /** PostgreSQL-Dump (custom/komprimiert) via pg_dump. */ function pgDump(dumpPath: string): Promise { const url = process.env.DATABASE_URL if (!url) return Promise.reject(new Error('DATABASE_URL fehlt')) return new Promise((resolve, reject) => { const p = spawn('pg_dump', [url, '-Fc', '-f', dumpPath], { stdio: ['ignore', 'ignore', 'pipe'] }) let err = '' p.stderr.on('data', d => { err += d.toString() }) p.on('error', (e) => reject(new Error(`pg_dump nicht ausführbar: ${e.message} (postgresql-client im Image?)`))) p.on('close', code => code === 0 ? resolve() : reject(new Error('pg_dump: ' + (err || `Exit ${code}`)))) }) } function listObjectNames(): Promise { return new Promise((resolve, reject) => { const out: string[] = [] const s = minioClient.listObjects(BUCKET, '', true) s.on('data', (o: any) => { if (o.name) out.push(o.name) }) s.on('error', reject) s.on('end', () => resolve(out)) }) } /** tar.gz mit database.dump + files/. */ async function buildArchive(dumpPath: string, tarPath: string): Promise { const names = await listObjectNames() const output = createWriteStream(tarPath) const archive = archiver('tar', { gzip: true, gzipOptions: { level: 6 } }) const done = new Promise((resolve, reject) => { output.on('close', () => resolve()) output.on('error', reject) archive.on('error', reject) }) archive.pipe(output) archive.file(dumpPath, { name: 'database.dump' }) for (const name of names) { const st = await minioClient.getObject(BUCKET, name) archive.append(st as any, { name: 'files/' + name }) } await archive.finalize() await done } /** AES-256-GCM Stream-Verschlüsselung mit Passphrase. Format: [salt16][iv12][ciphertext][tag16]. */ function encryptFile(inPath: string, outPath: string, passphrase: string): Promise { return new Promise((resolve, reject) => { const salt = randomBytes(16) const iv = randomBytes(12) const key = scryptSync(passphrase, salt, 32) const cipher = createCipheriv('aes-256-gcm', key, iv) const out = createWriteStream(outPath) out.on('error', reject) out.on('close', () => resolve()) out.write(salt); out.write(iv) const input = createReadStream(inPath) input.on('error', reject) cipher.on('error', reject) cipher.on('data', (c) => out.write(c)) cipher.on('end', () => { out.write(cipher.getAuthTag()); out.end() }) input.pipe(cipher) }) } /** Entfernt Remote-Backups, die älter als retentionDays sind (Zeitstempel aus dem Dateinamen). */ async function prune(cfg: BackupConfig): Promise { try { const dest = plainDestFromConfig(cfg) const files = await listBackups(cfg.destinationType, dest) const cutoff = Date.now() - cfg.retentionDays * 86400_000 for (const f of files) { const m = f.name.match(NAME_RE) if (!m) continue // 2026-07-23-22-30-00 → ISO const iso = m[1].replace(/^(\d{4}-\d{2}-\d{2})-(\d{2})-(\d{2})-(\d{2})$/, '$1T$2:$3:$4Z') const t = Date.parse(iso) if (!isNaN(t) && t < cutoff) { await deleteBackup(cfg.destinationType, dest, f.name) } } } catch (e) { console.warn('[backup] prune:', e) } } /** * Führt ein vollständiges Backup aus: DB-Dump + Dateien → tar.gz → verschlüsselt → Upload → prune. * Läuft unabhängig vom Zeitplan (auch für „Jetzt sichern"), solange Ziel + Passphrase gesetzt sind. */ export async function runBackup(): Promise<{ ok: boolean; message: string }> { const cfg = await loadBackupConfig() if (!cfg) throw new Error('Backup nicht konfiguriert.') const pass = getPassphrase(cfg) if (!pass) throw new Error('Backup-Passphrase fehlt.') const dest = plainDestFromConfig(cfg) // wirft, wenn Ziel nicht konfiguriert const ts = tsName() const tmp = await fsp.mkdtemp(join(tmpdir(), 'lpbak-')) const dumpPath = join(tmp, 'db.dump') const tarPath = join(tmp, `${NAME_PREFIX}${ts}.tar.gz`) const encPath = `${tarPath}.enc` const remoteName = `${NAME_PREFIX}${ts}.tar.gz.enc` try { await pgDump(dumpPath) await buildArchive(dumpPath, tarPath) await encryptFile(tarPath, encPath, pass) await uploadBackup(cfg.destinationType, dest, encPath, remoteName) const size = (await fsp.stat(encPath)).size await prune(cfg) cfg.lastRun = new Date().toISOString() cfg.lastStatus = 'ok' cfg.lastMessage = `Gesichert: ${remoteName} (${fmtSize(size)})` cfg.lastSizeBytes = size await saveBackupConfig(cfg) return { ok: true, message: cfg.lastMessage } } catch (e: any) { cfg.lastRun = new Date().toISOString() cfg.lastStatus = 'error' cfg.lastMessage = e?.message || 'Unbekannter Fehler' await saveBackupConfig(cfg) throw e } finally { await fsp.rm(tmp, { recursive: true, force: true }).catch(() => {}) } } /** Prüft, ob laut Zeitplan ein Backup fällig ist (für den Scheduler). */ export function isBackupDue(cfg: BackupConfig, now = Date.now()): boolean { if (!cfg.enabled || cfg.schedule === 'off') return false const intervalMs = cfg.schedule === 'weekly' ? 7 * 86400_000 : 86400_000 if (!cfg.lastRun) return true const last = Date.parse(cfg.lastRun) if (isNaN(last)) return true // Bei Fehlversuchen nicht dauernd neu starten: erst nach dem Intervall erneut. return now - last >= intervalMs }