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:
@@ -134,6 +134,10 @@ export default function Settings({ me }) {
|
||||
<div className="stat"><span className="stat-value">{data.status.devlogs}</span><span className="stat-label">Devlogs</span></div>
|
||||
<div className="stat"><span className="stat-value">{data.status.commits}</span><span className="stat-label">Commits</span></div>
|
||||
<div className="stat"><span className="stat-value">{fmtBytes(data.status.dbSizeBytes)}</span><span className="stat-label">Datenbank</span></div>
|
||||
<div className="stat">
|
||||
<span className="stat-value">{data.status.lastBackup ? data.status.lastBackup.slice(0, 10) : '—'}</span>
|
||||
<span className="stat-label">Letztes Backup</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -282,6 +286,35 @@ export default function Settings({ me }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backups */}
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Backups</h2>
|
||||
<div className="field">
|
||||
<label className="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.backup_enabled}
|
||||
onChange={(e) => setForm({ ...form, backup_enabled: e.target.checked })}
|
||||
/>
|
||||
<span>Nächtliches DB-Backup (03:00)</span>
|
||||
<span className="field-hint">gzip nach data/backups/, 14 Tage Rotation</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Upload-Kanal <span className="field-hint">Offsite-Kopie in einen PRIVATEN Kanal — leer = nur lokal</span></label>
|
||||
<div className="field-row">
|
||||
<select
|
||||
value={form.backup_channel_id ?? ''}
|
||||
onChange={(e) => setForm({ ...form, backup_channel_id: e.target.value })}
|
||||
>
|
||||
<option value="">— nur lokal —</option>
|
||||
{channelOptions}
|
||||
</select>
|
||||
<button className="btn" onClick={() => sendTest('backup')}>Backup jetzt</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API-Keys */}
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// API-Keys (/api/v1)</h2>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { startBot } from './bot/client.js';
|
||||
import { startWebServer } from './web/server.js';
|
||||
import { scheduleWeeklyRecap } from './bot/weekly-recap.js';
|
||||
import { startWatchdog } from './bot/watchdog.js';
|
||||
import { scheduleBackups } from './backup.js';
|
||||
|
||||
process.on('unhandledRejection', (error) => {
|
||||
console.error('[main] Unhandled Rejection:', error);
|
||||
@@ -13,6 +14,7 @@ try {
|
||||
await startWebServer(client);
|
||||
scheduleWeeklyRecap(client);
|
||||
startWatchdog(client);
|
||||
scheduleBackups(client);
|
||||
} catch (error) {
|
||||
console.error('[main] Start fehlgeschlagen:', error);
|
||||
process.exit(1);
|
||||
|
||||
+21
-3
@@ -147,6 +147,8 @@ ${rssItems}
|
||||
watchdog_urls: getSetting('watchdog_urls') ?? '',
|
||||
public_url: publicUrl(),
|
||||
gitea_url: getSetting('gitea_url') ?? config.giteaUrl,
|
||||
backup_enabled: getSetting('backup_enabled') !== '0',
|
||||
backup_channel_id: getSetting('backup_channel_id') ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -163,6 +165,7 @@ ${rssItems}
|
||||
uptimeSeconds: Math.floor(process.uptime()),
|
||||
guilds: client.guilds.cache.size,
|
||||
giteaTokenConfigured: Boolean(config.giteaApiToken),
|
||||
lastBackup: getSetting('last_backup'),
|
||||
...stats,
|
||||
},
|
||||
};
|
||||
@@ -173,11 +176,11 @@ ${rssItems}
|
||||
|
||||
const body = request.body ?? {};
|
||||
|
||||
// Kanäle: müssen existierende Textkanäle sein (release darf leer sein = deaktiviert)
|
||||
for (const key of ['commit_channel_id', 'devlog_channel_id', 'release_channel_id']) {
|
||||
// Kanäle: müssen existierende Textkanäle sein (release/backup dürfen leer sein = aus)
|
||||
for (const key of ['commit_channel_id', 'devlog_channel_id', 'release_channel_id', 'backup_channel_id']) {
|
||||
if (body[key] === undefined) continue;
|
||||
const value = String(body[key]);
|
||||
if (value === '' && key === 'release_channel_id') {
|
||||
if (value === '' && ['release_channel_id', 'backup_channel_id'].includes(key)) {
|
||||
setSetting(key, '');
|
||||
continue;
|
||||
}
|
||||
@@ -209,6 +212,9 @@ ${rssItems}
|
||||
if (body.devlog_threads_enabled !== undefined) {
|
||||
setSetting('devlog_threads_enabled', body.devlog_threads_enabled ? '1' : '0');
|
||||
}
|
||||
if (body.backup_enabled !== undefined) {
|
||||
setSetting('backup_enabled', body.backup_enabled ? '1' : '0');
|
||||
}
|
||||
for (const key of ['commit_branch_filter', 'ignored_repos', 'bug_report_repo', 'watchdog_urls']) {
|
||||
if (body[key] !== undefined) {
|
||||
setSetting(key, String(body[key]).trim());
|
||||
@@ -275,6 +281,18 @@ ${rssItems}
|
||||
return reply.code(502).send({ error: 'Senden fehlgeschlagen' });
|
||||
}
|
||||
}
|
||||
|
||||
// Sonderfall: Backup sofort erstellen
|
||||
if (target === 'backup') {
|
||||
try {
|
||||
const { runBackup } = await import('../backup.js');
|
||||
const result = await runBackup(client);
|
||||
return { ok: true, sizeBytes: result.sizeBytes };
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, 'Backup-Test fehlgeschlagen');
|
||||
return reply.code(502).send({ error: 'Backup fehlgeschlagen' });
|
||||
}
|
||||
}
|
||||
const channelId =
|
||||
target === 'commit' ? commitChannelId()
|
||||
: target === 'devlog' ? devlogChannelId()
|
||||
|
||||
Reference in New Issue
Block a user