From 6f28ea0b16873b0d7ff4be6b37133afaa23c153a Mon Sep 17 00:00:00 2001 From: elpatron Date: Wed, 3 Jun 2026 15:13:51 +0200 Subject: [PATCH] Replace logbook backup v1 JSON with v2 ZIP archives. ZIP .daagbok files use a compact manifest and binary KDAB blobs so large photo, voice, and GPS payloads no longer inflate in a single JSON file. Co-authored-by: Cursor --- client/package-lock.json | 1 + client/package.json | 5 +- client/src/components/LogbookBackupPanel.tsx | 67 +- client/src/i18n/locales/da.json | 14 +- client/src/i18n/locales/de.json | 16 +- client/src/i18n/locales/en.json | 16 +- client/src/i18n/locales/nb.json | 14 +- client/src/i18n/locales/sv.json | 14 +- client/src/services/logbookBackup.ts | 699 +++++++++--------- .../src/services/logbookBackup/collector.ts | 355 +++++++++ .../services/logbookBackup/encBlob.test.ts | 27 + client/src/services/logbookBackup/encBlob.ts | 45 ++ client/src/services/logbookBackup/manifest.ts | 97 +++ .../src/services/logbookBackup/zipArchive.ts | 45 ++ docs/backup-format-v2.md | 337 +++++++++ docs/plausible-events.md | 4 +- 16 files changed, 1360 insertions(+), 396 deletions(-) create mode 100644 client/src/services/logbookBackup/collector.ts create mode 100644 client/src/services/logbookBackup/encBlob.test.ts create mode 100644 client/src/services/logbookBackup/encBlob.ts create mode 100644 client/src/services/logbookBackup/manifest.ts create mode 100644 client/src/services/logbookBackup/zipArchive.ts create mode 100644 docs/backup-format-v2.md diff --git a/client/package-lock.json b/client/package-lock.json index b0dcfb2..a647bcb 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -12,6 +12,7 @@ "bip39": "^3.1.0", "dexie": "^4.4.2", "dexie-react-hooks": "^4.4.0", + "fflate": "^0.8.3", "i18next": "^26.3.0", "i18next-browser-languagedetector": "^8.2.1", "jspdf": "^4.2.1", diff --git a/client/package.json b/client/package.json index 7831a10..296df39 100644 --- a/client/package.json +++ b/client/package.json @@ -22,15 +22,16 @@ "bip39": "^3.1.0", "dexie": "^4.4.2", "dexie-react-hooks": "^4.4.0", + "fflate": "^0.8.3", "i18next": "^26.3.0", "i18next-browser-languagedetector": "^8.2.1", "jspdf": "^4.2.1", "leaflet": "^1.9.4", "lucide-react": "^1.16.0", + "qrcode": "^1.5.4", "react": "^19.2.6", "react-dom": "^19.2.6", - "react-i18next": "^17.0.8", - "qrcode": "^1.5.4" + "react-i18next": "^17.0.8" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/client/src/components/LogbookBackupPanel.tsx b/client/src/components/LogbookBackupPanel.tsx index 734cf46..2b8695c 100644 --- a/client/src/components/LogbookBackupPanel.tsx +++ b/client/src/components/LogbookBackupPanel.tsx @@ -5,10 +5,12 @@ import { useDialog } from './ModalDialog.tsx' import { downloadBackupBlob, exportLogbookBackup, + formatBackupBytes, parseLogbookBackupFile, previewLogbookBackup, restoreLogbookBackup, - type LogbookBackupFile, + BACKUP_SIZE_CONFIRM_BYTES, + type ParsedLogbookBackup, type LogbookBackupPreview } from '../services/logbookBackup.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' @@ -27,6 +29,12 @@ function mapBackupError(code: string, t: (key: string) => string): string { return t('settings.backup_not_owner') case 'BACKUP_INVALID_JSON': return t('settings.backup_invalid_json') + case 'BACKUP_INVALID_ARCHIVE': + return t('settings.backup_invalid_archive') + case 'BACKUP_VERSION_UNSUPPORTED': + return t('settings.backup_version_unsupported') + case 'BACKUP_WRONG_PASSPHRASE': + return t('settings.backup_wrong_passphrase') case 'BACKUP_INVALID_FORMAT': return t('settings.backup_invalid_format') case 'BACKUP_NOT_AUTHENTICATED': @@ -53,9 +61,10 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac const [importPassphrase, setImportPassphrase] = useState('') const [importFile, setImportFile] = useState(null) const [importPreview, setImportPreview] = useState(null) - const [parsedBackup, setParsedBackup] = useState(null) + const [parsedBackup, setParsedBackup] = useState(null) const [importing, setImporting] = useState(false) const [previewing, setPreviewing] = useState(false) + const [exportProgress, setExportProgress] = useState(null) const [error, setError] = useState(null) const [success, setSuccess] = useState(null) @@ -83,21 +92,36 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac } setExporting(true) + setExportProgress(null) try { - const { blob, filename, backup } = await exportLogbookBackup(logbookId, exportPassphrase) + const { blob, filename, manifest } = await exportLogbookBackup(logbookId, exportPassphrase, { + onProgress: (p) => { + if (p.phase === 'pack') { + setExportProgress( + t('settings.backup_export_progress', { + current: p.current, + total: p.total + }) + ) + } + } + }) downloadBackupBlob(blob, filename) - setSuccess(t('settings.backup_export_success', { count: backup.counts.entries })) + setSuccess(t('settings.backup_export_success', { count: manifest.counts.entries })) setExportPassphrase('') setExportConfirm('') trackPlausibleEvent(PlausibleEvents.BACKUP_EXPORTED, { - entries: backup.counts.entries, - photos: backup.counts.photos + entries: manifest.counts.entries, + photos: manifest.counts.photos, + voiceMemos: manifest.counts.voiceMemos, + bytes: manifest.totalUncompressedBytes }) } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err) setError(mapBackupError(message, t)) } finally { setExporting(false) + setExportProgress(null) } } @@ -138,6 +162,18 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => { if (!parsedBackup || !importPassphrase) return + if (parsedBackup.manifest.totalUncompressedBytes > BACKUP_SIZE_CONFIRM_BYTES) { + const ok = await showConfirm( + t('settings.backup_import_size_confirm', { + size: formatBackupBytes(parsedBackup.manifest.totalUncompressedBytes) + }), + t('settings.backup_restore_title'), + t('logs.confirm_yes'), + t('logs.confirm_no') + ) + if (!ok) return + } + setImporting(true) setError(null) try { @@ -149,8 +185,10 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac setParsedBackup(null) if (fileInputRef.current) fileInputRef.current.value = '' trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, { - entries: parsedBackup.counts.entries, - photos: parsedBackup.counts.photos, + entries: parsedBackup.manifest.counts.entries, + photos: parsedBackup.manifest.counts.photos, + voiceMemos: parsedBackup.manifest.counts.voiceMemos, + bytes: parsedBackup.manifest.totalUncompressedBytes, mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id' }) onRestored?.(result.logbookId, result.title) @@ -258,6 +296,11 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac {exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')} + {exportProgress && ( +

+ {exportProgress} +

+ )} @@ -275,7 +318,7 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac id="backup-import-file" ref={fileInputRef} type="file" - accept=".daagbok.json,application/json" + accept=".daagbok,application/zip" className="input-text" onChange={handleFileChange} disabled={importing} @@ -330,8 +373,14 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
  • {t('settings.backup_stat_entries', { count: importPreview.counts.entries })}
  • {t('settings.backup_stat_photos', { count: importPreview.counts.photos })}
  • +
  • {t('settings.backup_stat_voice', { count: importPreview.counts.voiceMemos })}
  • {t('settings.backup_stat_crew', { count: importPreview.counts.crews })}
  • {t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}
  • +
  • + {t('settings.backup_stat_size', { + size: formatBackupBytes(importPreview.totalUncompressedBytes) + })} +

{t('settings.backup_exported_at', { diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json index 3db6013..cf0aeb4 100644 --- a/client/src/i18n/locales/da.json +++ b/client/src/i18n/locales/da.json @@ -495,7 +495,7 @@ "new_logbook_placeholder": "Navn på logbog eller yacht", "logout": "Log ud", "logged_in_as": "Logget ind som {{name}}", - "delete_confirm": "Er du sikker på, at du vil slette denne logbog permanent? Alle lokale data og serverkopier vil blive destrueret.\n\nTip: Lav en sikkerhedskopi (.daagbok.json) på forhånd under Indstillinger → Sikkerhedskopiering og gendannelse, hvis du vil beholde dataene senere.", + "delete_confirm": "Er du sikker på, at du vil slette denne logbog permanent? Alle lokale data og serverkopier vil blive destrueret.\n\nTip: Lav en sikkerhedskopi (.daagbok) på forhånd under Indstillinger → Sikkerhedskopiering og gendannelse, hvis du vil beholde dataene senere.", "no_logbooks": "Ingen logbøger fundet. Opret din første logbog for at komme i gang!", "loading": "Logbøgerne er fyldt op...", "status_synced": "Synkroniseret", @@ -774,7 +774,7 @@ "delete_account_confirm_yes": "Ja, slet konto og alle data", "delete_account_confirm_no": "Annuller", "delete_account_failed": "Kontoen kunne ikke slettes. Prøv venligst igen.", - "delete_backup_hint": "Tip: Lav sikkerhedskopier af dine logbøger (.daagbok.json) i indstillingerne for hver logbog, før du sletter dem.", + "delete_backup_hint": "Tip: Lav sikkerhedskopier af dine logbøger (.daagbok) i indstillingerne for hver logbog, før du sletter dem.", "deleting_account": "Kontoen vil blive slettet...", "invite_push_prompt_title": "Aktivere push-meddelelser?", "invite_push_prompt_message": "Så snart inviterede Crew-medlemmer synkroniserer ændringer, kan du blive informeret via push. Intet logbogsindhold sendes i almindelig tekst.", @@ -785,7 +785,7 @@ "backup_title": "Sikkerhedskopiering og gendannelse", "backup_desc": "Komplet krypteret backup af denne logbog (poster, fotos, GPS-spor, crew, skib). Beskyttet med backup-passphrase - til gendannelse til denne eller en ny konto.", "backup_export_title": "Opret backup", - "backup_export_desc": "Downloader alle lokale data som .daagbok.json. Hold filen og adgangssætningen adskilt og sikker.", + "backup_export_desc": "Downloader alle lokale data som et komprimeret .daagbok-arkiv. Hold filen og adgangssætningen adskilt og sikker.", "backup_restore_title": "Gendan sikkerhedskopi", "backup_restore_desc": "Gendanner en sikkerhedskopi til din nuværende konto - selv efter registrering af en ny konto.", "backup_passphrase": "Backup-passphrase", @@ -797,7 +797,13 @@ "backup_export_btn": "Download backup", "backup_exporting": "Sikkerhedskopien er oprettet...", "backup_export_success": "Backup oprettet ({{count}} rejsedage).", - "backup_file_label": "Backup-fil (.daagbok.json)", + "backup_file_label": "Backup-fil (.daagbok)", + "backup_export_progress": "Pakker filer {{current}} / {{total}}…", + "backup_invalid_archive": "Filen er ikke et gyldigt backup-arkiv.", + "backup_version_unsupported": "Gammelt backup-format (v1). Brug en aktuel .daagbok-backup.", + "backup_import_size_confirm": "Denne backup er ca. {{size}} ukomprimeret. Gendannelse kan tage længere tid. Fortsæt?", + "backup_stat_voice": "{{count}} stemmenotater", + "backup_stat_size": "Ca. {{size}} ukomprimeret", "backup_preview_btn": "Tjek indhold", "backup_previewing": "Tjek...", "backup_restore_btn": "Gendan", diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 44f7c20..efe0ba6 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -495,7 +495,7 @@ "new_logbook_placeholder": "Name des Logbuchs oder der Yacht", "logout": "Abmelden", "logged_in_as": "Angemeldet als {{name}}", - "delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok.json), falls du die Daten später behalten möchtest.", + "delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok), falls du die Daten später behalten möchtest.", "no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!", "loading": "Logbücher werden geladen...", "status_synced": "Synchronisiert", @@ -774,7 +774,7 @@ "delete_account_confirm_yes": "Ja, Konto und alle Daten löschen", "delete_account_confirm_no": "Abbrechen", "delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.", - "delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok.json) in den Einstellungen jedes Logbuchs.", + "delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok) in den Einstellungen jedes Logbuchs.", "deleting_account": "Konto wird gelöscht…", "invite_push_prompt_title": "Push-Benachrichtigungen aktivieren?", "invite_push_prompt_message": "Sobald eingeladene Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Es werden keine Logbuch-Inhalte im Klartext gesendet.", @@ -783,9 +783,9 @@ "invite_push_prompt_later": "Später", "invite_push_prompt_success": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.", "backup_title": "Backup & Wiederherstellung", - "backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.", + "backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, Sprachnotizen, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.", "backup_export_title": "Backup erstellen", - "backup_export_desc": "Lädt alle lokalen Daten als .daagbok.json herunter. Bewahre Datei und Passphrase getrennt und sicher auf.", + "backup_export_desc": "Lädt alle lokalen Daten als komprimierte .daagbok-Datei herunter. Bewahre Datei und Passphrase getrennt und sicher auf.", "backup_restore_title": "Backup wiederherstellen", "backup_restore_desc": "Stellt ein Backup in deinem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.", "backup_passphrase": "Backup-Passphrase", @@ -797,7 +797,13 @@ "backup_export_btn": "Backup herunterladen", "backup_exporting": "Backup wird erstellt…", "backup_export_success": "Backup erstellt ({{count}} Reisetage).", - "backup_file_label": "Backup-Datei (.daagbok.json)", + "backup_file_label": "Backup-Datei (.daagbok)", + "backup_export_progress": "Packe Dateien {{current}} / {{total}}…", + "backup_invalid_archive": "Die Datei ist kein gültiges Backup-Archiv.", + "backup_version_unsupported": "Altes Backup-Format (v1). Bitte ein aktuelles .daagbok-Backup verwenden.", + "backup_import_size_confirm": "Dieses Backup ist etwa {{size}} groß. Wiederherstellung kann auf dem Gerät länger dauern und viel Speicher belegen. Fortfahren?", + "backup_stat_voice": "{{count}} Sprachnotizen", + "backup_stat_size": "Unkomprimiert ca. {{size}}", "backup_preview_btn": "Inhalt prüfen", "backup_previewing": "Prüfe…", "backup_restore_btn": "Wiederherstellen", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 5982412..2a92995 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -495,7 +495,7 @@ "new_logbook_placeholder": "Logbook or Yacht Name", "logout": "Logout", "logged_in_as": "Signed in as {{name}}", - "delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok.json) if you may need the data later.", + "delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok) if you may need the data later.", "no_logbooks": "No logbooks found. Create your first logbook to begin!", "loading": "Loading logbooks...", "status_synced": "Synced", @@ -774,7 +774,7 @@ "delete_account_confirm_yes": "Yes, Delete Account and All Data", "delete_account_confirm_no": "Cancel", "delete_account_failed": "Failed to delete account. Please try again.", - "delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.", + "delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok) in each logbook's settings.", "deleting_account": "Deleting account…", "invite_push_prompt_title": "Enable push notifications?", "invite_push_prompt_message": "When invited crew members sync changes, you can be notified via push. No logbook content is sent in plain text.", @@ -783,9 +783,9 @@ "invite_push_prompt_later": "Later", "invite_push_prompt_success": "Push notifications are active on this device.", "backup_title": "Backup & restore", - "backup_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.", + "backup_desc": "Full encrypted backup of this logbook (entries, photos, voice memos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.", "backup_export_title": "Create backup", - "backup_export_desc": "Downloads all local data as a .daagbok.json file. Keep the file and passphrase separate and secure.", + "backup_export_desc": "Downloads all local data as a compressed .daagbok archive. Keep the file and passphrase separate and secure.", "backup_restore_title": "Restore backup", "backup_restore_desc": "Restores a backup into your current account — including after registering a new account.", "backup_passphrase": "Backup passphrase", @@ -797,7 +797,13 @@ "backup_export_btn": "Download backup", "backup_exporting": "Creating backup…", "backup_export_success": "Backup created ({{count}} travel days).", - "backup_file_label": "Backup file (.daagbok.json)", + "backup_file_label": "Backup file (.daagbok)", + "backup_export_progress": "Packing files {{current}} / {{total}}…", + "backup_invalid_archive": "The file is not a valid backup archive.", + "backup_version_unsupported": "Legacy backup format (v1). Please use a current .daagbok backup.", + "backup_import_size_confirm": "This backup is about {{size}} uncompressed. Restore may take longer and use significant memory. Continue?", + "backup_stat_voice": "{{count}} voice memos", + "backup_stat_size": "Approx. {{size}} uncompressed", "backup_preview_btn": "Verify contents", "backup_previewing": "Verifying…", "backup_restore_btn": "Restore", diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json index bfa96fc..e66add3 100644 --- a/client/src/i18n/locales/nb.json +++ b/client/src/i18n/locales/nb.json @@ -495,7 +495,7 @@ "new_logbook_placeholder": "Navn på loggboken eller båten", "logout": "Logg ut", "logged_in_as": "Innlogget som {{name}}", - "delete_confirm": "Er du sikker på at du vil slette denne loggboken permanent? Alle lokale data og serverkopier vil bli ødelagt.\n\nTips: Lag en sikkerhetskopi (.daagbok.json) på forhånd under Innstillinger → Sikkerhetskopiering og gjenoppretting hvis du ønsker å beholde dataene senere.", + "delete_confirm": "Er du sikker på at du vil slette denne loggboken permanent? Alle lokale data og serverkopier vil bli ødelagt.\n\nTips: Lag en sikkerhetskopi (.daagbok) på forhånd under Innstillinger → Sikkerhetskopiering og gjenoppretting hvis du ønsker å beholde dataene senere.", "no_logbooks": "Ingen loggbøker funnet. Opprett din første loggbok for å komme i gang!", "loading": "Loggbøker er lastet...", "status_synced": "Synkronisert", @@ -774,7 +774,7 @@ "delete_account_confirm_yes": "Ja, slett konto og alle data", "delete_account_confirm_no": "Avbryt", "delete_account_failed": "Kontoen kunne ikke slettes. Vennligst prøv igjen.", - "delete_backup_hint": "Tips: Lag sikkerhetskopier av loggbøkene dine (.daagbok.json) i innstillingene for hver loggbok før du sletter dem.", + "delete_backup_hint": "Tips: Lag sikkerhetskopier av loggbøkene dine (.daagbok) i innstillingene for hver loggbok før du sletter dem.", "deleting_account": "Kontoen vil bli slettet...", "invite_push_prompt_title": "Aktivere push-varsler?", "invite_push_prompt_message": "Så snart inviterte Crew-medlemmer synkroniserer endringer, kan du bli informert via push. Ingen loggbokinnhold sendes i ren tekst.", @@ -785,7 +785,7 @@ "backup_title": "Sikkerhetskopiering og gjenoppretting", "backup_desc": "Fullstendig kryptert sikkerhetskopi av denne loggboken (oppføringer, bilder, GPS-spor, crew, skip). Beskyttet med sikkerhetskopieringspassord - for gjenoppretting til denne eller en ny konto.", "backup_export_title": "Opprett sikkerhetskopi", - "backup_export_desc": "Laster ned alle lokale data som .daagbok.json. Hold filen og passordfrasen adskilt og sikker.", + "backup_export_desc": "Laster ned alle lokale data som et komprimert .daagbok-arkiv. Hold filen og passordfrasen adskilt og sikker.", "backup_restore_title": "Gjenopprett sikkerhetskopi", "backup_restore_desc": "Gjenoppretter en sikkerhetskopi til din nåværende konto - selv etter at du har registrert en ny konto.", "backup_passphrase": "Passord for sikkerhetskopiering", @@ -797,7 +797,13 @@ "backup_export_btn": "Last ned sikkerhetskopi", "backup_exporting": "Sikkerhetskopien er opprettet...", "backup_export_success": "Sikkerhetskopi opprettet ({{count}} reisedager).", - "backup_file_label": "Sikkerhetskopifil (.daagbok.json)", + "backup_file_label": "Sikkerhetskopifil (.daagbok)", + "backup_export_progress": "Pakker filer {{current}} / {{total}}…", + "backup_invalid_archive": "Filen er ikke et gyldig backup-arkiv.", + "backup_version_unsupported": "Gammelt backup-format (v1). Bruk en aktuell .daagbok-sikkerhetskopi.", + "backup_import_size_confirm": "Denne sikkerhetskopien er ca. {{size}} ukomprimert. Gjenoppretting kan ta lengre tid. Fortsette?", + "backup_stat_voice": "{{count}} talemeldinger", + "backup_stat_size": "Ca. {{size}} ukomprimert", "backup_preview_btn": "Sjekk innhold", "backup_previewing": "Sjekk...", "backup_restore_btn": "Gjenopprett", diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json index 974bbd8..1271762 100644 --- a/client/src/i18n/locales/sv.json +++ b/client/src/i18n/locales/sv.json @@ -495,7 +495,7 @@ "new_logbook_placeholder": "Loggbokens eller båtens namn", "logout": "Logga ut", "logged_in_as": "Inloggad som {{name}}", - "delete_confirm": "Är du säker på att du vill radera den här loggboken permanent? Alla lokala data och serverkopior kommer att förstöras.\n\nTips: Skapa en säkerhetskopia (.daagbok.json) i förväg under Inställningar → Säkerhetskopiering och återställning om du vill behålla data senare.", + "delete_confirm": "Är du säker på att du vill radera den här loggboken permanent? Alla lokala data och serverkopior kommer att förstöras.\n\nTips: Skapa en säkerhetskopia (.daagbok) i förväg under Inställningar → Säkerhetskopiering och återställning om du vill behålla data senare.", "no_logbooks": "Inga loggböcker hittades. Skapa din första loggbok för att komma igång!", "loading": "Loggböckerna är fulla...", "status_synced": "Synkroniserad", @@ -774,7 +774,7 @@ "delete_account_confirm_yes": "Ja, radera konto och all data", "delete_account_confirm_no": "Avbryt", "delete_account_failed": "Kontot kunde inte raderas. Vänligen försök igen.", - "delete_backup_hint": "Tips: Skapa säkerhetskopior av dina loggböcker (.daagbok.json) i inställningarna för varje loggbok innan du raderar dem.", + "delete_backup_hint": "Tips: Skapa säkerhetskopior av dina loggböcker (.daagbok) i inställningarna för varje loggbok innan du raderar dem.", "deleting_account": "Kontot kommer att raderas...", "invite_push_prompt_title": "Aktivera push-meddelanden?", "invite_push_prompt_message": "Så snart inbjudna Crew-medlemmar synkroniserar ändringar kan du bli informerad via push. Inget loggboksinnehåll skickas i klartext.", @@ -785,7 +785,7 @@ "backup_title": "Säkerhetskopiering och återställning", "backup_desc": "Komplett krypterad säkerhetskopia av denna loggbok (poster, foton, GPS-spår, crew, fartyg). Skyddad med lösenfras för säkerhetskopian - för återställning till detta eller ett nytt konto.", "backup_export_title": "Skapa säkerhetskopia", - "backup_export_desc": "Laddar ner alla lokala data som .daagbok.json. Förvara filen och lösenfrasen separat och säkert.", + "backup_export_desc": "Laddar ner alla lokala data som ett komprimerat .daagbok-arkiv. Förvara filen och lösenfrasen separat och säkert.", "backup_restore_title": "Återställ säkerhetskopian", "backup_restore_desc": "Återställer en säkerhetskopia till ditt nuvarande konto - även efter att du har registrerat ett nytt konto.", "backup_passphrase": "Lösenord för säkerhetskopiering", @@ -797,7 +797,13 @@ "backup_export_btn": "Ladda ner backup", "backup_exporting": "Säkerhetskopian skapas...", "backup_export_success": "Säkerhetskopia skapad ({{count}} resdagar).", - "backup_file_label": "Säkerhetskopieringsfil (.daagbok.json)", + "backup_file_label": "Säkerhetskopieringsfil (.daagbok)", + "backup_export_progress": "Packar filer {{current}} / {{total}}…", + "backup_invalid_archive": "Filen är inte ett giltigt backup-arkiv.", + "backup_version_unsupported": "Gammalt backup-format (v1). Använd en aktuell .daagbok-säkerhetskopia.", + "backup_import_size_confirm": "Denna säkerhetskopia är ca. {{size}} okomprimerad. Återställning kan ta längre tid. Fortsätta?", + "backup_stat_voice": "{{count}} röstanteckningar", + "backup_stat_size": "Ca. {{size}} okomprimerat", "backup_preview_btn": "Kontrollera innehåll", "backup_previewing": "Check...", "backup_restore_btn": "Återställ", diff --git a/client/src/services/logbookBackup.ts b/client/src/services/logbookBackup.ts index 3b3da4a..43a41bf 100644 --- a/client/src/services/logbookBackup.ts +++ b/client/src/services/logbookBackup.ts @@ -9,98 +9,54 @@ import { decryptLogbookTitle, deleteLocalLogbookCache } from './logbook.js' import { ensureLogbookKey, getLogbookKey, saveLogbookKey } from './logbookKeys.js' import { syncLogbook } from './sync.js' import type { SyncQueueItem } from './db.js' +import { getAppVersion } from './pwaVersion.js' +import { dexieFieldsFromEncBytes, encBytesFromDexieFields } from './logbookBackup/encBlob.js' +import { + BACKUP_FORMAT, + BACKUP_VERSION, + type BackupManifestCounts, + type BackupManifestV2, + type LogbookMetaJson +} from './logbookBackup/manifest.js' +import { + buildArchiveFromCollected, + collectLogbookBackupData, + type BackupExportProgress +} from './logbookBackup/collector.js' +import { + isZipArchive, + readBinaryFile, + readManifestFromArchive, + readTextFile, + unzipArchive +} from './logbookBackup/zipArchive.js' -export const BACKUP_FORMAT = 'kapteins-daagbok-backup' as const -export const BACKUP_VERSION = 1 as const - -export interface LogbookBackupFile { - format: typeof BACKUP_FORMAT - version: typeof BACKUP_VERSION - exportedAt: string - logbook: { - id: string - encryptedTitle: string - updatedAt: string - isDemo?: boolean - } - logbookKey: { - ciphertext: string - iv: string - tag: string - } - payloads: { - yacht: { - encryptedData: string - iv: string - tag: string - updatedAt: string - } | null - deviation: { - encryptedData: string - iv: string - tag: string - updatedAt: string - } | null - crews: Array<{ - payloadId: string - encryptedData: string - iv: string - tag: string - updatedAt: string - }> - entries: Array<{ - payloadId: string - encryptedData: string - iv: string - tag: string - updatedAt: string - }> - photos: Array<{ - payloadId: string - entryId: string - encryptedData: string - iv: string - tag: string - updatedAt: string - }> - voiceMemos: Array<{ - payloadId: string - entryId: string - encryptedData: string - iv: string - tag: string - updatedAt: string - }> - gpsTracks: Array<{ - entryId: string - encryptedData: string - iv: string - tag: string - updatedAt: string - }> - } - counts: { - entries: number - photos: number - voiceMemos: number - crews: number - gpsTracks: number - hasYacht: boolean - hasDeviation: boolean - } -} +export { BACKUP_FORMAT, BACKUP_VERSION } +export type { BackupExportProgress, BackupManifestCounts, BackupManifestV2 } export interface LogbookBackupPreview { title: string exportedAt: string sourceLogbookId: string - counts: LogbookBackupFile['counts'] + counts: BackupManifestCounts + totalUncompressedBytes: number } +export interface ParsedLogbookBackup { + manifest: BackupManifestV2 + files: Record +} + +export interface ExportLogbookBackupOptions { + onProgress?: (progress: BackupExportProgress) => void +} + +const BACKUP_PASSPHRASE_SALT = 'KapteinsDaagbokBackupFileSalt_v1' + async function deriveBackupPassphraseKey(passphrase: string): Promise { const encoder = new TextEncoder() const passphraseBytes = encoder.encode(passphrase.trim()) - const saltBytes = encoder.encode('KapteinsDaagbokBackupFileSalt_v1') + const saltBytes = encoder.encode(BACKUP_PASSPHRASE_SALT) const baseKey = await window.crypto.subtle.importKey( 'raw', @@ -129,37 +85,19 @@ async function wrapLogbookKey(logbookKey: ArrayBuffer, passphrase: string) { return encryptBuffer(logbookKey, key) } -async function unwrapLogbookKey( - wrapped: LogbookBackupFile['logbookKey'], +async function unwrapLogbookKeyFromEnc( + keyEnc: Uint8Array, passphrase: string ): Promise { - const key = await deriveBackupPassphraseKey(passphrase) - return decryptBuffer(wrapped.ciphertext, wrapped.iv, wrapped.tag, key) -} - -function normalizeBackupPayloads( - payloads: LogbookBackupFile['payloads'] -): LogbookBackupFile['payloads'] { - return { - ...payloads, - voiceMemos: payloads.voiceMemos ?? [] + try { + const fields = dexieFieldsFromEncBytes(keyEnc) + const cryptoKey = await deriveBackupPassphraseKey(passphrase) + return decryptBuffer(fields.encryptedData, fields.iv, fields.tag, cryptoKey) + } catch { + throw new Error('BACKUP_WRONG_PASSPHRASE') } } -function isBackupFile(value: unknown): value is LogbookBackupFile { - if (!value || typeof value !== 'object') return false - const obj = value as Partial - return ( - obj.format === BACKUP_FORMAT && - obj.version === BACKUP_VERSION && - typeof obj.exportedAt === 'string' && - !!obj.logbook?.id && - !!obj.logbook?.encryptedTitle && - !!obj.logbookKey?.ciphertext && - !!obj.payloads - ) -} - function encryptedPayloadData( encryptedData: string, iv: string, @@ -174,106 +112,12 @@ function encryptedPayloadData( }) } -async function collectLogbookPayloads(logbookId: string): Promise { - const [yacht, deviation, crews, entries, photos, voiceMemos, gpsTracks] = await Promise.all([ - db.yachts.get(logbookId), - db.deviations.get(logbookId), - db.crews.where({ logbookId }).toArray(), - db.entries.where({ logbookId }).toArray(), - db.photos.where({ logbookId }).toArray(), - db.voiceMemos.where({ logbookId }).toArray(), - db.gpsTracks.where({ logbookId }).toArray() - ]) - - return { - yacht: yacht - ? { - encryptedData: yacht.encryptedData, - iv: yacht.iv, - tag: yacht.tag, - updatedAt: yacht.updatedAt - } - : null, - deviation: deviation - ? { - encryptedData: deviation.encryptedData, - iv: deviation.iv, - tag: deviation.tag, - updatedAt: deviation.updatedAt - } - : null, - crews: crews.map((c) => ({ - payloadId: c.payloadId, - encryptedData: c.encryptedData, - iv: c.iv, - tag: c.tag, - updatedAt: c.updatedAt - })), - entries: entries.map((e) => ({ - payloadId: e.payloadId, - encryptedData: e.encryptedData, - iv: e.iv, - tag: e.tag, - updatedAt: e.updatedAt - })), - photos: photos.map((p) => ({ - payloadId: p.payloadId, - entryId: p.entryId, - encryptedData: p.encryptedData, - iv: p.iv, - tag: p.tag, - updatedAt: p.updatedAt - })), - voiceMemos: voiceMemos.map((v) => ({ - payloadId: v.payloadId, - entryId: v.entryId, - encryptedData: v.encryptedData, - iv: v.iv, - tag: v.tag, - updatedAt: v.updatedAt - })), - gpsTracks: gpsTracks.map((t) => ({ - entryId: t.entryId, - encryptedData: t.encryptedData, - iv: t.iv, - tag: t.tag, - updatedAt: t.updatedAt - })) - } -} - -function remapBackup( - backup: LogbookBackupFile, - newLogbookId: string -): LogbookBackupFile { - return { - ...backup, - logbook: { - ...backup.logbook, - id: newLogbookId - }, - payloads: { - ...backup.payloads, - yacht: backup.payloads.yacht - ? { ...backup.payloads.yacht, updatedAt: backup.payloads.yacht.updatedAt } - : null, - deviation: backup.payloads.deviation - ? { ...backup.payloads.deviation, updatedAt: backup.payloads.deviation.updatedAt } - : null, - crews: backup.payloads.crews.map((c) => ({ ...c })), - entries: backup.payloads.entries.map((e) => ({ ...e })), - photos: backup.payloads.photos.map((p) => ({ ...p })), - voiceMemos: (backup.payloads.voiceMemos ?? []).map((v) => ({ ...v })), - gpsTracks: backup.payloads.gpsTracks.map((t) => ({ ...t })) - } - } -} - async function queueRestoredLogbookForSync( logbookId: string, encryptedTitle: string, logbookKey: ArrayBuffer, - payloads: LogbookBackupFile['payloads'] + manifest: BackupManifestV2, + files: Record ): Promise { const masterKey = getActiveMasterKey() if (!masterKey) throw new Error('Master key not found') @@ -304,91 +148,123 @@ async function queueRestoredLogbookForSync( } ] - if (payloads.yacht) { + const readFields = (path: string | null) => { + if (!path) return null + return dexieFieldsFromEncBytes(readBinaryFile(files, path)) + } + + const yacht = readFields(manifest.files.yacht) + if (yacht) { items.push({ action: 'update', type: 'yacht', payloadId: logbookId, logbookId, - data: encryptedPayloadData( - payloads.yacht.encryptedData, - payloads.yacht.iv, - payloads.yacht.tag - ), - updatedAt: payloads.yacht.updatedAt + data: encryptedPayloadData(yacht.encryptedData, yacht.iv, yacht.tag), + updatedAt: now }) } - if (payloads.deviation) { + const deviation = readFields(manifest.files.deviation) + if (deviation) { items.push({ action: 'update', type: 'deviation', payloadId: logbookId, logbookId, - data: encryptedPayloadData( - payloads.deviation.encryptedData, - payloads.deviation.iv, - payloads.deviation.tag - ), - updatedAt: payloads.deviation.updatedAt + data: encryptedPayloadData(deviation.encryptedData, deviation.iv, deviation.tag), + updatedAt: now }) } - for (const crew of payloads.crews) { + const logbookCrew = readFields(manifest.files.logbookCrewSelection) + if (logbookCrew) { + items.push({ + action: 'update', + type: 'logbookCrew', + payloadId: logbookId, + logbookId, + data: encryptedPayloadData(logbookCrew.encryptedData, logbookCrew.iv, logbookCrew.tag), + updatedAt: now + }) + } + + const logbookVessel = readFields(manifest.files.logbookVesselSelection) + if (logbookVessel) { + items.push({ + action: 'update', + type: 'logbookVessel', + payloadId: logbookId, + logbookId, + data: encryptedPayloadData( + logbookVessel.encryptedData, + logbookVessel.iv, + logbookVessel.tag + ), + updatedAt: now + }) + } + + for (const crew of manifest.files.crews) { + const f = readFields(crew.path) items.push({ action: 'create', type: 'crew', payloadId: crew.payloadId, logbookId, - data: encryptedPayloadData(crew.encryptedData, crew.iv, crew.tag), + data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag), updatedAt: crew.updatedAt }) } - for (const entry of payloads.entries) { + for (const entry of manifest.files.entries) { + const f = readFields(entry.path) items.push({ action: 'create', type: 'entry', payloadId: entry.payloadId, logbookId, - data: encryptedPayloadData(entry.encryptedData, entry.iv, entry.tag), + data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag), updatedAt: entry.updatedAt }) } - for (const photo of payloads.photos) { + for (const photo of manifest.files.photos) { + const f = readFields(photo.path) items.push({ action: 'create', type: 'photo', payloadId: photo.payloadId, logbookId, - data: encryptedPayloadData(photo.encryptedData, photo.iv, photo.tag, { + data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag, { entryId: photo.entryId }), updatedAt: photo.updatedAt }) } - for (const voice of payloads.voiceMemos ?? []) { + for (const voice of manifest.files.voiceMemos) { + const f = readFields(voice.path) items.push({ action: 'create', type: 'voiceMemo', payloadId: voice.payloadId, logbookId, - data: encryptedPayloadData(voice.encryptedData, voice.iv, voice.tag, { + data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag, { entryId: voice.entryId }), updatedAt: voice.updatedAt }) } - for (const track of payloads.gpsTracks) { + for (const track of manifest.files.gpsTracks) { + const f = readFields(track.path) items.push({ action: 'create', type: 'gpsTrack', payloadId: track.entryId, logbookId, - data: encryptedPayloadData(track.encryptedData, track.iv, track.tag), + data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag), updatedAt: track.updatedAt }) } @@ -398,116 +274,190 @@ async function queueRestoredLogbookForSync( async function writeBackupToDexie( logbookId: string, - backup: LogbookBackupFile, - logbookKey: ArrayBuffer + logbookMeta: LogbookMetaJson, + logbookKey: ArrayBuffer, + manifest: BackupManifestV2, + files: Record ): Promise { - const { logbook, payloads } = backup - await db.logbooks.put({ id: logbookId, - encryptedTitle: logbook.encryptedTitle, - updatedAt: logbook.updatedAt, + encryptedTitle: logbookMeta.encryptedTitle, + updatedAt: logbookMeta.updatedAt, isSynced: 0, isShared: 0, - isDemo: logbook.isDemo ? 1 : 0 + isDemo: logbookMeta.isDemo ? 1 : 0 }) await saveLogbookKey(logbookId, logbookKey) - if (payloads.yacht) { + const readFields = (path: string | null) => { + if (!path) return null + return dexieFieldsFromEncBytes(readBinaryFile(files, path)) + } + + const yacht = readFields(manifest.files.yacht) + if (yacht) { await db.yachts.put({ logbookId, - encryptedData: payloads.yacht.encryptedData, - iv: payloads.yacht.iv, - tag: payloads.yacht.tag, - updatedAt: payloads.yacht.updatedAt + encryptedData: yacht.encryptedData, + iv: yacht.iv, + tag: yacht.tag, + updatedAt: logbookMeta.updatedAt }) } - if (payloads.deviation) { + const deviation = readFields(manifest.files.deviation) + if (deviation) { await db.deviations.put({ logbookId, - encryptedData: payloads.deviation.encryptedData, - iv: payloads.deviation.iv, - tag: payloads.deviation.tag, - updatedAt: payloads.deviation.updatedAt + encryptedData: deviation.encryptedData, + iv: deviation.iv, + tag: deviation.tag, + updatedAt: logbookMeta.updatedAt }) } - if (payloads.crews.length > 0) { + const logbookCrew = readFields(manifest.files.logbookCrewSelection) + if (logbookCrew) { + await db.logbookCrewSelections.put({ + logbookId, + encryptedData: logbookCrew.encryptedData, + iv: logbookCrew.iv, + tag: logbookCrew.tag, + updatedAt: logbookMeta.updatedAt + }) + } + + const logbookVessel = readFields(manifest.files.logbookVesselSelection) + if (logbookVessel) { + await db.logbookVesselSelections.put({ + logbookId, + encryptedData: logbookVessel.encryptedData, + iv: logbookVessel.iv, + tag: logbookVessel.tag, + updatedAt: logbookMeta.updatedAt + }) + } + + if (manifest.files.crews.length > 0) { await db.crews.bulkPut( - payloads.crews.map((c) => ({ - payloadId: c.payloadId, - logbookId, - encryptedData: c.encryptedData, - iv: c.iv, - tag: c.tag, - updatedAt: c.updatedAt - })) + manifest.files.crews.map((c) => { + const f = dexieFieldsFromEncBytes(readBinaryFile(files, c.path)) + return { + payloadId: c.payloadId, + logbookId, + encryptedData: f.encryptedData, + iv: f.iv, + tag: f.tag, + updatedAt: c.updatedAt + } + }) ) } - if (payloads.entries.length > 0) { + if (manifest.files.entries.length > 0) { await db.entries.bulkPut( - payloads.entries.map((e) => ({ - payloadId: e.payloadId, - logbookId, - encryptedData: e.encryptedData, - iv: e.iv, - tag: e.tag, - updatedAt: e.updatedAt - })) + manifest.files.entries.map((e) => { + const f = dexieFieldsFromEncBytes(readBinaryFile(files, e.path)) + return { + payloadId: e.payloadId, + logbookId, + encryptedData: f.encryptedData, + iv: f.iv, + tag: f.tag, + updatedAt: e.updatedAt + } + }) ) } - if (payloads.photos.length > 0) { + if (manifest.files.photos.length > 0) { await db.photos.bulkPut( - payloads.photos.map((p) => ({ - payloadId: p.payloadId, - entryId: p.entryId, - logbookId, - encryptedData: p.encryptedData, - iv: p.iv, - tag: p.tag, - caption: '', - updatedAt: p.updatedAt - })) + manifest.files.photos.map((p) => { + const f = dexieFieldsFromEncBytes(readBinaryFile(files, p.path)) + return { + payloadId: p.payloadId, + entryId: p.entryId, + logbookId, + encryptedData: f.encryptedData, + iv: f.iv, + tag: f.tag, + caption: '', + updatedAt: p.updatedAt + } + }) ) } - const voiceMemosToRestore = payloads.voiceMemos ?? [] - if (voiceMemosToRestore.length > 0) { + if (manifest.files.voiceMemos.length > 0) { await db.voiceMemos.bulkPut( - voiceMemosToRestore.map((v) => ({ - payloadId: v.payloadId, - entryId: v.entryId, - logbookId, - encryptedData: v.encryptedData, - iv: v.iv, - tag: v.tag, - updatedAt: v.updatedAt - })) + manifest.files.voiceMemos.map((v) => { + const f = dexieFieldsFromEncBytes(readBinaryFile(files, v.path)) + return { + payloadId: v.payloadId, + entryId: v.entryId, + logbookId, + encryptedData: f.encryptedData, + iv: f.iv, + tag: f.tag, + updatedAt: v.updatedAt + } + }) ) } - if (payloads.gpsTracks.length > 0) { + if (manifest.files.gpsTracks.length > 0) { await db.gpsTracks.bulkPut( - payloads.gpsTracks.map((t) => ({ - entryId: t.entryId, - logbookId, - encryptedData: t.encryptedData, - iv: t.iv, - tag: t.tag, - updatedAt: t.updatedAt - })) + manifest.files.gpsTracks.map((t) => { + const f = dexieFieldsFromEncBytes(readBinaryFile(files, t.path)) + return { + entryId: t.entryId, + logbookId, + encryptedData: f.encryptedData, + iv: f.iv, + tag: f.tag, + updatedAt: t.updatedAt + } + }) ) } + + if (manifest.files.nmeaArchives.length > 0) { + await db.nmeaArchives.bulkPut( + manifest.files.nmeaArchives.map((n) => { + const f = dexieFieldsFromEncBytes(readBinaryFile(files, n.path)) + return { + entryId: n.entryId, + logbookId, + encryptedData: f.encryptedData, + iv: f.iv, + tag: f.tag, + updatedAt: n.updatedAt + } + }) + ) + } +} + +function remapParsedBackup( + parsed: ParsedLogbookBackup, + newLogbookId: string +): ParsedLogbookBackup { + const logbookMeta = JSON.parse(readTextFile(parsed.files, parsed.manifest.files.logbook)) as LogbookMetaJson + logbookMeta.id = newLogbookId + const newFiles = { ...parsed.files } + newFiles[parsed.manifest.files.logbook] = new TextEncoder().encode(JSON.stringify(logbookMeta)) + return { + manifest: { ...parsed.manifest, logbookId: newLogbookId }, + files: newFiles + } } export async function exportLogbookBackup( logbookId: string, - passphrase: string -): Promise<{ blob: Blob; filename: string; backup: LogbookBackupFile }> { + passphrase: string, + options: ExportLogbookBackupOptions = {} +): Promise<{ blob: Blob; filename: string; manifest: BackupManifestV2 }> { if (!passphrase.trim() || passphrase.length < 8) { throw new Error('BACKUP_PASSPHRASE_TOO_SHORT') } @@ -523,78 +473,84 @@ export async function exportLogbookBackup( }) } + options.onProgress?.({ phase: 'collect', current: 0, total: 1, bytesPacked: 0 }) + const collected = await collectLogbookBackupData(logbookId) const logbookKey = (await getLogbookKey(logbookId)) ?? (await ensureLogbookKey(logbookId)) - const payloads = await collectLogbookPayloads(logbookId) - const wrappedKey = await wrapLogbookKey(logbookKey, passphrase) + const wrapped = await wrapLogbookKey(logbookKey, passphrase) + const keyEnc = encBytesFromDexieFields({ + encryptedData: wrapped.ciphertext, + iv: wrapped.iv, + tag: wrapped.tag + }) - const backup: LogbookBackupFile = { - format: BACKUP_FORMAT, - version: BACKUP_VERSION, + const { zipBytes, manifest } = buildArchiveFromCollected(collected, keyEnc, { exportedAt: new Date().toISOString(), - logbook: { - id: logbook.id, - encryptedTitle: logbook.encryptedTitle, - updatedAt: logbook.updatedAt, - isDemo: logbook.isDemo === 1 - }, - logbookKey: wrappedKey, - payloads, - counts: { - entries: payloads.entries.length, - photos: payloads.photos.length, - voiceMemos: payloads.voiceMemos?.length ?? 0, - crews: payloads.crews.length, - gpsTracks: payloads.gpsTracks.length, - hasYacht: !!payloads.yacht, - hasDeviation: !!payloads.deviation - } - } + appVersion: getAppVersion(), + onProgress: options.onProgress + }) const title = await decryptLogbookTitle(logbookId, logbook.encryptedTitle) const safeTitle = title.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook' const datePart = new Date().toISOString().slice(0, 10) - const filename = `${safeTitle}-${datePart}.daagbok.json` - const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }) + const filename = `${safeTitle}-${datePart}.daagbok` + const blob = new Blob([zipBytes.slice()], { type: 'application/zip' }) - return { blob, filename, backup } + return { blob, filename, manifest } } -export async function parseLogbookBackupFile(file: File): Promise { - const text = await file.text() - let parsed: unknown +function detectLegacyJsonV1(text: string): boolean { + const trimmed = text.trimStart() + if (!trimmed.startsWith('{')) return false try { - parsed = JSON.parse(text) + const parsed = JSON.parse(trimmed) as { format?: string; version?: number } + return parsed.format === BACKUP_FORMAT && parsed.version === 1 } catch { - throw new Error('BACKUP_INVALID_JSON') + return false } +} - if (!isBackupFile(parsed)) { - throw new Error('BACKUP_INVALID_FORMAT') - } +export async function parseLogbookBackupFile(file: File): Promise { + const buffer = await file.arrayBuffer() + const bytes = new Uint8Array(buffer) - return { - ...parsed, - payloads: normalizeBackupPayloads(parsed.payloads), - counts: { - ...parsed.counts, - voiceMemos: parsed.counts.voiceMemos ?? parsed.payloads.voiceMemos?.length ?? 0 + if (!isZipArchive(bytes)) { + const text = new TextDecoder().decode(bytes) + if (detectLegacyJsonV1(text)) { + throw new Error('BACKUP_VERSION_UNSUPPORTED') } + throw new Error('BACKUP_INVALID_ARCHIVE') } + + const files = unzipArchive(bytes) + const manifest = readManifestFromArchive(files) + return { manifest, files } } export async function previewLogbookBackup( - backup: LogbookBackupFile, + backup: ParsedLogbookBackup, passphrase: string ): Promise { - const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase) - const parsed = JSON.parse(backup.logbook.encryptedTitle) - const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey) + const logbookKey = await unwrapLogbookKeyFromEnc( + readBinaryFile(backup.files, backup.manifest.files.key), + passphrase + ) + const logbookMeta = JSON.parse( + readTextFile(backup.files, backup.manifest.files.logbook) + ) as LogbookMetaJson + const parsed = JSON.parse(logbookMeta.encryptedTitle) + let title: string + try { + title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey) + } catch { + throw new Error('BACKUP_WRONG_PASSPHRASE') + } return { title, - exportedAt: backup.exportedAt, - sourceLogbookId: backup.logbook.id, - counts: backup.counts + exportedAt: backup.manifest.exportedAt, + sourceLogbookId: backup.manifest.logbookId, + counts: backup.manifest.counts, + totalUncompressedBytes: backup.manifest.totalUncompressedBytes } } @@ -604,7 +560,7 @@ export interface RestoreLogbookOptions { } export async function restoreLogbookBackup( - backup: LogbookBackupFile, + backup: ParsedLogbookBackup, passphrase: string, options: RestoreLogbookOptions = {} ): Promise<{ logbookId: string; title: string }> { @@ -612,16 +568,22 @@ export async function restoreLogbookBackup( throw new Error('BACKUP_NOT_AUTHENTICATED') } - const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase) - const parsedTitle = JSON.parse(backup.logbook.encryptedTitle) - const title = await decryptJson( - parsedTitle.ciphertext, - parsedTitle.iv, - parsedTitle.tag, - logbookKey + const logbookKey = await unwrapLogbookKeyFromEnc( + readBinaryFile(backup.files, backup.manifest.files.key), + passphrase ) + const logbookMeta = JSON.parse( + readTextFile(backup.files, backup.manifest.files.logbook) + ) as LogbookMetaJson + const parsedTitle = JSON.parse(logbookMeta.encryptedTitle) + let title: string + try { + title = await decryptJson(parsedTitle.ciphertext, parsedTitle.iv, parsedTitle.tag, logbookKey) + } catch { + throw new Error('BACKUP_WRONG_PASSPHRASE') + } - let targetId = backup.logbook.id + let targetId = backup.manifest.logbookId const existing = await db.logbooks.get(targetId) if (existing && !options.overwrite && !options.assignNewId) { @@ -632,24 +594,29 @@ export async function restoreLogbookBackup( await deleteLocalLogbookCache(targetId) } + let prepared = backup if (options.assignNewId || (existing && !options.overwrite)) { targetId = crypto.randomUUID() + prepared = remapParsedBackup(backup, targetId) } - const normalized = { - ...backup, - payloads: normalizeBackupPayloads(backup.payloads) - } - const prepared = targetId === normalized.logbook.id - ? normalized - : remapBackup(normalized, targetId) + const finalMeta = JSON.parse( + readTextFile(prepared.files, prepared.manifest.files.logbook) + ) as LogbookMetaJson - await writeBackupToDexie(targetId, prepared, logbookKey) + await writeBackupToDexie( + targetId, + finalMeta, + logbookKey, + prepared.manifest, + prepared.files + ) await queueRestoredLogbookForSync( targetId, - prepared.logbook.encryptedTitle, + finalMeta.encryptedTitle, logbookKey, - prepared.payloads + prepared.manifest, + prepared.files ) if (navigator.onLine) { @@ -669,3 +636,13 @@ export function downloadBackupBlob(blob: Blob, filename: string): void { anchor.click() URL.revokeObjectURL(url) } + +/** Human-readable size for UI warnings. */ +export function formatBackupBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +export const BACKUP_SIZE_WARN_BYTES = 50_000_000 +export const BACKUP_SIZE_CONFIRM_BYTES = 150_000_000 diff --git a/client/src/services/logbookBackup/collector.ts b/client/src/services/logbookBackup/collector.ts new file mode 100644 index 0000000..d29dd23 --- /dev/null +++ b/client/src/services/logbookBackup/collector.ts @@ -0,0 +1,355 @@ +import { db } from '../db.js' +import { encBytesFromDexieFields, type DexieEncFields } from './encBlob.js' +import { buildZipArchive, utf8Bytes } from './zipArchive.js' +import { + BACKUP_FORMAT, + BACKUP_VERSION, + type BackupIndexedEntryFile, + type BackupIndexedPayloadFile, + type BackupIndexedTrackFile, + type BackupManifestCounts, + type BackupManifestFiles, + type BackupManifestV2, + type LogbookMetaJson +} from './manifest.js' + +export interface CollectedBackupData { + logbookMeta: LogbookMetaJson + yacht: DexieEncFields | null + deviation: DexieEncFields | null + logbookCrewSelection: DexieEncFields | null + logbookVesselSelection: DexieEncFields | null + crews: Array + entries: Array + photos: Array + voiceMemos: Array + gpsTracks: Array + nmeaArchives: Array +} + +function pickEnc(row: { + encryptedData: string + iv: string + tag: string +}): DexieEncFields { + return { + encryptedData: row.encryptedData, + iv: row.iv, + tag: row.tag + } +} + +export async function collectLogbookBackupData( + logbookId: string +): Promise { + const [ + logbook, + yacht, + deviation, + logbookCrewSelection, + logbookVesselSelection, + crews, + entries, + photos, + voiceMemos, + gpsTracks, + nmeaArchives + ] = await Promise.all([ + db.logbooks.get(logbookId), + db.yachts.get(logbookId), + db.deviations.get(logbookId), + db.logbookCrewSelections.get(logbookId), + db.logbookVesselSelections.get(logbookId), + db.crews.where({ logbookId }).toArray(), + db.entries.where({ logbookId }).toArray(), + db.photos.where({ logbookId }).toArray(), + db.voiceMemos.where({ logbookId }).toArray(), + db.gpsTracks.where({ logbookId }).toArray(), + db.nmeaArchives.where({ logbookId }).toArray() + ]) + + if (!logbook) throw new Error('BACKUP_LOGBOOK_NOT_FOUND') + + return { + logbookMeta: { + id: logbook.id, + encryptedTitle: logbook.encryptedTitle, + updatedAt: logbook.updatedAt, + isDemo: logbook.isDemo === 1 + }, + yacht: yacht ? pickEnc(yacht) : null, + deviation: deviation ? pickEnc(deviation) : null, + logbookCrewSelection: logbookCrewSelection ? pickEnc(logbookCrewSelection) : null, + logbookVesselSelection: logbookVesselSelection ? pickEnc(logbookVesselSelection) : null, + crews: crews.map((c) => ({ ...pickEnc(c), payloadId: c.payloadId, updatedAt: c.updatedAt })), + entries: entries.map((e) => ({ + ...pickEnc(e), + payloadId: e.payloadId, + updatedAt: e.updatedAt + })), + photos: photos.map((p) => ({ + ...pickEnc(p), + payloadId: p.payloadId, + entryId: p.entryId, + updatedAt: p.updatedAt + })), + voiceMemos: voiceMemos.map((v) => ({ + ...pickEnc(v), + payloadId: v.payloadId, + entryId: v.entryId, + updatedAt: v.updatedAt + })), + gpsTracks: gpsTracks.map((t) => ({ + ...pickEnc(t), + entryId: t.entryId, + updatedAt: t.updatedAt + })), + nmeaArchives: nmeaArchives.map((n) => ({ + ...pickEnc(n), + entryId: n.entryId, + updatedAt: n.updatedAt + })) + } +} + +export type BackupProgressPhase = 'collect' | 'pack' | 'done' + +export interface BackupExportProgress { + phase: BackupProgressPhase + current: number + total: number + bytesPacked: number +} + +export interface BuiltArchive { + zipBytes: Uint8Array + manifest: BackupManifestV2 + counts: BackupManifestCounts + totalUncompressedBytes: number +} + +function addEncFile( + zipFiles: Record, + path: string, + fields: DexieEncFields +): number { + const bytes = encBytesFromDexieFields(fields) + zipFiles[path] = bytes + return bytes.byteLength +} + +export function buildArchiveFromCollected( + collected: CollectedBackupData, + keyEnc: Uint8Array, + options: { + exportedAt: string + appVersion?: string + onProgress?: (progress: BackupExportProgress) => void + } +): BuiltArchive { + const zipFiles: Record = {} + let totalUncompressedBytes = 0 + + const logbookPath = 'logbook.meta.json' + zipFiles[logbookPath] = utf8Bytes(JSON.stringify(collected.logbookMeta)) + totalUncompressedBytes += zipFiles[logbookPath].byteLength + + zipFiles['key.enc'] = keyEnc + totalUncompressedBytes += keyEnc.byteLength + + const files: BackupManifestFiles = { + key: 'key.enc', + logbook: logbookPath, + yacht: null, + deviation: null, + logbookCrewSelection: null, + logbookVesselSelection: null, + crews: [], + entries: [], + photos: [], + voiceMemos: [], + gpsTracks: [], + nmeaArchives: [] + } + + const packSteps: Array<() => void> = [] + + if (collected.yacht) { + packSteps.push(() => { + const path = 'payloads/yacht.enc' + const size = addEncFile(zipFiles, path, collected.yacht!) + files.yacht = path + totalUncompressedBytes += size + }) + } + + if (collected.deviation) { + packSteps.push(() => { + const path = 'payloads/deviation.enc' + const size = addEncFile(zipFiles, path, collected.deviation!) + files.deviation = path + totalUncompressedBytes += size + }) + } + + if (collected.logbookCrewSelection) { + packSteps.push(() => { + const path = 'payloads/logbook-crew.enc' + const size = addEncFile(zipFiles, path, collected.logbookCrewSelection!) + files.logbookCrewSelection = path + totalUncompressedBytes += size + }) + } + + if (collected.logbookVesselSelection) { + packSteps.push(() => { + const path = 'payloads/logbook-vessel.enc' + const size = addEncFile(zipFiles, path, collected.logbookVesselSelection!) + files.logbookVesselSelection = path + totalUncompressedBytes += size + }) + } + + for (const c of collected.crews) { + packSteps.push(() => { + const path = `payloads/crews/${c.payloadId}.enc` + const size = addEncFile(zipFiles, path, c) + const index: BackupIndexedPayloadFile = { + path, + payloadId: c.payloadId, + updatedAt: c.updatedAt, + bytes: size + } + files.crews.push(index) + totalUncompressedBytes += size + }) + } + + for (const e of collected.entries) { + packSteps.push(() => { + const path = `payloads/entries/${e.payloadId}.enc` + const size = addEncFile(zipFiles, path, e) + const index: BackupIndexedPayloadFile = { + path, + payloadId: e.payloadId, + updatedAt: e.updatedAt, + bytes: size + } + files.entries.push(index) + totalUncompressedBytes += size + }) + } + + for (const p of collected.photos) { + packSteps.push(() => { + const path = `payloads/photos/${p.payloadId}.enc` + const size = addEncFile(zipFiles, path, p) + const index: BackupIndexedEntryFile = { + path, + payloadId: p.payloadId, + entryId: p.entryId, + updatedAt: p.updatedAt, + bytes: size + } + files.photos.push(index) + totalUncompressedBytes += size + }) + } + + for (const v of collected.voiceMemos) { + packSteps.push(() => { + const path = `payloads/voice-memos/${v.payloadId}.enc` + const size = addEncFile(zipFiles, path, v) + const index: BackupIndexedEntryFile = { + path, + payloadId: v.payloadId, + entryId: v.entryId, + updatedAt: v.updatedAt, + bytes: size + } + files.voiceMemos.push(index) + totalUncompressedBytes += size + }) + } + + for (const t of collected.gpsTracks) { + packSteps.push(() => { + const path = `payloads/gps-tracks/${t.entryId}.enc` + const size = addEncFile(zipFiles, path, t) + const index: BackupIndexedTrackFile = { + path, + entryId: t.entryId, + updatedAt: t.updatedAt, + bytes: size + } + files.gpsTracks.push(index) + totalUncompressedBytes += size + }) + } + + for (const n of collected.nmeaArchives) { + packSteps.push(() => { + const path = `payloads/nmea-archives/${n.entryId}.enc` + const size = addEncFile(zipFiles, path, n) + const index: BackupIndexedTrackFile = { + path, + entryId: n.entryId, + updatedAt: n.updatedAt, + bytes: size + } + files.nmeaArchives.push(index) + totalUncompressedBytes += size + }) + } + + const total = packSteps.length + packSteps.forEach((step, i) => { + step() + options.onProgress?.({ + phase: 'pack', + current: i + 1, + total, + bytesPacked: totalUncompressedBytes + }) + }) + + const counts: BackupManifestCounts = { + entries: collected.entries.length, + photos: collected.photos.length, + voiceMemos: collected.voiceMemos.length, + crews: collected.crews.length, + gpsTracks: collected.gpsTracks.length, + nmeaArchives: collected.nmeaArchives.length, + hasYacht: !!collected.yacht, + hasDeviation: !!collected.deviation, + hasLogbookCrewSelection: !!collected.logbookCrewSelection, + hasLogbookVesselSelection: !!collected.logbookVesselSelection + } + + const manifest: BackupManifestV2 = { + format: BACKUP_FORMAT, + version: BACKUP_VERSION, + exportedAt: options.exportedAt, + appVersion: options.appVersion, + compression: 'zip-deflate-6', + logbookId: collected.logbookMeta.id, + counts, + totalUncompressedBytes, + files + } + + zipFiles['manifest.json'] = utf8Bytes(JSON.stringify(manifest)) + totalUncompressedBytes += zipFiles['manifest.json'].byteLength + + const zipBytes = buildZipArchive(zipFiles) + manifest.totalUncompressedBytes = totalUncompressedBytes + + options.onProgress?.({ + phase: 'done', + current: total, + total, + bytesPacked: totalUncompressedBytes + }) + + return { zipBytes, manifest, counts, totalUncompressedBytes } +} diff --git a/client/src/services/logbookBackup/encBlob.test.ts b/client/src/services/logbookBackup/encBlob.test.ts new file mode 100644 index 0000000..34f3675 --- /dev/null +++ b/client/src/services/logbookBackup/encBlob.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { + dexieFieldsFromEncBytes, + encBytesFromDexieFields, + ENC_HEADER_SIZE +} from './encBlob.js' + +function toB64(bytes: number[]): string { + return btoa(String.fromCharCode(...bytes)) +} + +describe('encBlob', () => { + it('round-trips dexie AES-GCM fields', () => { + const fields = { + encryptedData: toB64([9, 8, 7]), + iv: toB64(Array.from({ length: 12 }, (_, i) => i)), + tag: toB64(Array.from({ length: 16 }, (_, i) => i + 20)) + } + const enc = encBytesFromDexieFields(fields) + expect(enc.byteLength).toBe(ENC_HEADER_SIZE + 3) + expect(dexieFieldsFromEncBytes(enc)).toEqual(fields) + }) + + it('rejects invalid magic', () => { + expect(() => dexieFieldsFromEncBytes(new Uint8Array(40))).toThrow('BACKUP_INVALID_ENC') + }) +}) diff --git a/client/src/services/logbookBackup/encBlob.ts b/client/src/services/logbookBackup/encBlob.ts new file mode 100644 index 0000000..ed5cc3a --- /dev/null +++ b/client/src/services/logbookBackup/encBlob.ts @@ -0,0 +1,45 @@ +import { base64ToBuffer, bufferToBase64 } from '../crypto.js' + +export const ENC_MAGIC = new Uint8Array([0x4b, 0x44, 0x41, 0x42]) // KDAB +export const ENC_FORMAT_VERSION = 1 +export const ENC_HEADER_SIZE = 33 // 4 + 1 + 12 + 16 + +export interface DexieEncFields { + encryptedData: string + iv: string + tag: string +} + +export function encBytesFromDexieFields(fields: DexieEncFields): Uint8Array { + const iv = new Uint8Array(base64ToBuffer(fields.iv)) + const tag = new Uint8Array(base64ToBuffer(fields.tag)) + const ciphertext = new Uint8Array(base64ToBuffer(fields.encryptedData)) + if (iv.length !== 12) throw new Error('BACKUP_INVALID_ENC') + if (tag.length !== 16) throw new Error('BACKUP_INVALID_ENC') + + const out = new Uint8Array(ENC_HEADER_SIZE + ciphertext.length) + out.set(ENC_MAGIC, 0) + out[4] = ENC_FORMAT_VERSION + out.set(iv, 5) + out.set(tag, 17) + out.set(ciphertext, 33) + return out +} + +export function dexieFieldsFromEncBytes(bytes: Uint8Array): DexieEncFields { + if (bytes.length < ENC_HEADER_SIZE) throw new Error('BACKUP_INVALID_ENC') + for (let i = 0; i < 4; i++) { + if (bytes[i] !== ENC_MAGIC[i]) throw new Error('BACKUP_INVALID_ENC') + } + if (bytes[4] !== ENC_FORMAT_VERSION) throw new Error('BACKUP_INVALID_ENC') + + const iv = bufferToBase64(bytes.slice(5, 17).buffer) + const tag = bufferToBase64(bytes.slice(17, 33).buffer) + const ciphertext = bufferToBase64(bytes.slice(33).buffer) + return { encryptedData: ciphertext, iv, tag } +} + +export function encByteLength(fields: DexieEncFields): number { + const ct = base64ToBuffer(fields.encryptedData).byteLength + return ENC_HEADER_SIZE + ct +} diff --git a/client/src/services/logbookBackup/manifest.ts b/client/src/services/logbookBackup/manifest.ts new file mode 100644 index 0000000..2fa9059 --- /dev/null +++ b/client/src/services/logbookBackup/manifest.ts @@ -0,0 +1,97 @@ +export const BACKUP_FORMAT = 'kapteins-daagbok-backup' as const +export const BACKUP_VERSION = 2 as const + +export interface BackupIndexedFile { + path: string + updatedAt: string + bytes: number +} + +export interface BackupIndexedPayloadFile extends BackupIndexedFile { + payloadId: string +} + +export interface BackupIndexedEntryFile extends BackupIndexedPayloadFile { + entryId: string +} + +export interface BackupIndexedTrackFile extends BackupIndexedFile { + entryId: string +} + +export interface BackupManifestCounts { + entries: number + photos: number + voiceMemos: number + crews: number + gpsTracks: number + nmeaArchives: number + hasYacht: boolean + hasDeviation: boolean + hasLogbookCrewSelection: boolean + hasLogbookVesselSelection: boolean +} + +export interface BackupManifestFiles { + key: string + logbook: string + yacht: string | null + deviation: string | null + logbookCrewSelection: string | null + logbookVesselSelection: string | null + crews: BackupIndexedPayloadFile[] + entries: BackupIndexedPayloadFile[] + photos: BackupIndexedEntryFile[] + voiceMemos: BackupIndexedEntryFile[] + gpsTracks: BackupIndexedTrackFile[] + nmeaArchives: BackupIndexedTrackFile[] +} + +export interface BackupManifestV2 { + format: typeof BACKUP_FORMAT + version: typeof BACKUP_VERSION + exportedAt: string + appVersion?: string + compression: 'zip-deflate-6' + logbookId: string + counts: BackupManifestCounts + totalUncompressedBytes: number + files: BackupManifestFiles +} + +export interface LogbookMetaJson { + id: string + encryptedTitle: string + updatedAt: string + isDemo?: boolean +} + +export function parseManifestJson(text: string): BackupManifestV2 { + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + throw new Error('BACKUP_INVALID_FORMAT') + } + if (!isBackupManifestV2(parsed)) { + throw new Error('BACKUP_INVALID_FORMAT') + } + return parsed +} + +export function isBackupManifestV2(value: unknown): value is BackupManifestV2 { + if (!value || typeof value !== 'object') return false + const obj = value as Partial + return ( + obj.format === BACKUP_FORMAT && + obj.version === BACKUP_VERSION && + typeof obj.exportedAt === 'string' && + typeof obj.logbookId === 'string' && + !!obj.counts && + !!obj.files + ) +} + +export function serializeManifest(manifest: BackupManifestV2): string { + return JSON.stringify(manifest) +} diff --git a/client/src/services/logbookBackup/zipArchive.ts b/client/src/services/logbookBackup/zipArchive.ts new file mode 100644 index 0000000..3dede2d --- /dev/null +++ b/client/src/services/logbookBackup/zipArchive.ts @@ -0,0 +1,45 @@ +import { strToU8, unzipSync, zipSync } from 'fflate' +import { parseManifestJson, type BackupManifestV2 } from './manifest.js' + +const ZIP_LEVEL = 6 + +export function buildZipArchive(files: Record): Uint8Array { + return zipSync(files, { level: ZIP_LEVEL }) +} + +export function unzipArchive(data: Uint8Array): Record { + try { + return unzipSync(data) + } catch { + throw new Error('BACKUP_INVALID_ARCHIVE') + } +} + +export function readManifestFromArchive( + files: Record +): BackupManifestV2 { + const raw = files['manifest.json'] + if (!raw) throw new Error('BACKUP_INVALID_FORMAT') + const text = new TextDecoder().decode(raw) + return parseManifestJson(text) +} + +export function readTextFile(files: Record, path: string): string { + const raw = files[path] + if (!raw) throw new Error('BACKUP_MISSING_BLOB') + return new TextDecoder().decode(raw) +} + +export function readBinaryFile(files: Record, path: string): Uint8Array { + const raw = files[path] + if (!raw) throw new Error('BACKUP_MISSING_BLOB') + return raw +} + +export function utf8Bytes(text: string): Uint8Array { + return strToU8(text) +} + +export function isZipArchive(bytes: Uint8Array): boolean { + return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b +} diff --git a/docs/backup-format-v2.md b/docs/backup-format-v2.md new file mode 100644 index 0000000..a1e2bca --- /dev/null +++ b/docs/backup-format-v2.md @@ -0,0 +1,337 @@ +# Backup-Format v2 — Design + +**Status:** Implementiert in `feature/backup-format-v2` (`BACKUP_VERSION = 2`, Datei `*.daagbok`). + +**Ziel:** Logbuch-Backups skalieren für viele Reisetage, Fotos, Voice-Memos und GPS-Tracks — ohne den gesamten Inhalt als eine große JSON-Datei im Browser-RAM zu halten. + +**Ausgangslage:** v1 (`BACKUP_VERSION = 1`, Datei `*.daagbok.json`) serialisiert alle Payloads in ein einziges JSON-Objekt mit Pretty-Print. Binärdaten stecken doppelt als Base64-Strings in `encryptedData`. Import nutzt `file.text()` + `JSON.parse()` auf der vollen Datei. + +**Entscheidung:** Keine Abwärtskompatibilität zu v1 — es gibt noch keine produktiven User-Backups. v1-Code und `-json`-Dateiendung wurden durch v2 ersetzt. + +--- + +## 1. Anforderungen + +### Funktional + +| ID | Anforderung | +|----|-------------| +| B-01 | Export enthält **alle** lokalen Logbuch-Payloads: yacht, deviation, crews, entries, photos, voiceMemos, gpsTracks, **logbookCrewSelection**, **logbookVesselSelection**, **nmeaArchives**. | +| B-02 | E2E-Verschlüsselung bleibt: Logbuch-Key mit Backup-Passphrase (PBKDF2) gewrappt; Payload-Blobs unverändert wie in Dexie (AES-GCM über `encryptJson`). | +| B-03 | Medien (Fotos, Voice-Memos) als **binäre Blob-Dateien** im Archiv, nicht als Base64 in JSON. | +| B-04 | Strukturdaten (Manifest, kleine Metadaten) als **kompaktes JSON** (ein Zeile, kein Pretty-Print). | +| B-05 | Gesamtarchiv **DEFLATE-komprimiert** (ZIP). | +| B-06 | Preview (Titel, Counts, Export-Datum) ohne vollständiges Entpacken aller Medien — nur Manifest + Key-Entschlüsselung. | +| B-07 | Restore-Optionen wie heute: `overwrite`, `assignNewId`, Konflikt bei gleicher ID. | +| B-08 | Nach Restore: Sync-Queue wie heute befüllen, optional `syncLogbook` wenn online. | +| B-09 | Export vor Download optional mit **Fortschrittsanzeige** (Anzahl Blobs / Bytes). | + +### Nicht-Ziele (v2) + +- Inkrementelles / dedupliziertes Backup über mehrere Dateien. +- Backup auf dem Server (nur lokaler Download wie heute). +- Klartext-Manifest oder unverschlüsselte Medien. +- Account-weites Multi-Logbuch-Archiv (weiterhin **ein Logbuch pro Datei**). + +### Akzeptanzkriterien (UAT) + +1. Logbuch mit 50 Fotos + 20 Voice-Memos exportieren → Datei `.daagbok` deutlich kleiner als vergleichbares v1-JSON (Kompression + kein Pretty-Print + binäre Blobs). +2. Restore auf frischem Gerät (eingeloggt) → alle Einträge, Medien abspielbar, Crew/Vessel-Selection vorhanden. +3. Falsches Passphrase → `BACKUP_WRONG_PASSPHRASE` (wie heute). +4. Beschädigtes ZIP / fehlende Manifest → `BACKUP_INVALID_FORMAT`. +5. v1-Datei (`version: 1`) → `BACKUP_VERSION_UNSUPPORTED` mit Hinweis. + +--- + +## 2. Container: ZIP-Archiv + +| Eigenschaft | Wert | +|-------------|------| +| MIME-Typ (Download) | `application/vnd.kapteins-daagbok+zip` (Fallback: `application/zip`) | +| Dateiendung | `.daagbok` (kein `.json`) | +| Kompression | ZIP mit DEFLATE (Level 6 — Balance Größe/Geschwindigkeit) | +| Magic / Erkennung | ZIP-Signatur `PK\x03\x04` + `manifest.json` mit `format` + `version: 2` | + +**Bibliothek (Client):** [`fflate`](https://github.com/101arrowz/fflate) (klein, ESM, ZIP sync/async). Als direkte `dependencies`-Eintrag in `client/package.json`, nicht nur transitiv. + +**Warum ZIP und nicht nur gzip auf einer JSON-Datei?** + +- Viele unabhängige Blobs → paralleles Entpacken, Preview ohne alle Medien zu lesen. +- Manifest bleibt klein (< 100 KB typisch). +- Standard-Tooling (optional manuelle Inspektion mit `unzip -l`). + +--- + +## 3. Archiv-Layout + +``` +backup.daagbok (ZIP) +├── manifest.json # Klartext-Metadaten + Index (kompakt) +├── key.enc # Mit Backup-Passphrase gewrapptes Logbuch-Key (binär) +├── logbook.meta.json # encryptedTitle, id, updatedAt, isDemo (klein, JSON) +└── payloads/ + ├── yacht.enc + ├── deviation.enc + ├── logbook-crew.enc + ├── logbook-vessel.enc + ├── crews/ + │ └── {payloadId}.enc + ├── entries/ + │ └── {payloadId}.enc + ├── photos/ + │ └── {payloadId}.enc # + sidecar optional: {payloadId}.meta.json + ├── voice-memos/ + │ └── {payloadId}.enc + ├── gps-tracks/ + │ └── {entryId}.enc + └── nmea-archives/ + └── {entryId}.enc +``` + +### 3.1 `manifest.json` (Schema) + +```json +{ + "format": "kapteins-daagbok-backup", + "version": 2, + "exportedAt": "2026-06-03T12:00:00.000Z", + "appVersion": "0.1.0.109", + "compression": "zip-deflate-6", + "logbookId": "uuid", + "counts": { + "entries": 42, + "photos": 50, + "voiceMemos": 12, + "crews": 3, + "gpsTracks": 40, + "nmeaArchives": 2, + "hasYacht": true, + "hasDeviation": true, + "hasLogbookCrewSelection": true, + "hasLogbookVesselSelection": true + }, + "totalUncompressedBytes": 125000000, + "files": { + "key": "key.enc", + "logbook": "logbook.meta.json", + "yacht": "payloads/yacht.enc", + "deviation": null, + "logbookCrewSelection": "payloads/logbook-crew.enc", + "logbookVesselSelection": "payloads/logbook-vessel.enc", + "crews": [ + { "payloadId": "…", "path": "payloads/crews/….enc", "updatedAt": "…" } + ], + "entries": [ … ], + "photos": [ + { "payloadId": "…", "entryId": "…", "path": "…", "updatedAt": "…", "bytes": 183422 } + ], + "voiceMemos": [ … ], + "gpsTracks": [ … ], + "nmeaArchives": [ … ] + } +} +``` + +- **`appVersion`:** optional, aus Client-Build (PWA-Version) — nur für Support/Debug. +- **`totalUncompressedBytes`:** Summe der Blob-Größen vor ZIP — für UI („~120 MB“) und Speicher-Warnung vor Import. +- **Keine** `encryptedData` / IV / Tag im Manifest für große Blobs — nur im Binärformat (siehe 3.2). + +### 3.2 Binärformat `.enc` (einheitlich für alle Payloads) + +Jede `.enc`-Datei ist **roh**, kein JSON: + +``` +Offset Size Inhalt +0 4 Magic ASCII "KDAB" (Kaptein's Daagbok) +4 1 Format version = 1 +5 12 IV (AES-GCM, wie heute) +17 16 Auth tag (AES-GCM) +33 N Ciphertext (identisch mit heutigem decryptJson-Eingang: + Base64-decode von encryptedData + concat mit tag in decryptBuffer) +``` + +**Migration von Dexie:** Beim Export aus `encryptedData` (Base64-String) + `iv` + `tag` (Base64) → einmal decodieren → `.enc`-Datei schreiben. Beim Import umgekehrt → Dexie-Felder wie heute. + +Vorteil: ~33 % weniger Speicher als Base64-in-JSON; Parser liest nur Header + Länge. + +**`key.enc`:** Gleiches Binärformat, Inhalt = `encryptBuffer(logbookKey, passphraseDerivedKey)` — ersetzt das JSON-Objekt `logbookKey: { ciphertext, iv, tag }` aus v1. + +**`logbook.meta.json`:** Unverändert kleines JSON (nur `encryptedTitle`, `updatedAt`, `isDemo`) — kein Binärbedarf. + +### 3.3 Optionale Sidecars (Phase 2, nicht blocking v2) + +Für Photos/Voice-Memos könnte `{id}.meta.json` nur `{ entryId, updatedAt }` enthalten, falls das Manifest zu groß wird (>10k Medien). v2 startet mit **allen Metadaten im Manifest** — ausreichend bis ~einige tausend Dateien. + +--- + +## 4. Kryptographie (unverändert in der Semantik) + +| Element | v1 | v2 | +|---------|----|----| +| Logbuch-Key im Backup | PBKDF2 + AES-GCM (`KapteinsDaagbokBackupFileSalt_v1`) | **Gleich** (Salt-String beibehalten für gleiche Passphrase → gleicher Key) | +| Payload-Verschlüsselung | `encryptJson` mit Logbuch-Key | **Byte-für-byte gleicher Ciphertext** in `.enc` | +| Passphrase-Mindestlänge | 8 Zeichen | 8 Zeichen | + +Optional in v2.1 (nicht v2): PBKDF2-Iterationen erhöhen (z. B. 310_000) mit neuem Salt `…_v2` und Feld `keyWrap: "pbkdf2-v2"` in Manifest — nur wenn gewünscht. + +--- + +## 5. Ablauf Export + +```mermaid +sequenceDiagram + participant UI as LogbookBackupPanel + participant Svc as logbookBackupV2 + participant DB as Dexie + participant ZIP as fflate ZIP + + UI->>Svc: exportLogbookBackup(logbookId, passphrase) + Svc->>DB: collect all tables (batched) + loop each payload + Svc->>Svc: base64 → KDAB .enc bytes + Svc->>ZIP: add file (deflate) + end + Svc->>ZIP: manifest.json + key.enc + logbook.meta.json + Svc->>UI: Blob + filename.daagbok +``` + +### Implementierungsdetails + +1. **Pre-sync:** wie heute `syncLogbook(logbookId)` wenn online. +2. **Sammlung:** `collectLogbookPayloadsV2()` — alle Tabellen aus Abschnitt B-01; Batches à 20 für Medien. +3. **ZIP-Erzeugung:** `fflate` `zipSync` oder `zip` mit Streaming-Callback; **nicht** das gesamte Archiv als ein Array im RAM, wenn `totalUncompressedBytes > 80_000_000` → Warnung in UI + ggf. `requestIdleCallback` zwischen Blobs. +4. **Fortschritt:** `onProgress({ phase, current, total, bytes })`. +5. **Download:** `downloadBackupBlob(blob, `${safeTitle}-${date}.daagbok`)`. + +### Speicher-Richtwerte (UI-Warnung) + +| Uncompressed | Empfehlung | +|--------------|------------| +| < 50 MB | Normal exportieren | +| 50–150 MB | Hinweis: Import kann auf schwachen Geräten dauern | +| > 150 MB | Bestätigungsdialog; Export trotzdem erlauben | + +ZIP reduziert typisch Medien-Anteil um **20–40 %** (JPEG/WebM komprimieren schlecht in ZIP, aber Base64-Overhead entfällt). + +--- + +## 6. Ablauf Import / Preview + +### Preview (Passphrase-Check) + +1. ZIP öffnen (nur zentrales Directory lesen — `fflate` `unzip`). +2. `manifest.json` parsen → `version === 2` prüfen. +3. `key.enc` laden → Passphrase → Logbuch-Key. +4. `logbook.meta.json` → Titel entschlüsseln. +5. Counts aus Manifest anzeigen — **keine** Medien-Blobs dekodieren. + +### Restore + +1. Vollständig entpacken in **temporäre Struktur** (Object-URLs / `Map`) — bei >150 MB Warnung. +2. `writeBackupToDexieV2()` — analog v1, aber aus `.enc` Bytes. +3. `queueRestoredLogbookForSync()` — unveränderte Sync-Queue-Semantik. +4. `listCache` auf Entries: nach Restore optional aus Entry-Payload neu ableiten (wie bei normalem Decrypt) oder beim Export mit speichern — **Design:** listCache beim Export **nicht** sichern (bleibt abgeleitetes Feld); nach Restore beim ersten Öffnen neu berechnen. + +### Fehlercodes (neu/angepasst) + +| Code | Bedeutung | +|------|-----------| +| `BACKUP_VERSION_UNSUPPORTED` | `version !== 2` (inkl. v1) | +| `BACKUP_INVALID_ARCHIVE` | Kein ZIP / kein manifest | +| `BACKUP_MISSING_BLOB` | Index verweist auf fehlende Datei | +| `BACKUP_INVALID_ENC` | Magic/ Länge ungültig | +| *(bestehend)* | `BACKUP_WRONG_PASSPHRASE`, `BACKUP_ID_CONFLICT`, … | + +--- + +## 7. Code-Struktur (Implementierung) + +| Datei | Aufgabe | +|-------|---------| +| [`client/src/services/logbookBackup/encBlob.ts`](client/src/services/logbookBackup/encBlob.ts) | `dexieRecordToEncBytes`, `encBytesToDexieFields` | +| [`client/src/services/logbookBackup/manifest.ts`](client/src/services/logbookBackup/manifest.ts) | Typen `BackupManifestV2`, Validierung | +| [`client/src/services/logbookBackup/zipArchive.ts`](client/src/services/logbookBackup/zipArchive.ts) | ZIP pack/unpack mit fflate | +| [`client/src/services/logbookBackup.ts`](client/src/services/logbookBackup.ts) | Öffentliche API: `exportLogbookBackup`, `parseLogbookBackupFile`, `preview…`, `restore…` — ruft v2 intern auf | +| [`client/src/components/LogbookBackupPanel.tsx`](client/src/components/LogbookBackupPanel.tsx) | `.daagbok`-Accept, Fortschrittsbalken, Größen-Warnung | +| i18n `settings.backup_*` | Texte für v2, `BACKUP_VERSION_UNSUPPORTED` | +| [`docs/plausible-events.md`](docs/plausible-events.md) | Properties `bytes`, `counts` bei Export/Restore | + +**v1 entfernen:** `BACKUP_VERSION = 1`, `LogbookBackupFile`-Monolith, `JSON.stringify(…, null, 2)`, `normalizeBackupPayloads` für voiceMemos — löschen, nicht parallel halten. + +--- + +## 8. Vergleich v1 → v2 + +| Aspekt | v1 | v2 | +|--------|----|----| +| Container | Eine JSON-Datei | ZIP `.daagbok` | +| Medien im Export | Base64 in JSON-Strings | Binäre `.enc`-Dateien | +| Manifest | Alles in einem Objekt | Schlankes `manifest.json` + Blobs | +| Kompression | Keine (+ Pretty-Print) | DEFLATE | +| RAM Import | `file.text()` + full parse | ZIP directory + gezieltes Entpacken; Preview nur Manifest | +| Fehlende Payloads | Kein crew/vessel selection, kein NMEA | Vollständig | +| Abwärtskompatibel | — | Nein (bewusst) | + +### Größenbeispiel (Schätzung) + +100 Fotos à ~250 KB verschlüsselt (≈190 KB Ciphertext): + +- **v1 JSON:** ~100 × (190 KB × 4/3 Base64) ≈ **25 MB** nur Fotos-Ciphertext-Strings + JSON-Escaping + Pretty-Print → oft **35+ MB** +- **v2 ZIP:** ~100 × 190 KB + Kompression ≈ **19–22 MB** Archiv + +--- + +## 9. Implementierungsplan (Phasen) + +### Phase 1 — Kern (MVP v2) + +- [x] `encBlob` + `manifest` + `zipArchive` Module +- [x] Export/Import/Preview/Restore auf v2 +- [x] Alle Dexie-Tabellen inkl. crew/vessel selection + nmeaArchives +- [x] UI: `.daagbok`, Fehler `BACKUP_VERSION_UNSUPPORTED` für v1-JSON +- [x] Tests: `encBlob` unit tests + +### Phase 2 — UX & Robustheit + +- [x] Export-Fortschritt +- [x] Größen-Warnung vor Import (>150 MB) +- [ ] `onProgress` während Restore + +### Phase 3 — Optional + +- [ ] Streaming-Export für sehr große Archive (fflate async + Chunk-Schreiben) +- [ ] PBKDF2 v2 mit höheren Iterationen +- [ ] Sidecar-Metadaten wenn Manifest > 2 MB + +--- + +## 10. Testplan + +| Test | Typ | +|------|-----| +| `encBlob`: round-trip Dexie-Felder ↔ Bytes | Unit | +| Manifest-Validator: version 2, fehlende paths | Unit | +| Export → unzip → Manifest-Counts = DB-Counts | Integration (Vitest + IndexedDB fake) | +| Restore → decrypt photo/voice → gültige Data-URL | Integration | +| v1-JSON-Datei → `BACKUP_VERSION_UNSUPPORTED` | Unit | +| Korruptes ZIP → `BACKUP_INVALID_ARCHIVE` | Unit | + +--- + +## 11. Dokumentation & Analytics + +- Nutzer-Texte: „Sicherungsdatei `.daagbok`“ statt `.daagbok.json`. +- Plausible **Backup Exported / Restored:** Properties `bytes`, `photos`, `voiceMemos` (Anzahlen, keine Inhalte). +- Deployment: **kein Server-Change** — Backup ist rein clientseitig. + +--- + +## 12. Offene Punkte (für Review) + +1. **Schwellwert Speicher-Warnung:** 150 MB uncompressed — anpassen nach ersten Real-Logbüchern? +2. **NMEA-Archive:** oft groß — eigenes Subdirectory ausreichend; später ggf. „NMEA nicht ins Backup“ als Opt-out? +3. **Geteilte Logbücher (`isShared === 1`):** Export weiterhin nur Owner — unverändert. + +--- + +*Implementierung: [`client/src/services/logbookBackup/`](../client/src/services/logbookBackup/), API in [`client/src/services/logbookBackup.ts`](../client/src/services/logbookBackup.ts).* diff --git a/docs/plausible-events.md b/docs/plausible-events.md index 9b98d68..6a6caa1 100644 --- a/docs/plausible-events.md +++ b/docs/plausible-events.md @@ -42,8 +42,8 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri | Live Log Voice Uploaded | Sprachnotiz im Live-Journal gespeichert (`voiceAttachments.ts`, `analyticsContext`: `live_log`) | — | | OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) | | AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — | -| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`) | `entries`, `photos` (Anzahlen, keine Inhalte) | -| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`) | `entries`, `photos`, `mode`: `same_id` \| `overwrite` \| `new_id` | +| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes` (Anzahlen/Größe, keine Inhalte) | +| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes`, `mode`: `same_id` \| `overwrite` \| `new_id` | | Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — | | Push Disabled | Crew-Änderungs-Push deaktiviert (`PushNotificationSettings.tsx`) | — | | Footer Link Clicked | Klick auf Autoren-Link im App-Footer (`AppFooter.tsx`) | — |