import { Request, Response } from 'express'; import * as backupService from '../services/backup.service.js'; import prisma from '../lib/prisma.js'; /** * Validiert Backup-Namen: nur Zeichen die auch der Backup-Generator erstellen darf * (ISO-Zeitstempel mit Buchstaben, Zahlen, Bindestrich, optional -N Suffix). * Blockt Path-Traversal-Versuche wie "../../etc/passwd". */ function isValidBackupName(name: string): boolean { return /^[A-Za-z0-9_-]+$/.test(name) && !name.includes('..'); } import { logChange } from '../services/audit.service.js'; // Fängt console.log/info/warn/error für die Laufzeit einer Operation in // einen Puffer mit ab (zusätzlich landet alles weiterhin in stdout/stderr). // Wird in createBackup/restoreBackup verwendet, um den vollständigen // Verlauf in `BackupLog.fullLog` zu persistieren. Da die Backup-Operationen // in der Praxis nicht parallel laufen (Single-User-Admin-UI), reicht die // process-globale Patch-Variante. function startLogCapture(): { lines: string[]; restore: () => void } { const lines: string[] = []; const orig = { log: console.log, info: console.info, warn: console.warn, error: console.error, }; function fmt(args: unknown[]): string { return args .map((a) => { if (a instanceof Error) return a.stack || a.message; if (typeof a === 'object') { try { return JSON.stringify(a); } catch { return String(a); } } return String(a); }) .join(' '); } console.log = (...args: unknown[]) => { lines.push(fmt(args)); orig.log(...args); }; console.info = (...args: unknown[]) => { lines.push(fmt(args)); orig.info(...args); }; console.warn = (...args: unknown[]) => { lines.push(`[WARN] ${fmt(args)}`); orig.warn(...args); }; console.error = (...args: unknown[]) => { lines.push(`[ERROR] ${fmt(args)}`); orig.error(...args); }; return { lines, restore: () => { console.log = orig.log; console.info = orig.info; console.warn = orig.warn; console.error = orig.error; }, }; } async function recordBackupLog(opts: { req: Request; operation: 'CREATE' | 'RESTORE'; backupName: string | null; success: boolean; durationMs: number; summary: string; fullLog: string; }) { try { const user = (opts.req as any).user; await prisma.backupLog.create({ data: { operation: opts.operation, backupName: opts.backupName, success: opts.success, durationMs: opts.durationMs, summary: opts.summary.slice(0, 2000), // LongText: bis ~4 GB, aber wir cappen bei 1 MB damit nichts entgleist fullLog: opts.fullLog.slice(0, 1_000_000), userId: user?.userId ?? null, userEmail: user?.email ?? null, ipAddress: (opts.req as any).socket?.remoteAddress || (opts.req.headers?.['x-forwarded-for'] as string) || null, }, }); } catch (err) { console.error('[BackupLog] Konnte Log nicht persistieren:', err); } } /** * Liste aller Backups abrufen * GET /api/settings/backups */ export async function listBackups(req: Request, res: Response) { try { const backups = await backupService.listBackups(); res.json({ data: backups }); } catch (error: any) { res.status(500).json({ error: 'Fehler beim Laden der Backups', details: error.message }); } } /** * Neues Backup erstellen * POST /api/settings/backup */ export async function createBackup(req: Request, res: Response) { const start = Date.now(); const capture = startLogCapture(); try { const result = await backupService.createBackup(); const durationMs = Date.now() - start; if (result.success) { capture.restore(); const summary = `Backup ${result.backupName} erstellt (${(durationMs / 1000).toFixed(1)}s)`; await recordBackupLog({ req, operation: 'CREATE', backupName: result.backupName ?? null, success: true, durationMs, summary, fullLog: capture.lines.join('\n') || summary, }); await logChange({ req, action: 'CREATE', resourceType: 'Backup', label: `Backup ${result.backupName} erstellt`, }); res.json({ data: { backupName: result.backupName }, message: 'Backup erfolgreich erstellt' }); } else { capture.restore(); await recordBackupLog({ req, operation: 'CREATE', backupName: null, success: false, durationMs, summary: `Backup fehlgeschlagen: ${result.error || 'unbekannt'}`, fullLog: capture.lines.join('\n') + '\n[Fehler] ' + (result.error || ''), }); res.status(500).json({ error: 'Backup fehlgeschlagen', details: result.error }); } } catch (error: any) { const durationMs = Date.now() - start; capture.restore(); await recordBackupLog({ req, operation: 'CREATE', backupName: null, success: false, durationMs, summary: `Fehler: ${error?.message || 'unbekannt'}`, fullLog: capture.lines.join('\n') + '\n[Exception] ' + (error?.stack || error?.message || error), }); res.status(500).json({ error: 'Fehler beim Erstellen des Backups', details: error.message }); } } /** * Backup wiederherstellen * POST /api/settings/backup/:name/restore */ // Macht eine Fehlermeldung admin-lesbar OHNE den globalen ORM-Leak-Filter // auszulösen: Stack-Frames raus, "TypeError: …" → "Code-Fehler: …", // "Cannot read properties of undefined" → "Interner Code-Fehler". // Vollständiger Stack landet immer im Server-Log (siehe `console.error`). function makeRestoreErrorReadable(raw: unknown): string { if (!raw) return 'Unbekannter Fehler'; let s = typeof raw === 'string' ? raw : (raw as any)?.message || String(raw); // Stack-Frames " at …(…:123:45)" abschneiden s = s.split('\n').filter((line: string) => !/^\s*at\s+/.test(line)).join('\n').trim(); // Bekannte JS-Runtime-Marker rephrasen, damit der orm-leak-guard nicht // alles auf "Operation fehlgeschlagen" maskiert. s = s .replace(/^TypeError:?\s*/i, 'Code-Fehler: ') .replace(/^ReferenceError:?\s*/i, 'Code-Fehler: ') .replace(/^SyntaxError:?\s*/i, 'Code-Fehler: ') .replace(/^RangeError:?\s*/i, 'Code-Fehler: ') .replace(/Cannot read propert(?:y|ies) of (undefined|null) \(reading '([^']+)'\)/i, 'Wert fehlt: $2') .replace(/is not a function/i, '(ungültiger Funktionsaufruf)') .replace(/is not defined$/i, '(Wert nicht definiert)') .replace(/Invalid `prisma\.[^`]+`/i, 'DB-Fehler'); return s.slice(0, 500); // Längenlimit für UI } export async function restoreBackup(req: Request, res: Response) { const start = Date.now(); const { name } = req.params; if (!name || !isValidBackupName(name)) { return res.status(400).json({ error: 'Ungültiger Backup-Name' }); } // Pflicht-Confirm im Body, gleiche Defensive wie factoryReset. // Pentest 2026-05-19 (KRITISCH): leerer POST-Body löste vorher // sofort den destruktiven Restore aus – ein versehentlicher // Re-Fire (Browser-Tab, CSRF auf eingeloggten Admin, doppelter // Klick) konnte die DB ungewollt überschreiben. Der String ist // bewusst ein unique Magic-Value, kein Boolean. const confirm = (req.body && req.body.confirm) ? String(req.body.confirm) : ''; if (confirm !== 'RESTORE-BESTAETIGT') { return res.status(400).json({ error: 'Bestätigung fehlt. Body muss { "confirm": "RESTORE-BESTAETIGT" } enthalten.', }); } const capture = startLogCapture(); try { const result = await backupService.restoreBackup(name); const durationMs = Date.now() - start; if (result.success) { capture.restore(); const summary = `${result.restoredRecords} Datensätze, ${result.restoredFiles || 0} Dateien (${(durationMs / 1000).toFixed(1)}s)`; await recordBackupLog({ req, operation: 'RESTORE', backupName: name, success: true, durationMs, summary, fullLog: capture.lines.join('\n') || summary, }); await logChange({ req, action: 'UPDATE', resourceType: 'Backup', label: `Backup ${name} wiederhergestellt`, }); res.json({ data: { restoredRecords: result.restoredRecords, restoredFiles: result.restoredFiles, }, message: `${result.restoredRecords} Datensätze und ${result.restoredFiles || 0} Dateien wiederhergestellt`, }); } else { console.error(`[restore] Backup ${name} fehlgeschlagen:`, result.error); capture.restore(); await recordBackupLog({ req, operation: 'RESTORE', backupName: name, success: false, durationMs, summary: `Fehlgeschlagen: ${makeRestoreErrorReadable(result.error)}`, fullLog: capture.lines.join('\n') + '\n[Fehler] ' + (result.error || ''), }); res.status(500).json({ error: 'Wiederherstellung fehlgeschlagen', details: makeRestoreErrorReadable(result.error), hint: 'Vollständiger Verlauf in den Backup-Logs unterhalb der Backup-Liste.', }); } } catch (error: any) { const durationMs = Date.now() - start; console.error(`[restore] Exception bei Backup ${name}:`, error?.stack || error); capture.restore(); await recordBackupLog({ req, operation: 'RESTORE', backupName: name, success: false, durationMs, summary: `Exception: ${makeRestoreErrorReadable(error)}`, fullLog: capture.lines.join('\n') + '\n[Exception] ' + (error?.stack || error?.message || error), }); res.status(500).json({ error: 'Fehler bei der Wiederherstellung', details: makeRestoreErrorReadable(error), hint: 'Vollständiger Verlauf in den Backup-Logs unterhalb der Backup-Liste.', }); } } /** * Backup löschen * DELETE /api/settings/backup/:name */ export async function deleteBackup(req: Request, res: Response) { try { const { name } = req.params; if (!name || !isValidBackupName(name)) { return res.status(400).json({ error: 'Ungültiger Backup-Name' }); } const result = await backupService.deleteBackup(name); if (result.success) { await logChange({ req, action: 'DELETE', resourceType: 'Backup', label: `Backup ${name} gelöscht`, }); res.json({ message: 'Backup gelöscht' }); } else { res.status(500).json({ error: 'Löschen fehlgeschlagen', details: result.error }); } } catch (error: any) { res.status(500).json({ error: 'Fehler beim Löschen des Backups', details: error.message }); } } /** * Backup als ZIP herunterladen * GET /api/settings/backup/:name/download */ export async function downloadBackup(req: Request, res: Response) { try { const { name } = req.params; if (!name || !isValidBackupName(name)) { return res.status(400).json({ error: 'Ungültiger Backup-Name' }); } const result = await backupService.createBackupZip(name); if ('error' in result) { return res.status(404).json({ error: result.error }); } // Response-Header setzen res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`); // Archiver zum Response pipen result.stream.pipe(res); // Archiver finalisieren (startet das Schreiben) result.stream.finalize(); } catch (error: any) { res.status(500).json({ error: 'Fehler beim Download', details: error.message }); } } /** * Backup-ZIP hochladen * POST /api/settings/backup/upload */ export async function uploadBackup(req: Request, res: Response) { try { if (!req.file) { return res.status(400).json({ error: 'Keine Datei hochgeladen' }); } // Prüfen ob es eine ZIP-Datei ist if (!req.file.originalname.endsWith('.zip')) { return res.status(400).json({ error: 'Nur ZIP-Dateien sind erlaubt' }); } const result = await backupService.uploadBackupZip(req.file.buffer); if (result.success) { res.json({ data: { backupName: result.backupName }, message: 'Backup erfolgreich hochgeladen', }); } else { res.status(400).json({ error: 'Upload fehlgeschlagen', details: result.error }); } } catch (error: any) { res.status(500).json({ error: 'Fehler beim Upload', details: error.message }); } } /** * Werkseinstellungen - Alle Daten löschen * POST /api/settings/factory-reset */ export async function factoryReset(req: Request, res: Response) { try { // Bestätigung erforderlich: client MUSS explizit // `confirm: "FACTORY-RESET-BESTAETIGT"` schicken. Ohne diesen Schritt // konnte ein eingeloggter Admin die komplette DB mit einem einfachen // POST plätten (Pentest Runde 11 (2026-05-18) – C2 KRITISCH: // 3× DB-Plättung in einer Session). Body-Wert ist absichtlich ein // unique String und kein boolean, damit kein Auto-JSON-Tooling / // Replay-Angriff aus Versehen triggern kann. const confirm = (req.body && req.body.confirm) ? String(req.body.confirm) : ''; if (confirm !== 'FACTORY-RESET-BESTAETIGT') { res.status(400).json({ success: false, error: 'Bestätigung fehlt. Body muss { "confirm": "FACTORY-RESET-BESTAETIGT" } enthalten.', }); return; } const result = await backupService.factoryReset(); if (result.success) { await logChange({ req, action: 'DELETE', resourceType: 'System', label: `Werkseinstellungen wiederhergestellt`, }); res.json({ message: 'Werkseinstellungen wiederhergestellt. Bitte melden Sie sich mit admin@admin.com / admin an.', }); } else { res.status(500).json({ error: 'Werkseinstellungen fehlgeschlagen', details: result.error }); } } catch (error: any) { res.status(500).json({ error: 'Fehler bei Werkseinstellungen' }); console.error('factoryReset error:', error); } } /** * Liste der Backup-Logs (CREATE oder RESTORE) * GET /api/settings/backup-logs?operation=CREATE|RESTORE&limit=50 * Liefert die Übersichtsdaten OHNE den großen fullLog. */ export async function listBackupLogs(req: Request, res: Response) { try { const op = String(req.query.operation || '').toUpperCase(); const limit = Math.min(Math.max(parseInt(String(req.query.limit || '50'), 10) || 50, 1), 200); const where: any = {}; if (op === 'CREATE' || op === 'RESTORE') { where.operation = op; } const logs = await prisma.backupLog.findMany({ where, orderBy: { createdAt: 'desc' }, take: limit, select: { id: true, operation: true, backupName: true, success: true, durationMs: true, summary: true, userEmail: true, ipAddress: true, createdAt: true, }, }); res.json({ data: logs }); } catch (error: any) { res.status(500).json({ error: 'Fehler beim Laden der Logs', details: error.message }); } } /** * Detail eines Backup-Logs inkl. fullLog * GET /api/settings/backup-logs/:id */ export async function getBackupLogDetail(req: Request, res: Response) { try { const id = parseInt(req.params.id, 10); if (!Number.isFinite(id) || id < 1) { return res.status(400).json({ error: 'Ungültige ID' }); } const log = await prisma.backupLog.findUnique({ where: { id } }); if (!log) { return res.status(404).json({ error: 'Log-Eintrag nicht gefunden' }); } res.json({ data: log }); } catch (error: any) { res.status(500).json({ error: 'Fehler beim Laden des Log-Details', details: error.message }); } }