From 0cbd93e4e089f1f067b8081e3fea6b00d1284f7d Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Wed, 29 Apr 2026 14:04:04 -0300 Subject: [PATCH] feat(settings): add database backup management --- docs/installation.md | 24 +++++-- public/api.js | 14 +++- public/locales/ar.json | 18 +++++- public/locales/de.json | 18 +++++- public/locales/el.json | 18 +++++- public/locales/en.json | 18 +++++- public/locales/es.json | 18 +++++- public/locales/fr.json | 18 +++++- public/locales/hi.json | 18 +++++- public/locales/it.json | 18 +++++- public/locales/ja.json | 18 +++++- public/locales/pt.json | 18 +++++- public/locales/ru.json | 18 +++++- public/locales/sv.json | 18 +++++- public/locales/tr.json | 18 +++++- public/locales/uk.json | 18 +++++- public/locales/zh.json | 18 +++++- public/pages/settings.js | 128 +++++++++++++++++++++++++++++++++++++ public/styles/settings.css | 102 +++++++++++++++++++++++++++++ public/sw.js | 8 +-- scripts/restore-backup.js | 35 ++++++++++ server/db.js | 117 +++++++++++++++++++++++++++++++-- server/index.js | 2 + server/openapi.js | 52 +++++++++++++++ server/routes/backup.js | 97 ++++++++++++++++++++++++++++ 25 files changed, 816 insertions(+), 33 deletions(-) create mode 100644 scripts/restore-backup.js create mode 100644 server/routes/backup.js diff --git a/docs/installation.md b/docs/installation.md index 8cdaf88..39f1e07 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -457,23 +457,33 @@ The SQLite database lives in a Docker named volume called `oikos_data`, mounted ### Backup -Copy the database from the running container to your host: +Use the built-in backup helper to create a consistent SQLite backup from the running container, then copy it to your host: ```bash -docker compose exec oikos cp /data/oikos.db /data/oikos-backup.db +docker compose exec oikos node -e "import('./server/db.js').then(async db => { await db.backupToFile('/data/oikos-backup.db'); process.exit(0); })" docker cp oikos:/data/oikos-backup.db ./oikos-backup-$(date +%Y%m%d).db -docker compose exec oikos rm /data/oikos-backup.db ``` +Admins can also download a backup from **Settings → Backup Management**. + ### Restore -Copy a backup file back into the container and restart: +Admins can restore a backup from **Settings → Backup Management**. For operational restores via Docker Compose, stop the running app, mount the backup into a temporary container that uses the same Docker volume, and run the restore helper: ```bash -docker cp ./oikos-backup-20260401.db oikos:/data/oikos.db -docker compose restart +docker compose stop oikos +docker compose run --rm -v "$PWD/oikos-backup-20260401.db:/tmp/oikos-restore.db:ro" --entrypoint node oikos scripts/restore-backup.js /tmp/oikos-restore.db +docker compose up -d ``` +For a local CLI restore outside Docker, set the same environment variables used by the app and run: + +```bash +DB_PATH=/path/to/oikos.db node --import dotenv/config scripts/restore-backup.js ./oikos-backup-20260401.db +``` + +The restore helper validates that the file is an Oikos database before replacing the active database. It also keeps a pre-restore copy next to the database file for emergency rollback. + ### Automated Backups Add a cron job to back up daily (adjust the path to your preference): @@ -485,7 +495,7 @@ crontab -e Add this line: ``` -0 3 * * * docker cp oikos:/data/oikos.db /path/to/backups/oikos-$(date +\%Y\%m\%d).db +0 3 * * * docker compose exec -T oikos node -e "import('./server/db.js').then(async db => { await db.backupToFile('/data/oikos-cron-backup.db'); process.exit(0); })" && docker cp oikos:/data/oikos-cron-backup.db /path/to/backups/oikos-$(date +\%Y\%m\%d).db ``` This creates a backup at 3:00 AM every day. diff --git a/public/api.js b/public/api.js index b41817d..f05c83d 100644 --- a/public/api.js +++ b/public/api.js @@ -31,16 +31,17 @@ async function apiFetch(path, options = {}, _retried = false) { const method = options.method ?? 'GET'; const stateChanging = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method); + const { headers: optionHeaders = {}, ...fetchOptions } = options; const response = await fetch(url, { credentials: 'same-origin', cache: 'no-store', + ...fetchOptions, headers: { 'Content-Type': 'application/json', ...(stateChanging ? { 'X-CSRF-Token': getCsrfToken() } : {}), - ...options.headers, + ...optionHeaders, }, - ...options, }); if (response.status === 401) { @@ -115,6 +116,15 @@ const api = { body: JSON.stringify(body), }), + rawPost: (path, body, headers = {}) => apiFetch(path, { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream', + ...headers, + }, + body, + }), + put: (path, body) => apiFetch(path, { method: 'PUT', body: JSON.stringify(body), diff --git a/public/locales/ar.json b/public/locales/ar.json index f342dae..419f4bd 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -752,7 +752,23 @@ "memberContactBirthdayHint": "تتم مزامنة هذا العضو تلقائيًا مع جهات الاتصال وأعياد الميلاد.", "memberBirthDateInvalid": "استخدم تاريخ ميلاد صحيحًا بالتنسيق المحدد.", "memberPhoneMeta": "الهاتف: {{value}}", - "memberBirthdayMeta": "عيد الميلاد: {{date}}" + "memberBirthdayMeta": "عيد الميلاد: {{date}}", + "tabBackup": "إدارة النسخ الاحتياطي", + "sectionBackup": "إدارة النسخ الاحتياطي", + "backupDownloadTitle": "تنزيل نسخة احتياطية من قاعدة البيانات", + "backupDownloadHint": "ينشئ نسخة SQLite متسقة لكل بيانات التطبيق.", + "backupDownloadButton": "تنزيل النسخة الاحتياطية", + "backupRestoreTitle": "استعادة نسخة قاعدة البيانات", + "backupRestoreHint": "الاستعادة تستبدل قاعدة البيانات الحالية. نزّل نسخة حديثة قبل المتابعة.", + "backupDropzoneTitle": "أسقط ملف النسخة هنا أو انقر للاختيار", + "backupDropzoneHint": "ملفات SQLite: .db أو .sqlite أو .sqlite3", + "backupRestoreButton": "استعادة النسخة", + "backupRestoreConfirm": "ستستبدل هذه الاستعادة قاعدة البيانات الحالية للجميع. هل تريد المتابعة؟", + "backupRestoring": "جارٍ الاستعادة...", + "backupRestoredToast": "تمت استعادة قاعدة البيانات. جارٍ إعادة التحميل...", + "backupCliTitle": "استعادة CLI / Docker Compose", + "backupCliHint": "للاستعادة التشغيلية، اربط النسخة داخل الحاوية وشغّل مساعد الاستعادة.", + "backupCliBackupHint": "يمكنك أيضًا إنشاء نسخة مباشرة عبر Docker Compose:" }, "login": { "tagline": "تخطيط عائلي. آمن. يحترم الخصوصية. مفتوح المصدر.", diff --git a/public/locales/de.json b/public/locales/de.json index bc269bf..8200f3c 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -777,7 +777,23 @@ "memberContactBirthdayHint": "Dieses Mitglied wird automatisch mit Kontakten und Geburtstagen synchronisiert.", "memberBirthDateInvalid": "Bitte ein gültiges Geburtstagsdatum im ausgewählten Format verwenden.", "memberPhoneMeta": "Telefon: {{value}}", - "memberBirthdayMeta": "Geburtstag: {{date}}" + "memberBirthdayMeta": "Geburtstag: {{date}}", + "tabBackup": "Backup-Verwaltung", + "sectionBackup": "Backup-Verwaltung", + "backupDownloadTitle": "Datenbank-Backup herunterladen", + "backupDownloadHint": "Erstellt ein konsistentes SQLite-Backup aller Anwendungsdaten.", + "backupDownloadButton": "Backup herunterladen", + "backupRestoreTitle": "Datenbank-Backup wiederherstellen", + "backupRestoreHint": "Die Wiederherstellung ersetzt die aktuelle Datenbank. Lade vorher ein frisches Backup herunter.", + "backupDropzoneTitle": "Backup-Datei hier ablegen oder zum Auswählen klicken", + "backupDropzoneHint": "SQLite-Backup-Dateien: .db, .sqlite oder .sqlite3", + "backupRestoreButton": "Backup wiederherstellen", + "backupRestoreConfirm": "Dieses Backup ersetzt die aktuelle Datenbank für alle Benutzer. Fortfahren?", + "backupRestoring": "Wird wiederhergestellt...", + "backupRestoredToast": "Datenbank wiederhergestellt. Seite wird neu geladen...", + "backupCliTitle": "CLI / Docker-Compose-Wiederherstellung", + "backupCliHint": "Kopiere für operative Wiederherstellungen das Backup in den Container und starte den Restore-Helfer.", + "backupCliBackupHint": "Du kannst auch direkt über Docker Compose ein Backup erstellen:" }, "login": { "tagline": "Familienplanung. Sicher. Datenschutzfreundlich. Open Source.", diff --git a/public/locales/el.json b/public/locales/el.json index b1c39e6..f56659a 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -752,7 +752,23 @@ "memberContactBirthdayHint": "Αυτό το μέλος συγχρονίζεται αυτόματα με τις Επαφές και τα Γενέθλια.", "memberBirthDateInvalid": "Χρησιμοποιήστε μια έγκυρη ημερομηνία γέννησης στην επιλεγμένη μορφή.", "memberPhoneMeta": "Τηλέφωνο: {{value}}", - "memberBirthdayMeta": "Γενέθλια: {{date}}" + "memberBirthdayMeta": "Γενέθλια: {{date}}", + "tabBackup": "Διαχείριση αντιγράφων", + "sectionBackup": "Διαχείριση αντιγράφων", + "backupDownloadTitle": "Λήψη αντιγράφου βάσης δεδομένων", + "backupDownloadHint": "Δημιουργεί συνεπές αντίγραφο SQLite όλων των δεδομένων.", + "backupDownloadButton": "Λήψη αντιγράφου", + "backupRestoreTitle": "Επαναφορά αντιγράφου βάσης δεδομένων", + "backupRestoreHint": "Η επαναφορά αντικαθιστά την τρέχουσα βάση. Κατεβάστε νέο αντίγραφο πριν συνεχίσετε.", + "backupDropzoneTitle": "Αφήστε εδώ ένα αρχείο αντιγράφου ή κάντε κλικ για επιλογή", + "backupDropzoneHint": "Αρχεία SQLite: .db, .sqlite ή .sqlite3", + "backupRestoreButton": "Επαναφορά αντιγράφου", + "backupRestoreConfirm": "Αυτό το αντίγραφο θα αντικαταστήσει την τρέχουσα βάση για όλους. Συνέχεια;", + "backupRestoring": "Γίνεται επαναφορά...", + "backupRestoredToast": "Η βάση επαναφέρθηκε. Επαναφόρτωση...", + "backupCliTitle": "Επαναφορά CLI / Docker Compose", + "backupCliHint": "Για λειτουργική επαναφορά, προσαρτήστε το αντίγραφο στο container και εκτελέστε τον βοηθό.", + "backupCliBackupHint": "Μπορείτε επίσης να δημιουργήσετε αντίγραφο απευθείας με Docker Compose:" }, "login": { "tagline": "Οικογενειακός προγραμματισμός. Ασφαλής. Φιλικός προς την ιδιωτικότητα. Ανοιχτός κώδικας.", diff --git a/public/locales/en.json b/public/locales/en.json index c9c5d11..8eeb9d9 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -752,7 +752,23 @@ "memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.", "memberBirthDateInvalid": "Use a valid birthday date in the selected date format.", "memberPhoneMeta": "Phone: {{value}}", - "memberBirthdayMeta": "Birthday: {{date}}" + "memberBirthdayMeta": "Birthday: {{date}}", + "tabBackup": "Backup Management", + "sectionBackup": "Backup Management", + "backupDownloadTitle": "Download database backup", + "backupDownloadHint": "Create a consistent SQLite backup of all application data.", + "backupDownloadButton": "Download backup", + "backupRestoreTitle": "Restore database backup", + "backupRestoreHint": "Restore replaces the current database. Download a fresh backup before continuing.", + "backupDropzoneTitle": "Drop a backup file here or click to select", + "backupDropzoneHint": "SQLite backup files: .db, .sqlite or .sqlite3", + "backupRestoreButton": "Restore backup", + "backupRestoreConfirm": "Restoring this backup will replace the current database for everyone. Continue?", + "backupRestoring": "Restoring...", + "backupRestoredToast": "Database restored. Reloading...", + "backupCliTitle": "CLI / Docker Compose restore", + "backupCliHint": "For operational restores, copy the backup into the container and run the restore helper.", + "backupCliBackupHint": "You can also create a backup directly from Docker Compose:" }, "login": { "tagline": "Family planning. Secure. Privacy-friendly. Open source.", diff --git a/public/locales/es.json b/public/locales/es.json index 4e6716a..465d8a5 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -752,7 +752,23 @@ "memberContactBirthdayHint": "Este miembro se sincroniza automáticamente con Contactos y Cumpleaños.", "memberBirthDateInvalid": "Usa una fecha de nacimiento válida en el formato seleccionado.", "memberPhoneMeta": "Teléfono: {{value}}", - "memberBirthdayMeta": "Cumpleaños: {{date}}" + "memberBirthdayMeta": "Cumpleaños: {{date}}", + "tabBackup": "Gestión de copias", + "sectionBackup": "Gestión de copias", + "backupDownloadTitle": "Descargar copia de la base de datos", + "backupDownloadHint": "Crea una copia SQLite consistente de todos los datos de la aplicación.", + "backupDownloadButton": "Descargar copia", + "backupRestoreTitle": "Restaurar copia de la base de datos", + "backupRestoreHint": "La restauración reemplaza la base de datos actual. Descarga una copia reciente antes de continuar.", + "backupDropzoneTitle": "Suelta un archivo de copia aquí o haz clic para seleccionarlo", + "backupDropzoneHint": "Archivos SQLite: .db, .sqlite o .sqlite3", + "backupRestoreButton": "Restaurar copia", + "backupRestoreConfirm": "Restaurar esta copia reemplazará la base de datos actual para todos. ¿Continuar?", + "backupRestoring": "Restaurando...", + "backupRestoredToast": "Base de datos restaurada. Recargando...", + "backupCliTitle": "Restauración por CLI / Docker Compose", + "backupCliHint": "Para restauraciones operativas, monta la copia en el contenedor y ejecuta el asistente de restauración.", + "backupCliBackupHint": "También puedes crear una copia directamente con Docker Compose:" }, "login": { "tagline": "Planificación familiar. Segura. Privada. Código abierto.", diff --git a/public/locales/fr.json b/public/locales/fr.json index 82cf675..dc2b111 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -752,7 +752,23 @@ "memberContactBirthdayHint": "Ce membre est automatiquement synchronisé avec les Contacts et les Anniversaires.", "memberBirthDateInvalid": "Utilisez une date de naissance valide dans le format sélectionné.", "memberPhoneMeta": "Téléphone : {{value}}", - "memberBirthdayMeta": "Anniversaire : {{date}}" + "memberBirthdayMeta": "Anniversaire : {{date}}", + "tabBackup": "Gestion des sauvegardes", + "sectionBackup": "Gestion des sauvegardes", + "backupDownloadTitle": "Télécharger la sauvegarde de la base", + "backupDownloadHint": "Crée une sauvegarde SQLite cohérente de toutes les données.", + "backupDownloadButton": "Télécharger la sauvegarde", + "backupRestoreTitle": "Restaurer une sauvegarde de la base", + "backupRestoreHint": "La restauration remplace la base actuelle. Télécharge une sauvegarde récente avant de continuer.", + "backupDropzoneTitle": "Dépose un fichier de sauvegarde ici ou clique pour le choisir", + "backupDropzoneHint": "Fichiers SQLite : .db, .sqlite ou .sqlite3", + "backupRestoreButton": "Restaurer la sauvegarde", + "backupRestoreConfirm": "Cette restauration remplacera la base actuelle pour tout le monde. Continuer ?", + "backupRestoring": "Restauration...", + "backupRestoredToast": "Base restaurée. Rechargement...", + "backupCliTitle": "Restauration CLI / Docker Compose", + "backupCliHint": "Pour une restauration opérationnelle, monte la sauvegarde dans le conteneur et lance l’assistant.", + "backupCliBackupHint": "Tu peux aussi créer une sauvegarde directement avec Docker Compose :" }, "login": { "tagline": "Planification familiale. Sécurisée. Respectueuse de la vie privée. Open source.", diff --git a/public/locales/hi.json b/public/locales/hi.json index 0f2c858..382af74 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -752,7 +752,23 @@ "memberContactBirthdayHint": "यह सदस्य स्वचालित रूप से संपर्क और जन्मदिन के साथ समन्वयित होता है।", "memberBirthDateInvalid": "चयनित दिनांक प्रारूप में एक मान्य जन्म तिथि का उपयोग करें।", "memberPhoneMeta": "फ़ोन: {{value}}", - "memberBirthdayMeta": "जन्मदिन: {{date}}" + "memberBirthdayMeta": "जन्मदिन: {{date}}", + "tabBackup": "बैकअप प्रबंधन", + "sectionBackup": "बैकअप प्रबंधन", + "backupDownloadTitle": "डेटाबेस बैकअप डाउनलोड करें", + "backupDownloadHint": "सभी ऐप डेटा का एक संगत SQLite बैकअप बनाता है।", + "backupDownloadButton": "बैकअप डाउनलोड करें", + "backupRestoreTitle": "डेटाबेस बैकअप पुनर्स्थापित करें", + "backupRestoreHint": "पुनर्स्थापना मौजूदा डेटाबेस को बदल देगी। जारी रखने से पहले नया बैकअप डाउनलोड करें।", + "backupDropzoneTitle": "बैकअप फ़ाइल यहाँ छोड़ें या चुनने के लिए क्लिक करें", + "backupDropzoneHint": "SQLite बैकअप फ़ाइलें: .db, .sqlite या .sqlite3", + "backupRestoreButton": "बैकअप पुनर्स्थापित करें", + "backupRestoreConfirm": "यह बैकअप सभी के लिए मौजूदा डेटाबेस को बदल देगा। जारी रखें?", + "backupRestoring": "पुनर्स्थापित हो रहा है...", + "backupRestoredToast": "डेटाबेस पुनर्स्थापित हुआ। फिर से लोड हो रहा है...", + "backupCliTitle": "CLI / Docker Compose पुनर्स्थापना", + "backupCliHint": "ऑपरेशनल पुनर्स्थापना के लिए, बैकअप को कंटेनर में माउंट करें और restore helper चलाएँ।", + "backupCliBackupHint": "आप Docker Compose से सीधे बैकअप भी बना सकते हैं:" }, "login": { "tagline": "पारिवारिक योजना। सुरक्षित। गोपनीयता-अनुकूल। ओपन सोर्स।", diff --git a/public/locales/it.json b/public/locales/it.json index 9e085c7..c7e2287 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -752,7 +752,23 @@ "memberContactBirthdayHint": "Questo membro viene sincronizzato automaticamente con i Contatti e i Compleanni.", "memberBirthDateInvalid": "Usa una data di nascita valida nel formato selezionato.", "memberPhoneMeta": "Telefono: {{value}}", - "memberBirthdayMeta": "Compleanno: {{date}}" + "memberBirthdayMeta": "Compleanno: {{date}}", + "tabBackup": "Gestione backup", + "sectionBackup": "Gestione backup", + "backupDownloadTitle": "Scarica backup del database", + "backupDownloadHint": "Crea un backup SQLite coerente di tutti i dati dell’applicazione.", + "backupDownloadButton": "Scarica backup", + "backupRestoreTitle": "Ripristina backup del database", + "backupRestoreHint": "Il ripristino sostituisce il database corrente. Scarica un backup recente prima di continuare.", + "backupDropzoneTitle": "Trascina qui un file di backup o fai clic per selezionarlo", + "backupDropzoneHint": "File SQLite: .db, .sqlite o .sqlite3", + "backupRestoreButton": "Ripristina backup", + "backupRestoreConfirm": "Questo backup sostituirà il database corrente per tutti. Continuare?", + "backupRestoring": "Ripristino...", + "backupRestoredToast": "Database ripristinato. Ricaricamento...", + "backupCliTitle": "Ripristino CLI / Docker Compose", + "backupCliHint": "Per ripristini operativi, monta il backup nel container ed esegui l’helper.", + "backupCliBackupHint": "Puoi anche creare un backup direttamente con Docker Compose:" }, "login": { "tagline": "Pianificazione familiare. Sicura. Rispettosa della privacy. Open source.", diff --git a/public/locales/ja.json b/public/locales/ja.json index 7995b14..9fc43a0 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -752,7 +752,23 @@ "memberContactBirthdayHint": "このメンバーは連絡先と誕生日と自動的に同期されます。", "memberBirthDateInvalid": "選択した日付形式で有効な生年月日を入力してください。", "memberPhoneMeta": "電話: {{value}}", - "memberBirthdayMeta": "誕生日: {{date}}" + "memberBirthdayMeta": "誕生日: {{date}}", + "tabBackup": "バックアップ管理", + "sectionBackup": "バックアップ管理", + "backupDownloadTitle": "データベースバックアップをダウンロード", + "backupDownloadHint": "すべてのアプリデータを含む一貫した SQLite バックアップを作成します。", + "backupDownloadButton": "バックアップをダウンロード", + "backupRestoreTitle": "データベースバックアップを復元", + "backupRestoreHint": "復元すると現在のデータベースが置き換えられます。続行前に新しいバックアップをダウンロードしてください。", + "backupDropzoneTitle": "バックアップファイルをここにドロップするかクリックして選択", + "backupDropzoneHint": "SQLite バックアップファイル: .db、.sqlite、.sqlite3", + "backupRestoreButton": "バックアップを復元", + "backupRestoreConfirm": "このバックアップを復元すると全員の現在のデータベースが置き換えられます。続行しますか?", + "backupRestoring": "復元中...", + "backupRestoredToast": "データベースを復元しました。再読み込み中...", + "backupCliTitle": "CLI / Docker Compose 復元", + "backupCliHint": "運用復元では、バックアップをコンテナにマウントして復元ヘルパーを実行します。", + "backupCliBackupHint": "Docker Compose から直接バックアップを作成することもできます:" }, "login": { "tagline": "家族計画。安全。プライバシー重視。オープンソース。", diff --git a/public/locales/pt.json b/public/locales/pt.json index e02332e..1fbd7d8 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -752,7 +752,23 @@ "memberContactBirthdayHint": "Este membro é sincronizado automaticamente com Contatos e Aniversários.", "memberBirthDateInvalid": "Use uma data de aniversário válida no formato selecionado.", "memberPhoneMeta": "Telefone: {{value}}", - "memberBirthdayMeta": "Aniversário: {{date}}" + "memberBirthdayMeta": "Aniversário: {{date}}", + "tabBackup": "Gestão de backups", + "sectionBackup": "Gestão de backups", + "backupDownloadTitle": "Baixar backup do banco de dados", + "backupDownloadHint": "Cria um backup SQLite consistente com todos os dados da aplicação.", + "backupDownloadButton": "Baixar backup", + "backupRestoreTitle": "Restaurar backup do banco de dados", + "backupRestoreHint": "A restauração substitui o banco atual. Baixe um backup novo antes de continuar.", + "backupDropzoneTitle": "Arraste um arquivo de backup aqui ou clique para selecionar", + "backupDropzoneHint": "Arquivos de backup SQLite: .db, .sqlite ou .sqlite3", + "backupRestoreButton": "Restaurar backup", + "backupRestoreConfirm": "Restaurar este backup substituirá o banco de dados atual para todos. Continuar?", + "backupRestoring": "Restaurando...", + "backupRestoredToast": "Banco de dados restaurado. Recarregando...", + "backupCliTitle": "Restauração via CLI / Docker Compose", + "backupCliHint": "Para restaurações operacionais, copie o backup para o container e execute o helper de restauração.", + "backupCliBackupHint": "Você também pode criar um backup diretamente pelo Docker Compose:" }, "login": { "tagline": "Planejamento familiar. Seguro. Privado. Código aberto.", diff --git a/public/locales/ru.json b/public/locales/ru.json index 4ad7293..c370d92 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -752,7 +752,23 @@ "memberContactBirthdayHint": "Этот участник автоматически синхронизируется с контактами и днями рождения.", "memberBirthDateInvalid": "Используйте действительную дату рождения в выбранном формате.", "memberPhoneMeta": "Телефон: {{value}}", - "memberBirthdayMeta": "День рождения: {{date}}" + "memberBirthdayMeta": "День рождения: {{date}}", + "tabBackup": "Управление резервными копиями", + "sectionBackup": "Управление резервными копиями", + "backupDownloadTitle": "Скачать резервную копию базы данных", + "backupDownloadHint": "Создает согласованную SQLite-копию всех данных приложения.", + "backupDownloadButton": "Скачать копию", + "backupRestoreTitle": "Восстановить резервную копию базы данных", + "backupRestoreHint": "Восстановление заменит текущую базу данных. Перед продолжением скачайте свежую копию.", + "backupDropzoneTitle": "Перетащите файл копии сюда или нажмите для выбора", + "backupDropzoneHint": "Файлы SQLite: .db, .sqlite или .sqlite3", + "backupRestoreButton": "Восстановить копию", + "backupRestoreConfirm": "Эта копия заменит текущую базу данных для всех. Продолжить?", + "backupRestoring": "Восстановление...", + "backupRestoredToast": "База данных восстановлена. Перезагрузка...", + "backupCliTitle": "Восстановление CLI / Docker Compose", + "backupCliHint": "Для операционного восстановления подключите копию к контейнеру и запустите помощник восстановления.", + "backupCliBackupHint": "Также можно создать копию напрямую через Docker Compose:" }, "login": { "tagline": "Семейное планирование. Безопасно. С уважением к приватности. Открытый исходный код.", diff --git a/public/locales/sv.json b/public/locales/sv.json index fb700de..340ac4f 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -752,7 +752,23 @@ "memberContactBirthdayHint": "Den här medlemmen synkroniseras automatiskt med Kontakter och Födelsedagar.", "memberBirthDateInvalid": "Använd ett giltigt födelsedatum i det valda datumformatet.", "memberPhoneMeta": "Telefon: {{value}}", - "memberBirthdayMeta": "Födelsedag: {{date}}" + "memberBirthdayMeta": "Födelsedag: {{date}}", + "tabBackup": "Backuphantering", + "sectionBackup": "Backuphantering", + "backupDownloadTitle": "Ladda ner databasbackup", + "backupDownloadHint": "Skapar en konsekvent SQLite-backup av alla appdata.", + "backupDownloadButton": "Ladda ner backup", + "backupRestoreTitle": "Återställ databasbackup", + "backupRestoreHint": "Återställning ersätter den aktuella databasen. Ladda ner en ny backup innan du fortsätter.", + "backupDropzoneTitle": "Släpp en backupfil här eller klicka för att välja", + "backupDropzoneHint": "SQLite-backuper: .db, .sqlite eller .sqlite3", + "backupRestoreButton": "Återställ backup", + "backupRestoreConfirm": "Den här backupen ersätter aktuell databas för alla. Fortsätta?", + "backupRestoring": "Återställer...", + "backupRestoredToast": "Databasen återställd. Laddar om...", + "backupCliTitle": "CLI / Docker Compose-återställning", + "backupCliHint": "För driftåterställning, montera backupen i containern och kör återställningshjälpen.", + "backupCliBackupHint": "Du kan också skapa en backup direkt med Docker Compose:" }, "login": { "tagline": "Familjeplanering. Säker. Sekretessvänlig. Öppen källkod.", diff --git a/public/locales/tr.json b/public/locales/tr.json index bfa2345..0a26d13 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -752,7 +752,23 @@ "memberContactBirthdayHint": "Bu üye otomatik olarak Kişiler ve Doğum Günleri ile senkronize edilir.", "memberBirthDateInvalid": "Seçilen tarih formatında geçerli bir doğum tarihi kullanın.", "memberPhoneMeta": "Telefon: {{value}}", - "memberBirthdayMeta": "Doğum günü: {{date}}" + "memberBirthdayMeta": "Doğum günü: {{date}}", + "tabBackup": "Yedek yönetimi", + "sectionBackup": "Yedek yönetimi", + "backupDownloadTitle": "Veritabanı yedeğini indir", + "backupDownloadHint": "Tüm uygulama verilerinin tutarlı bir SQLite yedeğini oluşturur.", + "backupDownloadButton": "Yedeği indir", + "backupRestoreTitle": "Veritabanı yedeğini geri yükle", + "backupRestoreHint": "Geri yükleme mevcut veritabanını değiştirir. Devam etmeden önce yeni bir yedek indirin.", + "backupDropzoneTitle": "Yedek dosyasını buraya bırakın veya seçmek için tıklayın", + "backupDropzoneHint": "SQLite yedek dosyaları: .db, .sqlite veya .sqlite3", + "backupRestoreButton": "Yedeği geri yükle", + "backupRestoreConfirm": "Bu yedek herkes için mevcut veritabanını değiştirecek. Devam edilsin mi?", + "backupRestoring": "Geri yükleniyor...", + "backupRestoredToast": "Veritabanı geri yüklendi. Yeniden yükleniyor...", + "backupCliTitle": "CLI / Docker Compose geri yükleme", + "backupCliHint": "Operasyonel geri yüklemeler için yedeği konteynere bağlayın ve geri yükleme yardımcısını çalıştırın.", + "backupCliBackupHint": "Docker Compose ile doğrudan yedek de oluşturabilirsiniz:" }, "login": { "tagline": "Aile planlaması. Güvenli. Gizlilik dostu. Açık kaynak.", diff --git a/public/locales/uk.json b/public/locales/uk.json index f27b19e..79f11d5 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -752,7 +752,23 @@ "memberContactBirthdayHint": "Цей учасник автоматично синхронізується з контактами та днями народження.", "memberBirthDateInvalid": "Використовуйте дійсну дату народження у вибраному форматі.", "memberPhoneMeta": "Телефон: {{value}}", - "memberBirthdayMeta": "День народження: {{date}}" + "memberBirthdayMeta": "День народження: {{date}}", + "tabBackup": "Керування резервними копіями", + "sectionBackup": "Керування резервними копіями", + "backupDownloadTitle": "Завантажити резервну копію бази даних", + "backupDownloadHint": "Створює узгоджену SQLite-копію всіх даних застосунку.", + "backupDownloadButton": "Завантажити копію", + "backupRestoreTitle": "Відновити резервну копію бази даних", + "backupRestoreHint": "Відновлення замінить поточну базу даних. Перед продовженням завантажте свіжу копію.", + "backupDropzoneTitle": "Перетягніть файл копії сюди або натисніть для вибору", + "backupDropzoneHint": "Файли SQLite: .db, .sqlite або .sqlite3", + "backupRestoreButton": "Відновити копію", + "backupRestoreConfirm": "Ця копія замінить поточну базу даних для всіх. Продовжити?", + "backupRestoring": "Відновлення...", + "backupRestoredToast": "Базу даних відновлено. Перезавантаження...", + "backupCliTitle": "Відновлення CLI / Docker Compose", + "backupCliHint": "Для операційного відновлення підключіть копію до контейнера та запустіть помічник.", + "backupCliBackupHint": "Також можна створити копію безпосередньо через Docker Compose:" }, "login": { "tagline": "Планування для родини. Безпечно. Конфіденційно. Відкритий код.", diff --git a/public/locales/zh.json b/public/locales/zh.json index fdbbafb..a5a417a 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -752,7 +752,23 @@ "memberContactBirthdayHint": "此成员自动与联系人和生日同步。", "memberBirthDateInvalid": "请使用所选日期格式的有效出生日期。", "memberPhoneMeta": "电话:{{value}}", - "memberBirthdayMeta": "生日:{{date}}" + "memberBirthdayMeta": "生日:{{date}}", + "tabBackup": "备份管理", + "sectionBackup": "备份管理", + "backupDownloadTitle": "下载数据库备份", + "backupDownloadHint": "创建包含所有应用数据的一致 SQLite 备份。", + "backupDownloadButton": "下载备份", + "backupRestoreTitle": "恢复数据库备份", + "backupRestoreHint": "恢复会替换当前数据库。继续前请先下载新的备份。", + "backupDropzoneTitle": "将备份文件拖到这里,或点击选择", + "backupDropzoneHint": "SQLite 备份文件:.db、.sqlite 或 .sqlite3", + "backupRestoreButton": "恢复备份", + "backupRestoreConfirm": "恢复此备份将为所有用户替换当前数据库。继续吗?", + "backupRestoring": "正在恢复...", + "backupRestoredToast": "数据库已恢复。正在重新加载...", + "backupCliTitle": "CLI / Docker Compose 恢复", + "backupCliHint": "对于运维恢复,请将备份挂载到容器并运行恢复助手。", + "backupCliBackupHint": "也可以直接通过 Docker Compose 创建备份:" }, "login": { "tagline": "家庭规划。安全。注重隐私。开源。", diff --git a/public/pages/settings.js b/public/pages/settings.js index 976ab2a..f7954c3 100644 --- a/public/pages/settings.js +++ b/public/pages/settings.js @@ -244,6 +244,7 @@ export async function render(container, { user }) { 'general', 'meals', 'budget', 'shopping', 'calendar', ...(user?.role === 'admin' ? ['family', 'api-tokens'] : []), 'account', + ...(user?.role === 'admin' ? ['backup'] : []), ]; const storedTab = sessionStorage.getItem(SETTINGS_TAB_KEY) ?? 'general'; const activeTab = (syncOk || syncErr) @@ -272,6 +273,7 @@ export async function render(container, { user }) { ${user?.role === 'admin' ? `` : ''} ${user?.role === 'admin' ? `` : ''} + ${user?.role === 'admin' ? `` : ''} @@ -712,6 +714,62 @@ export async function render(container, { user }) { + + ${user?.role === 'admin' ? ` + +
+
+

