feat(settings): add database backup management
This commit is contained in:
+17
-7
@@ -457,23 +457,33 @@ The SQLite database lives in a Docker named volume called `oikos_data`, mounted
|
|||||||
|
|
||||||
### Backup
|
### 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
|
```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 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
|
### 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
|
```bash
|
||||||
docker cp ./oikos-backup-20260401.db oikos:/data/oikos.db
|
docker compose stop oikos
|
||||||
docker compose restart
|
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
|
### Automated Backups
|
||||||
|
|
||||||
Add a cron job to back up daily (adjust the path to your preference):
|
Add a cron job to back up daily (adjust the path to your preference):
|
||||||
@@ -485,7 +495,7 @@ crontab -e
|
|||||||
Add this line:
|
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.
|
This creates a backup at 3:00 AM every day.
|
||||||
|
|||||||
+12
-2
@@ -31,16 +31,17 @@ async function apiFetch(path, options = {}, _retried = false) {
|
|||||||
|
|
||||||
const method = options.method ?? 'GET';
|
const method = options.method ?? 'GET';
|
||||||
const stateChanging = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
|
const stateChanging = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
|
||||||
|
const { headers: optionHeaders = {}, ...fetchOptions } = options;
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
|
...fetchOptions,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(stateChanging ? { 'X-CSRF-Token': getCsrfToken() } : {}),
|
...(stateChanging ? { 'X-CSRF-Token': getCsrfToken() } : {}),
|
||||||
...options.headers,
|
...optionHeaders,
|
||||||
},
|
},
|
||||||
...options,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
@@ -115,6 +116,15 @@ const api = {
|
|||||||
body: JSON.stringify(body),
|
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, {
|
put: (path, body) => apiFetch(path, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
|||||||
+17
-1
@@ -752,7 +752,23 @@
|
|||||||
"memberContactBirthdayHint": "تتم مزامنة هذا العضو تلقائيًا مع جهات الاتصال وأعياد الميلاد.",
|
"memberContactBirthdayHint": "تتم مزامنة هذا العضو تلقائيًا مع جهات الاتصال وأعياد الميلاد.",
|
||||||
"memberBirthDateInvalid": "استخدم تاريخ ميلاد صحيحًا بالتنسيق المحدد.",
|
"memberBirthDateInvalid": "استخدم تاريخ ميلاد صحيحًا بالتنسيق المحدد.",
|
||||||
"memberPhoneMeta": "الهاتف: {{value}}",
|
"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": {
|
"login": {
|
||||||
"tagline": "تخطيط عائلي. آمن. يحترم الخصوصية. مفتوح المصدر.",
|
"tagline": "تخطيط عائلي. آمن. يحترم الخصوصية. مفتوح المصدر.",
|
||||||
|
|||||||
+17
-1
@@ -777,7 +777,23 @@
|
|||||||
"memberContactBirthdayHint": "Dieses Mitglied wird automatisch mit Kontakten und Geburtstagen synchronisiert.",
|
"memberContactBirthdayHint": "Dieses Mitglied wird automatisch mit Kontakten und Geburtstagen synchronisiert.",
|
||||||
"memberBirthDateInvalid": "Bitte ein gültiges Geburtstagsdatum im ausgewählten Format verwenden.",
|
"memberBirthDateInvalid": "Bitte ein gültiges Geburtstagsdatum im ausgewählten Format verwenden.",
|
||||||
"memberPhoneMeta": "Telefon: {{value}}",
|
"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": {
|
"login": {
|
||||||
"tagline": "Familienplanung. Sicher. Datenschutzfreundlich. Open Source.",
|
"tagline": "Familienplanung. Sicher. Datenschutzfreundlich. Open Source.",
|
||||||
|
|||||||
+17
-1
@@ -752,7 +752,23 @@
|
|||||||
"memberContactBirthdayHint": "Αυτό το μέλος συγχρονίζεται αυτόματα με τις Επαφές και τα Γενέθλια.",
|
"memberContactBirthdayHint": "Αυτό το μέλος συγχρονίζεται αυτόματα με τις Επαφές και τα Γενέθλια.",
|
||||||
"memberBirthDateInvalid": "Χρησιμοποιήστε μια έγκυρη ημερομηνία γέννησης στην επιλεγμένη μορφή.",
|
"memberBirthDateInvalid": "Χρησιμοποιήστε μια έγκυρη ημερομηνία γέννησης στην επιλεγμένη μορφή.",
|
||||||
"memberPhoneMeta": "Τηλέφωνο: {{value}}",
|
"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": {
|
"login": {
|
||||||
"tagline": "Οικογενειακός προγραμματισμός. Ασφαλής. Φιλικός προς την ιδιωτικότητα. Ανοιχτός κώδικας.",
|
"tagline": "Οικογενειακός προγραμματισμός. Ασφαλής. Φιλικός προς την ιδιωτικότητα. Ανοιχτός κώδικας.",
|
||||||
|
|||||||
+17
-1
@@ -752,7 +752,23 @@
|
|||||||
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
|
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
|
||||||
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
|
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
|
||||||
"memberPhoneMeta": "Phone: {{value}}",
|
"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": {
|
"login": {
|
||||||
"tagline": "Family planning. Secure. Privacy-friendly. Open source.",
|
"tagline": "Family planning. Secure. Privacy-friendly. Open source.",
|
||||||
|
|||||||
+17
-1
@@ -752,7 +752,23 @@
|
|||||||
"memberContactBirthdayHint": "Este miembro se sincroniza automáticamente con Contactos y Cumpleaños.",
|
"memberContactBirthdayHint": "Este miembro se sincroniza automáticamente con Contactos y Cumpleaños.",
|
||||||
"memberBirthDateInvalid": "Usa una fecha de nacimiento válida en el formato seleccionado.",
|
"memberBirthDateInvalid": "Usa una fecha de nacimiento válida en el formato seleccionado.",
|
||||||
"memberPhoneMeta": "Teléfono: {{value}}",
|
"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": {
|
"login": {
|
||||||
"tagline": "Planificación familiar. Segura. Privada. Código abierto.",
|
"tagline": "Planificación familiar. Segura. Privada. Código abierto.",
|
||||||
|
|||||||
+17
-1
@@ -752,7 +752,23 @@
|
|||||||
"memberContactBirthdayHint": "Ce membre est automatiquement synchronisé avec les Contacts et les Anniversaires.",
|
"memberContactBirthdayHint": "Ce membre est automatiquement synchronisé avec les Contacts et les Anniversaires.",
|
||||||
"memberBirthDateInvalid": "Utilisez une date de naissance valide dans le format sélectionné.",
|
"memberBirthDateInvalid": "Utilisez une date de naissance valide dans le format sélectionné.",
|
||||||
"memberPhoneMeta": "Téléphone : {{value}}",
|
"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": {
|
"login": {
|
||||||
"tagline": "Planification familiale. Sécurisée. Respectueuse de la vie privée. Open source.",
|
"tagline": "Planification familiale. Sécurisée. Respectueuse de la vie privée. Open source.",
|
||||||
|
|||||||
+17
-1
@@ -752,7 +752,23 @@
|
|||||||
"memberContactBirthdayHint": "यह सदस्य स्वचालित रूप से संपर्क और जन्मदिन के साथ समन्वयित होता है।",
|
"memberContactBirthdayHint": "यह सदस्य स्वचालित रूप से संपर्क और जन्मदिन के साथ समन्वयित होता है।",
|
||||||
"memberBirthDateInvalid": "चयनित दिनांक प्रारूप में एक मान्य जन्म तिथि का उपयोग करें।",
|
"memberBirthDateInvalid": "चयनित दिनांक प्रारूप में एक मान्य जन्म तिथि का उपयोग करें।",
|
||||||
"memberPhoneMeta": "फ़ोन: {{value}}",
|
"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": {
|
"login": {
|
||||||
"tagline": "पारिवारिक योजना। सुरक्षित। गोपनीयता-अनुकूल। ओपन सोर्स।",
|
"tagline": "पारिवारिक योजना। सुरक्षित। गोपनीयता-अनुकूल। ओपन सोर्स।",
|
||||||
|
|||||||
+17
-1
@@ -752,7 +752,23 @@
|
|||||||
"memberContactBirthdayHint": "Questo membro viene sincronizzato automaticamente con i Contatti e i Compleanni.",
|
"memberContactBirthdayHint": "Questo membro viene sincronizzato automaticamente con i Contatti e i Compleanni.",
|
||||||
"memberBirthDateInvalid": "Usa una data di nascita valida nel formato selezionato.",
|
"memberBirthDateInvalid": "Usa una data di nascita valida nel formato selezionato.",
|
||||||
"memberPhoneMeta": "Telefono: {{value}}",
|
"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": {
|
"login": {
|
||||||
"tagline": "Pianificazione familiare. Sicura. Rispettosa della privacy. Open source.",
|
"tagline": "Pianificazione familiare. Sicura. Rispettosa della privacy. Open source.",
|
||||||
|
|||||||
+17
-1
@@ -752,7 +752,23 @@
|
|||||||
"memberContactBirthdayHint": "このメンバーは連絡先と誕生日と自動的に同期されます。",
|
"memberContactBirthdayHint": "このメンバーは連絡先と誕生日と自動的に同期されます。",
|
||||||
"memberBirthDateInvalid": "選択した日付形式で有効な生年月日を入力してください。",
|
"memberBirthDateInvalid": "選択した日付形式で有効な生年月日を入力してください。",
|
||||||
"memberPhoneMeta": "電話: {{value}}",
|
"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": {
|
"login": {
|
||||||
"tagline": "家族計画。安全。プライバシー重視。オープンソース。",
|
"tagline": "家族計画。安全。プライバシー重視。オープンソース。",
|
||||||
|
|||||||
+17
-1
@@ -752,7 +752,23 @@
|
|||||||
"memberContactBirthdayHint": "Este membro é sincronizado automaticamente com Contatos e Aniversários.",
|
"memberContactBirthdayHint": "Este membro é sincronizado automaticamente com Contatos e Aniversários.",
|
||||||
"memberBirthDateInvalid": "Use uma data de aniversário válida no formato selecionado.",
|
"memberBirthDateInvalid": "Use uma data de aniversário válida no formato selecionado.",
|
||||||
"memberPhoneMeta": "Telefone: {{value}}",
|
"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": {
|
"login": {
|
||||||
"tagline": "Planejamento familiar. Seguro. Privado. Código aberto.",
|
"tagline": "Planejamento familiar. Seguro. Privado. Código aberto.",
|
||||||
|
|||||||
+17
-1
@@ -752,7 +752,23 @@
|
|||||||
"memberContactBirthdayHint": "Этот участник автоматически синхронизируется с контактами и днями рождения.",
|
"memberContactBirthdayHint": "Этот участник автоматически синхронизируется с контактами и днями рождения.",
|
||||||
"memberBirthDateInvalid": "Используйте действительную дату рождения в выбранном формате.",
|
"memberBirthDateInvalid": "Используйте действительную дату рождения в выбранном формате.",
|
||||||
"memberPhoneMeta": "Телефон: {{value}}",
|
"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": {
|
"login": {
|
||||||
"tagline": "Семейное планирование. Безопасно. С уважением к приватности. Открытый исходный код.",
|
"tagline": "Семейное планирование. Безопасно. С уважением к приватности. Открытый исходный код.",
|
||||||
|
|||||||
+17
-1
@@ -752,7 +752,23 @@
|
|||||||
"memberContactBirthdayHint": "Den här medlemmen synkroniseras automatiskt med Kontakter och Födelsedagar.",
|
"memberContactBirthdayHint": "Den här medlemmen synkroniseras automatiskt med Kontakter och Födelsedagar.",
|
||||||
"memberBirthDateInvalid": "Använd ett giltigt födelsedatum i det valda datumformatet.",
|
"memberBirthDateInvalid": "Använd ett giltigt födelsedatum i det valda datumformatet.",
|
||||||
"memberPhoneMeta": "Telefon: {{value}}",
|
"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": {
|
"login": {
|
||||||
"tagline": "Familjeplanering. Säker. Sekretessvänlig. Öppen källkod.",
|
"tagline": "Familjeplanering. Säker. Sekretessvänlig. Öppen källkod.",
|
||||||
|
|||||||
+17
-1
@@ -752,7 +752,23 @@
|
|||||||
"memberContactBirthdayHint": "Bu üye otomatik olarak Kişiler ve Doğum Günleri ile senkronize edilir.",
|
"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.",
|
"memberBirthDateInvalid": "Seçilen tarih formatında geçerli bir doğum tarihi kullanın.",
|
||||||
"memberPhoneMeta": "Telefon: {{value}}",
|
"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": {
|
"login": {
|
||||||
"tagline": "Aile planlaması. Güvenli. Gizlilik dostu. Açık kaynak.",
|
"tagline": "Aile planlaması. Güvenli. Gizlilik dostu. Açık kaynak.",
|
||||||
|
|||||||
+17
-1
@@ -752,7 +752,23 @@
|
|||||||
"memberContactBirthdayHint": "Цей учасник автоматично синхронізується з контактами та днями народження.",
|
"memberContactBirthdayHint": "Цей учасник автоматично синхронізується з контактами та днями народження.",
|
||||||
"memberBirthDateInvalid": "Використовуйте дійсну дату народження у вибраному форматі.",
|
"memberBirthDateInvalid": "Використовуйте дійсну дату народження у вибраному форматі.",
|
||||||
"memberPhoneMeta": "Телефон: {{value}}",
|
"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": {
|
"login": {
|
||||||
"tagline": "Планування для родини. Безпечно. Конфіденційно. Відкритий код.",
|
"tagline": "Планування для родини. Безпечно. Конфіденційно. Відкритий код.",
|
||||||
|
|||||||
+17
-1
@@ -752,7 +752,23 @@
|
|||||||
"memberContactBirthdayHint": "此成员自动与联系人和生日同步。",
|
"memberContactBirthdayHint": "此成员自动与联系人和生日同步。",
|
||||||
"memberBirthDateInvalid": "请使用所选日期格式的有效出生日期。",
|
"memberBirthDateInvalid": "请使用所选日期格式的有效出生日期。",
|
||||||
"memberPhoneMeta": "电话:{{value}}",
|
"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": {
|
"login": {
|
||||||
"tagline": "家庭规划。安全。注重隐私。开源。",
|
"tagline": "家庭规划。安全。注重隐私。开源。",
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ export async function render(container, { user }) {
|
|||||||
'general', 'meals', 'budget', 'shopping', 'calendar',
|
'general', 'meals', 'budget', 'shopping', 'calendar',
|
||||||
...(user?.role === 'admin' ? ['family', 'api-tokens'] : []),
|
...(user?.role === 'admin' ? ['family', 'api-tokens'] : []),
|
||||||
'account',
|
'account',
|
||||||
|
...(user?.role === 'admin' ? ['backup'] : []),
|
||||||
];
|
];
|
||||||
const storedTab = sessionStorage.getItem(SETTINGS_TAB_KEY) ?? 'general';
|
const storedTab = sessionStorage.getItem(SETTINGS_TAB_KEY) ?? 'general';
|
||||||
const activeTab = (syncOk || syncErr)
|
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('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>` : ''}
|
${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>
|
<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>
|
</nav>
|
||||||
|
|
||||||
<!-- Panel: Allgemein (Design + Sprache) -->
|
<!-- 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>
|
<button class="btn btn--danger-outline settings-logout-btn" id="logout-btn">${t('settings.logout')}</button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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 => { 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>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -737,6 +795,7 @@ function bindEvents(container, user, users, categories, icsSubscriptions, apiTok
|
|||||||
bindCategoryEvents(container);
|
bindCategoryEvents(container);
|
||||||
bindIcsEvents(container, user, icsSubscriptions);
|
bindIcsEvents(container, user, icsSubscriptions);
|
||||||
bindApiTokenEvents(container, apiTokens);
|
bindApiTokenEvents(container, apiTokens);
|
||||||
|
bindBackupEvents(container);
|
||||||
// Theme-Toggle
|
// Theme-Toggle
|
||||||
const themeToggle = container.querySelector('#theme-toggle');
|
const themeToggle = container.querySelector('#theme-toggle');
|
||||||
if (themeToggle) {
|
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
|
// Kategorie-Verwaltung
|
||||||
|
|||||||
@@ -466,6 +466,108 @@
|
|||||||
background: var(--color-surface-2);
|
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
|
Theme-Toggle
|
||||||
-------------------------------------------------------- */
|
-------------------------------------------------------- */
|
||||||
|
|||||||
+4
-4
@@ -13,10 +13,10 @@
|
|||||||
* → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit)
|
* → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const SHELL_CACHE = 'oikos-shell-v68';
|
const SHELL_CACHE = 'oikos-shell-v69';
|
||||||
const PAGES_CACHE = 'oikos-pages-v63';
|
const PAGES_CACHE = 'oikos-pages-v64';
|
||||||
const LOCALES_CACHE = 'oikos-locales-v12';
|
const LOCALES_CACHE = 'oikos-locales-v13';
|
||||||
const ASSETS_CACHE = 'oikos-assets-v63';
|
const ASSETS_CACHE = 'oikos-assets-v64';
|
||||||
const BYPASS_CACHE = 'oikos-bypass-flag';
|
const BYPASS_CACHE = 'oikos-bypass-flag';
|
||||||
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, LOCALES_CACHE, ASSETS_CACHE];
|
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, LOCALES_CACHE, ASSETS_CACHE];
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
import { createLogger } from './logger.js';
|
import { createLogger } from './logger.js';
|
||||||
|
|
||||||
const log = createLogger('DB');
|
const log = createLogger('DB');
|
||||||
@@ -33,13 +34,12 @@ function init() {
|
|||||||
if (db) return db;
|
if (db) return db;
|
||||||
db = new Database(DB_PATH);
|
db = new Database(DB_PATH);
|
||||||
|
|
||||||
if (DB_KEY) {
|
applyEncryptionKey(db);
|
||||||
// Nur wirksam wenn Binary gegen SQLCipher kompiliert ist (Docker)
|
|
||||||
db.pragma(`key="x'${Buffer.from(DB_KEY, 'utf8').toString('hex')}'"`);
|
|
||||||
|
|
||||||
|
if (DB_KEY) {
|
||||||
// Sicherstellen dass die Datenbank tatsächlich entschlüsselbar ist
|
// Sicherstellen dass die Datenbank tatsächlich entschlüsselbar ist
|
||||||
try {
|
try {
|
||||||
db.prepare('SELECT count(*) FROM sqlite_master').get();
|
assertReadable(db);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('[DB] Wrong encryption key or SQLCipher support is unavailable.');
|
throw new Error('[DB] Wrong encryption key or SQLCipher support is unavailable.');
|
||||||
}
|
}
|
||||||
@@ -56,6 +56,16 @@ function init() {
|
|||||||
return db;
|
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
|
// 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
|
// Öffentliche API
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -937,4 +1044,4 @@ function transaction(fn) {
|
|||||||
|
|
||||||
init(); // auto-initialise when module is first imported
|
init(); // auto-initialise when module is first imported
|
||||||
|
|
||||||
export { init, get, transaction, currentVersion };
|
export { init, get, transaction, currentVersion, getPath, backupToFile, restoreFromFile };
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import preferencesRouter from './routes/preferences.js';
|
|||||||
import remindersRouter from './routes/reminders.js';
|
import remindersRouter from './routes/reminders.js';
|
||||||
import searchRouter from './routes/search.js';
|
import searchRouter from './routes/search.js';
|
||||||
import familyRouter from './routes/family.js';
|
import familyRouter from './routes/family.js';
|
||||||
|
import backupRouter from './routes/backup.js';
|
||||||
|
|
||||||
const log = createLogger('Server');
|
const log = createLogger('Server');
|
||||||
const logSync = createLogger('Sync');
|
const logSync = createLogger('Sync');
|
||||||
@@ -207,6 +208,7 @@ app.use('/api/v1/preferences', preferencesRouter);
|
|||||||
app.use('/api/v1/reminders', remindersRouter);
|
app.use('/api/v1/reminders', remindersRouter);
|
||||||
app.use('/api/v1/search', searchRouter);
|
app.use('/api/v1/search', searchRouter);
|
||||||
app.use('/api/v1/family', familyRouter);
|
app.use('/api/v1/family', familyRouter);
|
||||||
|
app.use('/api/v1/backup', backupRouter);
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Health-Check (für Docker)
|
// Health-Check (für Docker)
|
||||||
|
|||||||
@@ -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/dashboard': { get: op({ summary: 'Get dashboard data', tag: 'Dashboard' }) },
|
||||||
'/api/v1/tasks': {
|
'/api/v1/tasks': {
|
||||||
get: op({ summary: 'List tasks', tag: 'Tasks' }),
|
get: op({ summary: 'List tasks', tag: 'Tasks' }),
|
||||||
@@ -498,6 +549,7 @@ function buildOpenApiSpec(req, appVersion) {
|
|||||||
{ name: 'Contacts' },
|
{ name: 'Contacts' },
|
||||||
{ name: 'Birthdays' },
|
{ name: 'Birthdays' },
|
||||||
{ name: 'Budget' },
|
{ name: 'Budget' },
|
||||||
|
{ name: 'Backup' },
|
||||||
{ name: 'Weather' },
|
{ name: 'Weather' },
|
||||||
{ name: 'Preferences' },
|
{ name: 'Preferences' },
|
||||||
{ name: 'Reminders' },
|
{ name: 'Reminders' },
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user