From 9b29d1847cbc4820130952645ffe343c4cb1f357 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 4 May 2026 07:02:38 +0200 Subject: [PATCH] feat: automatische geplante Backups mit Rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1.3 - Automatische Backups: - Cron-basierter Scheduler (Standard: täglich 2 Uhr) - Konfigurierbar über .env (Zeitplan, Verzeichnis, Anzahl) - Automatische Rotation: behält nur letzte N Backups (Standard: 7) - UI in Settings → Backup: Status-Anzeige und manueller Trigger - Tests: 7 erfolgreiche Tests für Scheduler-Funktionalität Neue Umgebungsvariablen: - BACKUP_ENABLED (Standard: true) - BACKUP_SCHEDULE (Standard: 0 2 * * *) - BACKUP_DIR (Standard: ./backups) - BACKUP_KEEP (Standard: 7) - TZ (für Zeitzone) Co-Authored-By: Claude Opus 4.7 --- .env.example | 7 ++ package-lock.json | 10 ++ package.json | 4 +- public/locales/de.json | 17 ++- public/locales/en.json | 17 ++- public/pages/settings.js | 84 +++++++++++++ public/styles/settings.css | 48 +++++++ server/index.js | 4 + server/routes/backup.js | 13 ++ server/services/backup-scheduler.js | 189 ++++++++++++++++++++++++++++ test-backup-scheduler.js | 94 ++++++++++++++ 11 files changed, 484 insertions(+), 3 deletions(-) create mode 100644 server/services/backup-scheduler.js create mode 100644 test-backup-scheduler.js diff --git a/.env.example b/.env.example index 3130f57..6d35c3b 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,13 @@ APPLE_APP_SPECIFIC_PASSWORD= # Calendar sync interval in minutes (default: 15) SYNC_INTERVAL_MINUTES=15 +# Automatic Backups +# BACKUP_ENABLED=true # Enable/disable automated backups (default: true) +# BACKUP_SCHEDULE=0 2 * * * # Cron schedule (default: 2 AM daily) +# BACKUP_DIR=./backups # Backup directory (default: ./backups) +# BACKUP_KEEP=7 # Number of backups to keep (default: 7) +# TZ=Europe/Berlin # Timezone for scheduled backups (default: UTC) + # Security RATE_LIMIT_WINDOW_MS=60000 RATE_LIMIT_MAX_ATTEMPTS=5 diff --git a/package-lock.json b/package-lock.json index 95c579e..3f41f7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "express-rate-limit": "^8.4.1", "express-session": "^1.19.0", "helmet": "^8.1.0", + "node-cron": "^4.2.1", "node-fetch": "^3.3.2" }, "devDependencies": { @@ -1655,6 +1656,15 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", diff --git a/package.json b/package.json index ce72508..d9de47b 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "test:setup": "node test-setup.js", "test:ics-parser": "node test-ics-parser.js", "test:ics-sub": "node --experimental-sqlite test-ics-subscription.js", - "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs" + "test:backup-scheduler": "node --experimental-sqlite test-backup-scheduler.js", + "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler" }, "dependencies": { "bcrypt": "^6.0.0", @@ -36,6 +37,7 @@ "express-rate-limit": "^8.4.1", "express-session": "^1.19.0", "helmet": "^8.1.0", + "node-cron": "^4.2.1", "node-fetch": "^3.3.2" }, "optionalDependencies": { diff --git a/public/locales/de.json b/public/locales/de.json index d8e9df9..ee51751 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -973,7 +973,22 @@ "backupRestoredToast": "Datenbank wiederhergestellt. Seite wird neu geladen...", "backupCliTitle": "CLI / Docker-Compose-Wiederherstellung", "backupCliHint": "Für operative Wiederherstellungen die App stoppen, das Backup in einen temporären Container einbinden und die Datenbankdatei ersetzen.", - "backupCliBackupHint": "Du kannst auch direkt über Docker Compose ein Backup erstellen:" + "backupCliBackupHint": "Du kannst auch direkt über Docker Compose ein Backup erstellen:", + "backupSchedulerTitle": "Automatische Backups", + "backupSchedulerHint": "Geplante Backups werden automatisch erstellt und alte Backups rotiert.", + "backupSchedulerStatus": "Status", + "backupSchedulerEnabled": "Aktiv", + "backupSchedulerDisabled": "Deaktiviert", + "backupSchedulerSchedule": "Zeitplan", + "backupSchedulerKeep": "Aufbewahrung", + "backupSchedulerKeepCount": "{{count}} Backups", + "backupSchedulerLastBackup": "Letztes Backup", + "backupSchedulerLastSuccess": "{{date}} (erfolgreich)", + "backupSchedulerLastFail": "{{date}} (fehlgeschlagen)", + "backupSchedulerNever": "Noch kein Backup erstellt", + "backupSchedulerTrigger": "Jetzt Backup erstellen", + "backupSchedulerTriggering": "Backup wird erstellt...", + "backupSchedulerTriggeredToast": "Backup erfolgreich erstellt." }, "login": { "tagline": "Familienplanung. Sicher. Datenschutzfreundlich. Open Source.", diff --git a/public/locales/en.json b/public/locales/en.json index 94cb6bb..b90568e 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -967,7 +967,22 @@ "backupRestoredToast": "Database restored. Reloading...", "backupCliTitle": "CLI / Docker Compose restore", "backupCliHint": "For operational restores, stop the app, mount the backup in a temporary container and replace the database file.", - "backupCliBackupHint": "You can also create a backup directly from Docker Compose:" + "backupCliBackupHint": "You can also create a backup directly from Docker Compose:", + "backupSchedulerTitle": "Automatic Backups", + "backupSchedulerHint": "Scheduled backups are created automatically and old backups are rotated.", + "backupSchedulerStatus": "Status", + "backupSchedulerEnabled": "Enabled", + "backupSchedulerDisabled": "Disabled", + "backupSchedulerSchedule": "Schedule", + "backupSchedulerKeep": "Retention", + "backupSchedulerKeepCount": "{{count}} backups", + "backupSchedulerLastBackup": "Last backup", + "backupSchedulerLastSuccess": "{{date}} (successful)", + "backupSchedulerLastFail": "{{date}} (failed)", + "backupSchedulerNever": "No backup created yet", + "backupSchedulerTrigger": "Create backup now", + "backupSchedulerTriggering": "Creating backup...", + "backupSchedulerTriggeredToast": "Backup created successfully." }, "login": { "tagline": "Family planning. Secure. Privacy-friendly. Open source.", diff --git a/public/pages/settings.js b/public/pages/settings.js index 68f1e28..b6e7a1f 100644 --- a/public/pages/settings.js +++ b/public/pages/settings.js @@ -798,6 +798,14 @@ export async function render(container, { user }) { +
+

