diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index 2397c1f..f371085 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -134,6 +134,10 @@ export default function Settings({ me }) {
{data.status.devlogs}Devlogs
{data.status.commits}Commits
{fmtBytes(data.status.dbSizeBytes)}Datenbank
+
+ {data.status.lastBackup ? data.status.lastBackup.slice(0, 10) : '—'}
+ Letztes Backup
+
@@ -282,6 +286,35 @@ export default function Settings({ me }) {
+ {/* Backups */}
+
+
// Backups
+
+
+
+
+
+
+
+
+
+
+
+
{/* API-Keys */}
// API-Keys (/api/v1)
diff --git a/src/backup.js b/src/backup.js
new file mode 100644
index 0000000..2518784
--- /dev/null
+++ b/src/backup.js
@@ -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();
+}
diff --git a/src/index.js b/src/index.js
index 98306cd..f8279dc 100644
--- a/src/index.js
+++ b/src/index.js
@@ -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);
diff --git a/src/web/api.js b/src/web/api.js
index a5e8833..fc668f5 100644
--- a/src/web/api.js
+++ b/src/web/api.js
@@ -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()