import { createWriteStream, mkdirSync, unlinkSync } from 'fs'; import { createGzip } from 'zlib'; import { join } from 'path'; import { spawn } from 'child_process'; import { getBackupData } from './phpdb'; export function triggerBackup(): void { setImmediate(() => runBackup().catch((e) => console.error('[backup] Fehler:', e))); } async function dumpToFile(filePath: string): Promise { const { tables } = await getBackupData(); const gzip = createGzip(); const file = createWriteStream(filePath); gzip.pipe(file); const write = (s: string) => new Promise((res, rej) => gzip.write(s, (e) => e ? rej(e) : res()) ); const now = new Date().toISOString(); await write(`-- Führungsbuch Backup ${now}\n-- Logbuch-Tabellen\n\nSET FOREIGN_KEY_CHECKS=0;\n\n`); for (const { name, createSql, rows } of tables) { await write(`DROP TABLE IF EXISTS \`${name}\`;\n`); await write(`${createSql};\n\n`); if (rows.length > 0) { const cols = Object.keys(rows[0]).map((c) => `\`${c}\``).join(', '); const batchSize = 200; for (let i = 0; i < rows.length; i += batchSize) { const batch = rows.slice(i, i + batchSize); const values = batch.map((row) => '(' + Object.values(row).map((v) => { if (v === null) return 'NULL'; if (typeof v === 'number') return String(v); return `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; }).join(', ') + ')' ).join(',\n '); await write(`INSERT INTO \`${name}\` (${cols}) VALUES\n ${values};\n`); } await write('\n'); } } await write('SET FOREIGN_KEY_CHECKS=1;\n'); await new Promise((resolve, reject) => { gzip.end(); file.on('close', resolve); file.on('error', reject); }); } async function runBackup(): Promise { const sshUrl = process.env.BACKUP_SSH_URL || ''; const localDir = process.env.BACKUP_LOCAL_DIR || '/tmp/logbuch-backup'; const ts = new Date().toISOString().replace('T', '_').replace(/:/g, '-').slice(0, 19); const filename = `sternwarte_${ts}.sql.gz`; mkdirSync(localDir, { recursive: true }); const localPath = join(localDir, filename); await dumpToFile(localPath); console.log(`[backup] Dump geschrieben: ${localPath}`); try { if (!sshUrl) return; const match = sshUrl.match(/^([^:]+):(.+)$/); if (!match) { console.error('[backup] BACKUP_SSH_URL muss das Format user@host:/pfad haben'); return; } const [, sshHost, remotePath] = match; const rawKeyPath = process.env.BACKUP_SSH_KEY_PATH || ''; const keyPath = rawKeyPath.startsWith('~') ? rawKeyPath.replace('~', process.env.HOME || '/root') : rawKeyPath; const sshOpts = [ ...(keyPath ? ['-i', keyPath] : []), '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15', ]; await new Promise((resolve, reject) => { const ssh = spawn('ssh', [...sshOpts, sshHost, `mkdir -p ${remotePath}`]); ssh.on('error', reject); ssh.on('close', (code) => code === 0 ? resolve() : reject(new Error(`mkdir -p exit ${code}`))); }); await new Promise((resolve, reject) => { const scp = spawn('scp', [...sshOpts, localPath, `${sshHost}:${remotePath}/${filename}`]); let scpErr = ''; scp.stderr.on('data', (d: Buffer) => { scpErr += d.toString(); }); scp.on('error', reject); scp.on('close', (code) => code === 0 ? resolve() : reject(new Error(`scp exit ${code}${scpErr ? ': ' + scpErr.trim() : ''}`)) ); }); console.log(`[backup] ${filename} → ${sshHost}:${remotePath}`); await new Promise((resolve) => { const ssh = spawn('ssh', [ ...sshOpts, sshHost, `find ${remotePath} -name 'sternwarte_*.sql.gz' -mtime +30 -delete`, ]); ssh.on('error', (e) => { console.error('[backup] Cleanup spawn-Fehler:', e.message); resolve(); }); ssh.on('close', (code) => { if (code !== 0) console.error('[backup] Cleanup fehlgeschlagen (exit ' + code + ')'); resolve(); }); }); } finally { try { unlinkSync(localPath); } catch { /* bereits gelöscht oder nie angelegt */ } } }