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 });
+ });
+});