a75303f857
- lib/db.ts entfernt, mysql2-Abhängigkeit gestrichen - lib/phpdb.ts: HTTP-Client für alle DB-Operationen via DB4js_all.php - Alle API-Routen und Server Actions auf phpdb.ts umgestellt - compose.yml / docker-compose.prod.yml: MySQL/phpMyAdmin-Container entfernt - app/api/DB4js_all.php/route.ts: Proxy für Statistik-AJAX-Calls - Statistik-Grafik liest ab 2026 live aus logbuch statt StatistikJahre - PHP 7.3-Kompatibilität: str_contains → strpos Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
124 lines
4.2 KiB
TypeScript
124 lines
4.2 KiB
TypeScript
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<void> {
|
|
const { tables } = await getBackupData();
|
|
|
|
const gzip = createGzip();
|
|
const file = createWriteStream(filePath);
|
|
gzip.pipe(file);
|
|
|
|
const write = (s: string) => new Promise<void>((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<void>((resolve, reject) => {
|
|
gzip.end();
|
|
file.on('close', resolve);
|
|
file.on('error', reject);
|
|
});
|
|
}
|
|
|
|
async function runBackup(): Promise<void> {
|
|
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<void>((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<void>((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<void>((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 */ }
|
|
}
|
|
}
|