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()