feat(settings): add database backup management

This commit is contained in:
Rafael Foster
2026-04-29 14:04:04 -03:00
parent 7e61a83db9
commit 0cbd93e4e0
25 changed files with 816 additions and 33 deletions
+17 -7
View File
@@ -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.
+12 -2
View File
@@ -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),
+17 -1
View File
@@ -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": "تخطيط عائلي. آمن. يحترم الخصوصية. مفتوح المصدر.",
+17 -1
View File
@@ -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.",
+17 -1
View File
@@ -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": "Οικογενειακός προγραμματισμός. Ασφαλής. Φιλικός προς την ιδιωτικότητα. Ανοιχτός κώδικας.",
+17 -1
View File
@@ -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.",
+17 -1
View File
@@ -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.",
+17 -1
View File
@@ -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 lassistant.",
"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.",
+17 -1
View File
@@ -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": "पारिवारिक योजना। सुरक्षित। गोपनीयता-अनुकूल। ओपन सोर्स।",
+17 -1
View File
@@ -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 dellapplicazione.",
"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 lhelper.",
"backupCliBackupHint": "Puoi anche creare un backup direttamente con Docker Compose:"
},
"login": {
"tagline": "Pianificazione familiare. Sicura. Rispettosa della privacy. Open source.",
+17 -1
View File
@@ -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": "家族計画。安全。プライバシー重視。オープンソース。",
+17 -1
View File
@@ -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.",
+17 -1
View File
@@ -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": "Семейное планирование. Безопасно. С уважением к приватности. Открытый исходный код.",
+17 -1
View File
@@ -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.",
+17 -1
View File
@@ -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.",
+17 -1
View File
@@ -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": "Планування для родини. Безпечно. Конфіденційно. Відкритий код.",
+17 -1
View File
@@ -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": "家庭规划。安全。注重隐私。开源。",
+128
View File
@@ -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' ? `<button class="${btnClass('family')}" role="tab" data-tab="family" aria-selected="${btnAria('family')}">${t('settings.tabFamily')}</button>` : ''}
${user?.role === 'admin' ? `<button class="${btnClass('api-tokens')}" role="tab" data-tab="api-tokens" aria-selected="${btnAria('api-tokens')}">${t('settings.tabApiTokens')}</button>` : ''}
<button class="${btnClass('account')}" role="tab" data-tab="account" aria-selected="${btnAria('account')}">${t('settings.tabAccount')}</button>
${user?.role === 'admin' ? `<button class="${btnClass('backup')}" role="tab" data-tab="backup" aria-selected="${btnAria('backup')}">${t('settings.tabBackup')}</button>` : ''}
</nav>
<!-- Panel: Allgemein (Design + Sprache) -->
@@ -712,6 +714,62 @@ export async function render(container, { user }) {
<button class="btn btn--danger-outline settings-logout-btn" id="logout-btn">${t('settings.logout')}</button>
</section>
</div>
${user?.role === 'admin' ? `
<!-- Panel: Backup Management -->
<div class="settings-tab-panel" data-panel="backup" role="tabpanel"${panelHidden('backup')}>
<section class="settings-section">
<h2 class="settings-section__title">${t('settings.sectionBackup')}</h2>
<div class="settings-card settings-backup-card">
<div class="settings-backup-card__icon">
<i data-lucide="database-backup" aria-hidden="true"></i>
</div>
<div class="settings-backup-card__body">
<h3 class="settings-card__title">${t('settings.backupDownloadTitle')}</h3>
<p class="form-hint">${t('settings.backupDownloadHint')}</p>
<div class="settings-form-actions">
<a class="btn btn--primary" href="/api/v1/backup/database" download>${t('settings.backupDownloadButton')}</a>
</div>
</div>
</div>
<div class="settings-card settings-backup-card settings-backup-card--danger">
<div class="settings-backup-card__icon">
<i data-lucide="rotate-ccw" aria-hidden="true"></i>
</div>
<div class="settings-backup-card__body">
<h3 class="settings-card__title">${t('settings.backupRestoreTitle')}</h3>
<p class="form-hint">${t('settings.backupRestoreHint')}</p>
<form id="backup-restore-form" class="settings-form settings-form--compact">
<label class="settings-backup-dropzone" id="backup-dropzone" for="backup-restore-file">
<i data-lucide="upload-cloud" aria-hidden="true"></i>
<span>${t('settings.backupDropzoneTitle')}</span>
<small>${t('settings.backupDropzoneHint')}</small>
</label>
<input class="sr-only" type="file" id="backup-restore-file" accept=".db,.sqlite,.sqlite3,application/octet-stream" />
<div class="settings-backup-file" id="backup-selected-file" hidden></div>
<div id="backup-restore-error" class="form-error" hidden></div>
<div class="settings-form-actions">
<button type="submit" class="btn btn--danger-outline" id="backup-restore-btn" disabled>${t('settings.backupRestoreButton')}</button>
</div>
</form>
</div>
</div>
<div class="settings-card">
<h3 class="settings-card__title">${t('settings.backupCliTitle')}</h3>
<p class="form-hint">${t('settings.backupCliHint')}</p>
<pre class="settings-code-block"><code>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</code></pre>
<p class="form-hint">${t('settings.backupCliBackupHint')}</p>
<pre class="settings-code-block"><code>docker compose exec oikos node -e "import('./server/db.js').then(async db =&gt; { await db.backupToFile('/data/oikos-backup.db'); process.exit(0); })"
docker cp oikos:/data/oikos-backup.db ./oikos-backup.db</code></pre>
</div>
</section>
</div>
` : ''}
</div>
`;
@@ -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
+102
View File
@@ -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
-------------------------------------------------------- */
+4 -4
View File
@@ -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];
+35
View File
@@ -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);
}
+112 -5
View File
@@ -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 };
+2
View File
@@ -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)
+52
View File
@@ -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' },
+97
View File
@@ -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;