Automatische DB-Backups: nächtlich, gzip, Rotation, optional Discord-Upload

- SQLite-Online-Backup (WAL-sicher) täglich 03:00 → data/backups/*.db.gz, 14 Tage Rotation
- Optional Offsite-Kopie als Upload in einen privaten Discord-Kanal (Setting)
- Setup-Seite: Toggle, Upload-Kanal, 'Backup jetzt'-Button; letztes Backup im Status-Panel

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 12:22:23 +02:00
co-authored by Claude Opus 4.8
parent db2e7987a0
commit 39f9fd4f67
4 changed files with 136 additions and 3 deletions
+80
View File
@@ -0,0 +1,80 @@
// Nächtliche SQLite-Backups: Online-Backup (WAL-sicher) → gzip → data/backups/,
// 14 Tage Rotation, optional Upload in einen privaten Discord-Kanal.
import { createReadStream, createWriteStream, mkdirSync, readdirSync, statSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { pipeline } from 'node:stream/promises';
import { createGzip } from 'node:zlib';
import { join, dirname, resolve } from 'node:path';
import { AttachmentBuilder } from 'discord.js';
import { db, getSetting, setSetting } from './db.js';
import { config } from './config.js';
const CHECK_INTERVAL_MS = 10 * 60 * 1000;
const KEEP_DAYS = 14;
const MAX_UPLOAD_BYTES = 9 * 1024 * 1024; // Discord-Limit für Bot-Uploads (10 MB, Puffer)
export const backupDir = join(dirname(resolve(config.dbPath)), 'backups');
mkdirSync(backupDir, { recursive: true });
/** Backup erstellen; gibt { file, sizeBytes } zurück */
export async function runBackup(client) {
const stamp = new Date().toISOString().slice(0, 10);
const rawFile = join(backupDir, `d4rkbot-${stamp}.db`);
const gzFile = `${rawFile}.gz`;
// SQLite-Online-Backup (konsistent trotz laufender Writes), dann komprimieren
await db.backup(rawFile);
await pipeline(createReadStream(rawFile), createGzip({ level: 9 }), createWriteStream(gzFile));
await rm(rawFile, { force: true });
// Rotation: alles älter als KEEP_DAYS löschen
const cutoff = Date.now() - KEEP_DAYS * 86400000;
for (const f of readdirSync(backupDir)) {
const path = join(backupDir, f);
if (f.endsWith('.db.gz') && statSync(path).mtimeMs < cutoff) {
await rm(path, { force: true });
}
}
const sizeBytes = statSync(gzFile).size;
// Optionaler Upload in einen (privaten!) Discord-Kanal — Offsite-Kopie
const channelId = getSetting('backup_channel_id');
if (channelId && client) {
if (sizeBytes <= MAX_UPLOAD_BYTES) {
const channel = await client.channels.fetch(channelId).catch(() => null);
if (channel?.isTextBased()) {
await channel.send({
content: `💾 Backup ${stamp} (${(sizeBytes / 1024).toFixed(0)} KB)`,
files: [new AttachmentBuilder(gzFile)],
});
}
} else {
console.warn(`[backup] ${gzFile} zu groß für Discord-Upload (${sizeBytes} B)`);
}
}
setSetting('last_backup', new Date().toISOString());
console.log(`[backup] ${gzFile} erstellt (${(sizeBytes / 1024).toFixed(0)} KB)`);
return { file: gzFile, sizeBytes };
}
/** Scheduler: täglich zwischen 03:00 und 04:00 (lokale TZ), einmal pro Tag */
export function scheduleBackups(client) {
const check = async () => {
if (getSetting('backup_enabled') === '0') return;
const now = new Date();
if (now.getHours() !== 3) return;
const today = now.toISOString().slice(0, 10);
if ((getSetting('last_backup') ?? '').slice(0, 10) === today) return;
try {
await runBackup(client);
} catch (error) {
console.error('[backup] Fehlgeschlagen:', error);
}
};
setInterval(check, CHECK_INTERVAL_MS);
check();
}