${t('settings.sectionBackup')}

+ +
+
+ +
+
+

${t('settings.backupDownloadTitle')}

+

${t('settings.backupDownloadHint')}

+ +
+
+ +
+
+ +
+
+

${t('settings.backupRestoreTitle')}

+

${t('settings.backupRestoreHint')}

+
+ + + + +
+ +
+
+
+
+ +
+

${t('settings.backupCliTitle')}

+

${t('settings.backupCliHint')}

+
docker compose stop oikos
+docker compose run --rm -v "$PWD/oikos-backup.db:/tmp/oikos-restore.db:ro" --entrypoint node oikos scripts/restore-backup.js /tmp/oikos-restore.db
+docker compose up -d
+

${t('settings.backupCliBackupHint')}

+
docker compose exec oikos node -e "import('./server/db.js').then(async db => { await db.backupToFile('/data/oikos-backup.db'); process.exit(0); })"
+docker cp oikos:/data/oikos-backup.db ./oikos-backup.db
+
+
+
+ ` : ''} `; @@ -737,6 +795,7 @@ function bindEvents(container, user, users, categories, icsSubscriptions, apiTok bindCategoryEvents(container); bindIcsEvents(container, user, icsSubscriptions); bindApiTokenEvents(container, apiTokens); + bindBackupEvents(container); // Theme-Toggle const themeToggle = container.querySelector('#theme-toggle'); if (themeToggle) { @@ -1403,6 +1462,75 @@ function bindApiTokenEvents(container, initialTokens) { }); } +function bindBackupEvents(container) { + const form = container.querySelector('#backup-restore-form'); + const fileInput = container.querySelector('#backup-restore-file'); + const selectedFile = container.querySelector('#backup-selected-file'); + const restoreBtn = container.querySelector('#backup-restore-btn'); + const errorEl = container.querySelector('#backup-restore-error'); + const dropzone = container.querySelector('#backup-dropzone'); + + if (!form || !fileInput || !selectedFile || !restoreBtn || !errorEl) return; + + function setFile(file) { + if (!file) { + selectedFile.hidden = true; + selectedFile.textContent = ''; + restoreBtn.disabled = true; + return; + } + selectedFile.textContent = `${file.name} · ${Math.round(file.size / 1024)} KB`; + selectedFile.hidden = false; + restoreBtn.disabled = false; + } + + fileInput.addEventListener('change', () => { + errorEl.hidden = true; + setFile(fileInput.files?.[0]); + }); + + dropzone?.addEventListener('dragover', (e) => { + e.preventDefault(); + dropzone.classList.add('settings-backup-dropzone--active'); + }); + + dropzone?.addEventListener('dragleave', () => { + dropzone.classList.remove('settings-backup-dropzone--active'); + }); + + dropzone?.addEventListener('drop', (e) => { + e.preventDefault(); + dropzone.classList.remove('settings-backup-dropzone--active'); + const file = e.dataTransfer?.files?.[0]; + if (!file) return; + const transfer = new DataTransfer(); + transfer.items.add(file); + fileInput.files = transfer.files; + errorEl.hidden = true; + setFile(file); + }); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const file = fileInput.files?.[0]; + if (!file) return; + if (!await confirmModal(t('settings.backupRestoreConfirm'), { danger: true, confirmLabel: t('settings.backupRestoreButton') })) return; + + errorEl.hidden = true; + restoreBtn.disabled = true; + restoreBtn.textContent = t('settings.backupRestoring'); + try { + await api.rawPost('/backup/restore', file); + window.oikos?.showToast(t('settings.backupRestoredToast'), 'success'); + window.location.reload(); + } catch (err) { + showError(errorEl, err.message ?? t('common.errorGeneric')); + restoreBtn.disabled = false; + restoreBtn.textContent = t('settings.backupRestoreButton'); + } + }); +} + // -------------------------------------------------------- // Kategorie-Verwaltung diff --git a/public/styles/settings.css b/public/styles/settings.css index 6b0dfc7..834acce 100644 --- a/public/styles/settings.css +++ b/public/styles/settings.css @@ -466,6 +466,108 @@ background: var(--color-surface-2); } +/* -------------------------------------------------------- + Backup Management + -------------------------------------------------------- */ + +.settings-backup-card { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--space-4); + align-items: start; +} + +.settings-backup-card__icon { + width: 44px; + height: 44px; + border-radius: var(--radius-sm); + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--color-accent-light); + color: var(--color-accent); +} + +.settings-backup-card--danger .settings-backup-card__icon { + background: var(--color-warning-light, var(--color-surface-2)); + color: var(--color-warning, var(--color-text-primary)); +} + +.settings-backup-card__icon svg { + width: 22px; + height: 22px; +} + +.settings-backup-card__body { + min-width: 0; +} + +.settings-backup-dropzone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-2); + min-height: 148px; + padding: var(--space-5); + border: 1.5px dashed var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface-2); + color: var(--color-text-secondary); + text-align: center; + cursor: pointer; + transition: border-color var(--transition-fast), background-color var(--transition-fast), color var(--transition-fast); +} + +.settings-backup-dropzone:hover, +.settings-backup-dropzone--active { + border-color: var(--color-accent); + background: var(--color-accent-light); + color: var(--color-accent); +} + +.settings-backup-dropzone svg { + width: 28px; + height: 28px; +} + +.settings-backup-dropzone span { + font-size: var(--text-base); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); +} + +.settings-backup-dropzone small { + font-size: var(--text-sm); +} + +.settings-backup-file { + padding: var(--space-3); + border-radius: var(--radius-sm); + background: var(--color-surface-2); + color: var(--color-text-primary); + font-size: var(--text-sm); + font-weight: var(--font-weight-medium); +} + +.settings-code-block { + margin: var(--space-3) 0; + padding: var(--space-3); + overflow-x: auto; + border-radius: var(--radius-sm); + background: var(--color-surface-2); + border: 1px solid var(--color-border); + color: var(--color-text-primary); + font-size: var(--text-sm); + line-height: 1.5; +} + +@media (max-width: 560px) { + .settings-backup-card { + grid-template-columns: 1fr; + } +} + /* -------------------------------------------------------- Theme-Toggle -------------------------------------------------------- */ diff --git a/public/sw.js b/public/sw.js index 1fbec0c..b75e7aa 100644 --- a/public/sw.js +++ b/public/sw.js @@ -13,10 +13,10 @@ * → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit) */ -const SHELL_CACHE = 'oikos-shell-v68'; -const PAGES_CACHE = 'oikos-pages-v63'; -const LOCALES_CACHE = 'oikos-locales-v12'; -const ASSETS_CACHE = 'oikos-assets-v63'; +const SHELL_CACHE = 'oikos-shell-v69'; +const PAGES_CACHE = 'oikos-pages-v64'; +const LOCALES_CACHE = 'oikos-locales-v13'; +const ASSETS_CACHE = 'oikos-assets-v64'; const BYPASS_CACHE = 'oikos-bypass-flag'; const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, LOCALES_CACHE, ASSETS_CACHE]; diff --git a/scripts/restore-backup.js b/scripts/restore-backup.js new file mode 100644 index 0000000..6ad00f0 --- /dev/null +++ b/scripts/restore-backup.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node + +/** + * Restore an Oikos database backup from the CLI. + * + * Usage: + * node --import dotenv/config scripts/restore-backup.js /path/to/oikos-backup.db + */ + +import path from 'node:path'; +import fs from 'node:fs/promises'; +import 'dotenv/config'; +import { getPath, restoreFromFile } from '../server/db.js'; + +const backupPath = process.argv[2]; + +if (!backupPath) { + console.error('Usage: node --import dotenv/config scripts/restore-backup.js /path/to/oikos-backup.db'); + process.exit(1); +} + +const resolved = path.resolve(backupPath); + +try { + await fs.access(resolved); + const result = await restoreFromFile(resolved); + console.log(`Restored ${resolved} into ${getPath()}. Schema v${result.schemaVersion}.`); + if (result.rollbackPath) { + console.log(`Previous database copy saved at ${result.rollbackPath}.`); + } + process.exit(0); +} catch (err) { + console.error(`Restore failed: ${err?.message || err}`); + process.exit(1); +} diff --git a/server/db.js b/server/db.js index 3c407f9..d9adb6c 100644 --- a/server/db.js +++ b/server/db.js @@ -11,6 +11,7 @@ import Database from 'better-sqlite3'; import path from 'path'; +import fs from 'node:fs/promises'; import { createLogger } from './logger.js'; const log = createLogger('DB'); @@ -33,13 +34,12 @@ function init() { if (db) return db; db = new Database(DB_PATH); - if (DB_KEY) { - // Nur wirksam wenn Binary gegen SQLCipher kompiliert ist (Docker) - db.pragma(`key="x'${Buffer.from(DB_KEY, 'utf8').toString('hex')}'"`); + applyEncryptionKey(db); + if (DB_KEY) { // Sicherstellen dass die Datenbank tatsächlich entschlüsselbar ist try { - db.prepare('SELECT count(*) FROM sqlite_master').get(); + assertReadable(db); } catch { throw new Error('[DB] Wrong encryption key or SQLCipher support is unavailable.'); } @@ -56,6 +56,16 @@ function init() { return db; } +function applyEncryptionKey(database) { + if (!DB_KEY) return; + // Nur wirksam wenn Binary gegen SQLCipher kompiliert ist (Docker) + database.pragma(`key="x'${Buffer.from(DB_KEY, 'utf8').toString('hex')}'"`); +} + +function assertReadable(database) { + database.prepare('SELECT count(*) FROM sqlite_master').get(); +} + // -------------------------------------------------------- // Migrations-Engine // -------------------------------------------------------- @@ -912,6 +922,103 @@ function currentVersion() { } } +function getPath() { + return DB_PATH; +} + +async function backupToFile(destinationPath) { + const database = get(); + await fs.mkdir(path.dirname(destinationPath), { recursive: true }); + + if (typeof database.backup === 'function') { + await database.backup(destinationPath); + } else { + database.prepare('VACUUM INTO ?').run(destinationPath); + } + + return destinationPath; +} + +function validateBackupFile(sourcePath) { + const candidate = new Database(sourcePath, { readonly: true, fileMustExist: true }); + try { + applyEncryptionKey(candidate); + assertReadable(candidate); + const row = candidate.prepare(` + SELECT name + FROM sqlite_master + WHERE type = 'table' AND name = 'schema_migrations' + `).get(); + if (!row) { + throw new Error('Backup file is not a valid Oikos database.'); + } + return candidate.prepare('SELECT MAX(version) AS version FROM schema_migrations').get()?.version ?? 0; + } finally { + candidate.close(); + } +} + +async function unlinkIfExists(filePath) { + try { + await fs.unlink(filePath); + } catch (err) { + if (err?.code !== 'ENOENT') throw err; + } +} + +async function restoreFromFile(sourcePath) { + const backupVersion = validateBackupFile(sourcePath); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const rollbackPath = `${DB_PATH}.pre-restore-${timestamp}`; + let rollbackCreated = false; + + try { + if (db) { + try { db.pragma('wal_checkpoint(TRUNCATE)'); } catch { /* best effort */ } + db.close(); + db = null; + } + + await fs.mkdir(path.dirname(DB_PATH), { recursive: true }); + try { + await fs.copyFile(DB_PATH, rollbackPath); + rollbackCreated = true; + } catch (err) { + if (err?.code !== 'ENOENT') throw err; + } + + await unlinkIfExists(`${DB_PATH}-wal`); + await unlinkIfExists(`${DB_PATH}-shm`); + await fs.copyFile(sourcePath, DB_PATH); + + init(); + log.info(`Database restored from backup. Schema v${backupVersion}${rollbackCreated ? ` | rollback: ${rollbackPath}` : ''}`); + + return { + schemaVersion: currentVersion(), + rollbackPath: rollbackCreated ? rollbackPath : null, + }; + } catch (err) { + if (rollbackCreated) { + try { + if (db) { + db.close(); + db = null; + } + await unlinkIfExists(`${DB_PATH}-wal`); + await unlinkIfExists(`${DB_PATH}-shm`); + await fs.copyFile(rollbackPath, DB_PATH); + init(); + } catch (rollbackErr) { + log.error('Rollback after failed restore also failed:', rollbackErr); + } + } else if (!db) { + try { init(); } catch { /* preserve original restore error */ } + } + throw err; + } +} + // -------------------------------------------------------- // Öffentliche API // -------------------------------------------------------- @@ -937,4 +1044,4 @@ function transaction(fn) { init(); // auto-initialise when module is first imported -export { init, get, transaction, currentVersion }; +export { init, get, transaction, currentVersion, getPath, backupToFile, restoreFromFile }; diff --git a/server/index.js b/server/index.js index 3431711..5050633 100644 --- a/server/index.js +++ b/server/index.js @@ -33,6 +33,7 @@ import preferencesRouter from './routes/preferences.js'; import remindersRouter from './routes/reminders.js'; import searchRouter from './routes/search.js'; import familyRouter from './routes/family.js'; +import backupRouter from './routes/backup.js'; const log = createLogger('Server'); const logSync = createLogger('Sync'); @@ -207,6 +208,7 @@ app.use('/api/v1/preferences', preferencesRouter); app.use('/api/v1/reminders', remindersRouter); app.use('/api/v1/search', searchRouter); app.use('/api/v1/family', familyRouter); +app.use('/api/v1/backup', backupRouter); // -------------------------------------------------------- // Health-Check (für Docker) diff --git a/server/openapi.js b/server/openapi.js index 2d9b49e..aa91bf3 100644 --- a/server/openapi.js +++ b/server/openapi.js @@ -272,6 +272,57 @@ function buildPaths() { }, }), }, + '/api/v1/backup/status': { + get: op({ + summary: 'Get backup status', + tag: 'Backup', + admin: true, + }), + }, + '/api/v1/backup/database': { + get: op({ + summary: 'Download database backup', + tag: 'Backup', + admin: true, + responses: { + 200: { + description: 'SQLite database backup file', + content: { + 'application/octet-stream': { + schema: { type: 'string', format: 'binary' }, + }, + }, + }, + 401: { $ref: '#/components/responses/Unauthorized' }, + 403: { $ref: '#/components/responses/Forbidden' }, + 500: { $ref: '#/components/responses/InternalServerError' }, + }, + }), + }, + '/api/v1/backup/restore': { + post: op({ + summary: 'Restore database backup', + tag: 'Backup', + admin: true, + stateChanging: true, + requestBody: { + required: true, + description: 'Raw SQLite database backup file.', + content: { + 'application/octet-stream': { + schema: { type: 'string', format: 'binary' }, + }, + }, + }, + responses: { + 200: { description: 'Database restored' }, + 400: { $ref: '#/components/responses/BadRequest' }, + 401: { $ref: '#/components/responses/Unauthorized' }, + 403: { $ref: '#/components/responses/Forbidden' }, + 500: { $ref: '#/components/responses/InternalServerError' }, + }, + }), + }, '/api/v1/dashboard': { get: op({ summary: 'Get dashboard data', tag: 'Dashboard' }) }, '/api/v1/tasks': { get: op({ summary: 'List tasks', tag: 'Tasks' }), @@ -498,6 +549,7 @@ function buildOpenApiSpec(req, appVersion) { { name: 'Contacts' }, { name: 'Birthdays' }, { name: 'Budget' }, + { name: 'Backup' }, { name: 'Weather' }, { name: 'Preferences' }, { name: 'Reminders' }, diff --git a/server/routes/backup.js b/server/routes/backup.js new file mode 100644 index 0000000..334016a --- /dev/null +++ b/server/routes/backup.js @@ -0,0 +1,97 @@ +/** + * Module: Database Backup + * Purpose: Authenticated admin-only database backup and restore endpoints. + * Dependencies: express, server/db.js + */ + +import express from 'express'; +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { backupToFile, currentVersion, restoreFromFile } from '../db.js'; +import { requireAdmin } from '../auth.js'; +import { createLogger } from '../logger.js'; + +const router = express.Router(); +const log = createLogger('Backup'); +const RESTORE_LIMIT = process.env.BACKUP_UPLOAD_LIMIT || '100mb'; + +function backupFileName() { + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + return `oikos-backup-${stamp}.db`; +} + +router.get('/status', requireAdmin, (req, res) => { + res.json({ + data: { + schema_version: currentVersion(), + restore_upload_limit: RESTORE_LIMIT, + }, + }); +}); + +router.get('/database', requireAdmin, async (req, res) => { + let tmpPath = null; + try { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oikos-backup-')); + tmpPath = path.join(dir, backupFileName()); + await backupToFile(tmpPath); + + res.setHeader('Cache-Control', 'no-store'); + res.download(tmpPath, path.basename(tmpPath), async (err) => { + try { await fs.rm(dir, { recursive: true, force: true }); } catch { /* best effort */ } + if (err && !res.headersSent) { + log.error('Backup download failed:', err); + } + }); + } catch (err) { + log.error('Database backup failed:', err); + if (tmpPath) { + try { await fs.rm(path.dirname(tmpPath), { recursive: true, force: true }); } catch { /* best effort */ } + } + res.status(500).json({ error: 'Database backup failed.', code: 500 }); + } +}); + +router.post( + '/restore', + requireAdmin, + express.raw({ type: 'application/octet-stream', limit: RESTORE_LIMIT }), + async (req, res) => { + let dir = null; + try { + if (!Buffer.isBuffer(req.body) || req.body.length === 0) { + return res.status(400).json({ error: 'Backup file is required.', code: 400 }); + } + + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oikos-restore-')); + const uploadPath = path.join(dir, 'restore.db'); + await fs.writeFile(uploadPath, req.body); + const result = await restoreFromFile(uploadPath); + + res.json({ + ok: true, + data: { + schema_version: result.schemaVersion, + }, + }); + } catch (err) { + log.error('Database restore failed:', err); + const message = err?.message || 'Database restore failed.'; + res.status(400).json({ error: message, code: 400 }); + } finally { + if (dir) { + try { await fs.rm(dir, { recursive: true, force: true }); } catch { /* best effort */ } + } + } + } +); + +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 }); + } + next(err); +}); + +export default router;