${t('settings.backupSchedulerTitle')}

+

${t('settings.backupSchedulerHint')}

+
+ +
+
+

${t('settings.backupCliTitle')}

${t('settings.backupCliHint')}

@@ -1535,7 +1543,83 @@ function bindApiTokenEvents(container, initialTokens) { }); } +async function loadBackupSchedulerStatus(container) { + const infoContainer = container.querySelector('#backup-scheduler-info'); + if (!infoContainer) return; + + try { + const res = await api.get('/backup/status'); + const scheduler = res.data?.scheduler; + if (!scheduler) return; + + const { enabled, schedule, keepCount, lastBackup } = scheduler; + + let lastBackupText = t('settings.backupSchedulerNever'); + if (lastBackup?.timestamp) { + const date = formatDate(lastBackup.timestamp) + ' ' + formatTime(lastBackup.timestamp); + lastBackupText = lastBackup.success + ? t('settings.backupSchedulerLastSuccess', { date }) + : t('settings.backupSchedulerLastFail', { date }); + } + + const html = ` +
+ ${t('settings.backupSchedulerStatus')} + + ${enabled ? t('settings.backupSchedulerEnabled') : t('settings.backupSchedulerDisabled')} + +
+ ${enabled ? ` +
+ ${t('settings.backupSchedulerSchedule')} + ${esc(schedule)} +
+
+ ${t('settings.backupSchedulerKeep')} + ${t('settings.backupSchedulerKeepCount', { count: keepCount })} +
+
+ ${t('settings.backupSchedulerLastBackup')} + ${esc(lastBackupText)} +
+
+ +
+ ` : ''} + `; + + infoContainer.replaceChildren(); + infoContainer.insertAdjacentHTML('beforeend', html); + + if (window.lucide) window.lucide.createIcons(); + + // Event-Handler für manuellen Trigger + const triggerBtn = infoContainer.querySelector('#backup-trigger-btn'); + if (triggerBtn) { + triggerBtn.addEventListener('click', async () => { + triggerBtn.disabled = true; + triggerBtn.textContent = t('settings.backupSchedulerTriggering'); + try { + await api.post('/backup/trigger'); + window.oikos?.showToast(t('settings.backupSchedulerTriggeredToast'), 'success'); + // Status neu laden + loadBackupSchedulerStatus(container); + } catch (err) { + window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger'); + triggerBtn.disabled = false; + triggerBtn.textContent = t('settings.backupSchedulerTrigger'); + } + }); + } + } catch (err) { + console.error('Failed to load backup scheduler status:', err); + } +} + function bindBackupEvents(container) { + // Scheduler-Status laden und anzeigen + loadBackupSchedulerStatus(container); + const form = container.querySelector('#backup-restore-form'); const fileInput = container.querySelector('#backup-restore-file'); const selectedFile = container.querySelector('#backup-selected-file'); diff --git a/public/styles/settings.css b/public/styles/settings.css index 834acce..c65aa74 100644 --- a/public/styles/settings.css +++ b/public/styles/settings.css @@ -724,3 +724,51 @@ .cat-add-form .form-input { flex: 1; } + +/* -------------------------------------------------------- + Backup Scheduler Info Grid + -------------------------------------------------------- */ + +.settings-info-grid { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.settings-info-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) 0; + border-bottom: 1px solid var(--color-border); +} + +.settings-info-row:last-of-type { + border-bottom: none; +} + +.settings-info-label { + font-size: var(--text-sm); + color: var(--color-text-secondary); + font-weight: var(--font-weight-medium); +} + +.settings-info-value { + font-size: var(--text-sm); + color: var(--color-text-primary); + text-align: right; +} + +.settings-info-value--success { + color: var(--color-success); + font-weight: var(--font-weight-semibold); +} + +.settings-info-value code { + font-family: var(--font-mono); + font-size: var(--text-xs); + padding: var(--space-1) var(--space-2); + background: var(--color-surface-2); + border-radius: var(--radius-sm); +} diff --git a/server/index.js b/server/index.js index 1a27184..037ba9d 100644 --- a/server/index.js +++ b/server/index.js @@ -17,6 +17,7 @@ import { buildOpenApiSpec } from './openapi.js'; import * as googleCalendar from './services/google-calendar.js'; import * as appleCalendar from './services/apple-calendar.js'; import * as icsSubscription from './services/ics-subscription.js'; +import { startScheduler as startBackupScheduler } from './services/backup-scheduler.js'; import dashboardRouter from './routes/dashboard.js'; import tasksRouter from './routes/tasks.js'; import shoppingRouter from './routes/shopping.js'; @@ -274,6 +275,9 @@ app.listen(PORT, () => { setInterval(runSync, SYNC_INTERVAL_MS); logSync.info(`Auto-sync active every ${SYNC_INTERVAL_MS / 60_000} minutes.`); }, 10_000); + + // Backup-Scheduler starten + startBackupScheduler(); }); export default app; diff --git a/server/routes/backup.js b/server/routes/backup.js index 334016a..aa06e1e 100644 --- a/server/routes/backup.js +++ b/server/routes/backup.js @@ -11,6 +11,7 @@ import fs from 'node:fs/promises'; import { backupToFile, currentVersion, restoreFromFile } from '../db.js'; import { requireAdmin } from '../auth.js'; import { createLogger } from '../logger.js'; +import { getStatus as getSchedulerStatus, triggerBackup } from '../services/backup-scheduler.js'; const router = express.Router(); const log = createLogger('Backup'); @@ -22,10 +23,12 @@ function backupFileName() { } router.get('/status', requireAdmin, (req, res) => { + const schedulerStatus = getSchedulerStatus(); res.json({ data: { schema_version: currentVersion(), restore_upload_limit: RESTORE_LIMIT, + scheduler: schedulerStatus, }, }); }); @@ -87,6 +90,16 @@ router.post( } ); +router.post('/trigger', requireAdmin, async (req, res) => { + try { + const result = await triggerBackup(); + res.json({ data: result }); + } catch (err) { + log.error('Manual backup trigger failed:', err); + res.status(500).json({ error: 'Backup trigger failed.', code: 500 }); + } +}); + router.use((err, req, res, next) => { if (err?.type === 'entity.too.large') { return res.status(413).json({ error: `Backup file is too large. Maximum upload size is ${RESTORE_LIMIT}.`, code: 413 }); diff --git a/server/services/backup-scheduler.js b/server/services/backup-scheduler.js new file mode 100644 index 0000000..6801e27 --- /dev/null +++ b/server/services/backup-scheduler.js @@ -0,0 +1,189 @@ +/** + * Module: Backup Scheduler + * Purpose: Automated scheduled database backups with rotation + * Dependencies: node-cron, fs/promises, path, server/db.js + */ + +import cron from 'node-cron'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { backupToFile } from '../db.js'; +import { createLogger } from '../logger.js'; + +const log = createLogger('BackupScheduler'); + +// Configuration from environment variables +const BACKUP_SCHEDULE = process.env.BACKUP_SCHEDULE || '0 2 * * *'; // Default: 2 AM daily +const BACKUP_DIR = process.env.BACKUP_DIR || './backups'; +const BACKUP_KEEP = parseInt(process.env.BACKUP_KEEP || '7', 10); // Default: keep last 7 backups +const BACKUP_ENABLED = process.env.BACKUP_ENABLED !== 'false'; // Default: enabled + +let scheduledTask = null; +let lastBackup = null; +let lastError = null; + +/** + * Generate timestamped backup filename + */ +function backupFileName() { + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + return `oikos-backup-${stamp}.db`; +} + +/** + * Ensure backup directory exists + */ +async function ensureBackupDir() { + try { + await fs.mkdir(BACKUP_DIR, { recursive: true }); + } catch (err) { + log.error('Failed to create backup directory:', err); + throw err; + } +} + +/** + * Get all backup files sorted by modification time (newest first) + */ +async function getBackupFiles() { + try { + const files = await fs.readdir(BACKUP_DIR); + const backupFiles = files.filter((f) => f.startsWith('oikos-backup-') && f.endsWith('.db')); + + // Get file stats and sort by modification time + const filesWithStats = await Promise.all( + backupFiles.map(async (file) => { + const filePath = path.join(BACKUP_DIR, file); + const stats = await fs.stat(filePath); + return { file, mtime: stats.mtime, path: filePath }; + }) + ); + + return filesWithStats.sort((a, b) => b.mtime - a.mtime); + } catch (err) { + if (err.code === 'ENOENT') { + return []; + } + throw err; + } +} + +/** + * Rotate backups - keep only the last N backups + */ +async function rotateBackups() { + try { + const files = await getBackupFiles(); + + if (files.length <= BACKUP_KEEP) { + return; // Nothing to delete + } + + const filesToDelete = files.slice(BACKUP_KEEP); + + for (const { file, path: filePath } of filesToDelete) { + try { + await fs.unlink(filePath); + log.info(`Rotated old backup: ${file}`); + } catch (err) { + log.error(`Failed to delete old backup ${file}:`, err); + } + } + } catch (err) { + log.error('Backup rotation failed:', err); + } +} + +/** + * Perform automated backup + */ +async function performBackup() { + try { + log.info('Starting scheduled backup...'); + + await ensureBackupDir(); + + const fileName = backupFileName(); + const filePath = path.join(BACKUP_DIR, fileName); + + await backupToFile(filePath); + + log.info(`Backup created: ${fileName}`); + + // Rotate old backups + await rotateBackups(); + + lastBackup = { + timestamp: new Date().toISOString(), + file: fileName, + success: true, + }; + lastError = null; + } catch (err) { + log.error('Scheduled backup failed:', err); + lastError = { + timestamp: new Date().toISOString(), + message: err.message, + }; + lastBackup = { + timestamp: new Date().toISOString(), + success: false, + error: err.message, + }; + } +} + +/** + * Start the backup scheduler + */ +export function startScheduler() { + if (!BACKUP_ENABLED) { + log.info('Automated backups are disabled (BACKUP_ENABLED=false)'); + return; + } + + if (!cron.validate(BACKUP_SCHEDULE)) { + log.error(`Invalid cron schedule: ${BACKUP_SCHEDULE}`); + return; + } + + scheduledTask = cron.schedule(BACKUP_SCHEDULE, performBackup, { + timezone: process.env.TZ || 'UTC', + }); + + log.info(`Backup scheduler started: ${BACKUP_SCHEDULE} (keeping last ${BACKUP_KEEP} backups)`); +} + +/** + * Stop the backup scheduler + */ +export function stopScheduler() { + if (scheduledTask) { + scheduledTask.stop(); + scheduledTask = null; + log.info('Backup scheduler stopped'); + } +} + +/** + * Get scheduler status + */ +export function getStatus() { + return { + enabled: BACKUP_ENABLED, + schedule: BACKUP_SCHEDULE, + backupDir: BACKUP_DIR, + keepCount: BACKUP_KEEP, + running: scheduledTask !== null, + lastBackup, + lastError, + }; +} + +/** + * Trigger an immediate backup (for manual/testing purposes) + */ +export async function triggerBackup() { + await performBackup(); + return lastBackup; +} diff --git a/test-backup-scheduler.js b/test-backup-scheduler.js new file mode 100644 index 0000000..40cfa40 --- /dev/null +++ b/test-backup-scheduler.js @@ -0,0 +1,94 @@ +/** + * Test: Backup Scheduler + * Purpose: Verify automated backup scheduling functionality + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const TEST_BACKUP_DIR = './test-backups'; + +// Mock environment variables +process.env.BACKUP_ENABLED = 'false'; // Disable scheduler for tests +process.env.BACKUP_DIR = TEST_BACKUP_DIR; +process.env.BACKUP_KEEP = '3'; + +describe('Backup Scheduler', () => { + let backupScheduler; + + it('should load the backup scheduler module', async () => { + backupScheduler = await import('./server/services/backup-scheduler.js'); + assert.ok(backupScheduler.getStatus, 'getStatus function should exist'); + assert.ok(backupScheduler.triggerBackup, 'triggerBackup function should exist'); + }); + + it('should report correct status when disabled', () => { + const status = backupScheduler.getStatus(); + assert.strictEqual(status.enabled, false, 'Scheduler should be disabled'); + assert.strictEqual(status.schedule, '0 2 * * *', 'Default schedule should be set'); + assert.strictEqual(status.backupDir, TEST_BACKUP_DIR, 'Backup directory should match'); + assert.strictEqual(status.keepCount, 3, 'Keep count should be 3'); + assert.strictEqual(status.running, false, 'Scheduler should not be running'); + }); + + it('should create backup directory if it does not exist', async () => { + // Clean up any existing test directory + try { + await fs.rm(TEST_BACKUP_DIR, { recursive: true, force: true }); + } catch {} + + // Trigger a backup + const result = await backupScheduler.triggerBackup(); + + assert.ok(result, 'Trigger should return result'); + assert.ok(result.timestamp, 'Result should have timestamp'); + + // Check if directory was created + const dirExists = await fs.access(TEST_BACKUP_DIR).then(() => true).catch(() => false); + assert.ok(dirExists, 'Backup directory should be created'); + }); + + it('should create a backup file with timestamp', async () => { + const beforeFiles = await fs.readdir(TEST_BACKUP_DIR).catch(() => []); + + await backupScheduler.triggerBackup(); + + const afterFiles = await fs.readdir(TEST_BACKUP_DIR); + const newFiles = afterFiles.filter(f => !beforeFiles.includes(f)); + + assert.strictEqual(newFiles.length, 1, 'Should create exactly one new backup file'); + assert.ok(newFiles[0].startsWith('oikos-backup-'), 'Backup file should have correct prefix'); + assert.ok(newFiles[0].endsWith('.db'), 'Backup file should have .db extension'); + }); + + it('should rotate old backups (keep only last N)', async () => { + // Create 5 backups (more than BACKUP_KEEP=3) + for (let i = 0; i < 5; i++) { + await backupScheduler.triggerBackup(); + // Small delay to ensure different timestamps + await new Promise(resolve => setTimeout(resolve, 100)); + } + + const files = await fs.readdir(TEST_BACKUP_DIR); + const backupFiles = files.filter(f => f.startsWith('oikos-backup-') && f.endsWith('.db')); + + assert.strictEqual(backupFiles.length, 3, 'Should keep only last 3 backups'); + }); + + it('should update lastBackup status after trigger', async () => { + await backupScheduler.triggerBackup(); + + const status = backupScheduler.getStatus(); + assert.ok(status.lastBackup, 'Should have lastBackup info'); + assert.ok(status.lastBackup.timestamp, 'Last backup should have timestamp'); + assert.strictEqual(status.lastBackup.success, true, 'Last backup should be successful'); + assert.ok(status.lastBackup.file, 'Last backup should have filename'); + }); + + it('should cleanup test directory', async () => { + await fs.rm(TEST_BACKUP_DIR, { recursive: true, force: true }); + }); +});