From b317be5ae1e5ba8cf6c79cc8e840ef2aef058ad0 Mon Sep 17 00:00:00 2001 From: elpatron Date: Fri, 29 May 2026 20:42:44 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Logbuch-Backup/Restore=20f=C3=BCr=20Eig?= =?UTF-8?q?ner=20mit=20Plausible-Events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vollständiges verschlüsseltes .daagbok.json-Backup inkl. Fotos und GPS; Restore auf gleichem oder neuem Account. Events Backup Exported und Backup Restored mit Anzahlen und Restore-Modus. Co-authored-by: Cursor --- client/src/App.css | 60 ++ client/src/App.tsx | 5 +- client/src/components/LogbookBackupPanel.tsx | 327 ++++++++++ client/src/components/SettingsForm.tsx | 9 +- client/src/i18n/locales/de.json | 36 +- client/src/i18n/locales/en.json | 36 +- client/src/services/analytics.ts | 4 +- client/src/services/logbookBackup.ts | 601 +++++++++++++++++++ docs/plausible-events.md | 3 + 9 files changed, 1076 insertions(+), 5 deletions(-) create mode 100644 client/src/components/LogbookBackupPanel.tsx create mode 100644 client/src/services/logbookBackup.ts diff --git a/client/src/App.css b/client/src/App.css index 41c8c5c..71803a1 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -3048,6 +3048,66 @@ html.theme-cupertino .events-scroll-container { border: 1px solid rgba(148, 163, 184, 0.25); } +.backup-panel .backup-section { + margin-bottom: 28px; + padding-bottom: 24px; + border-bottom: 1px solid var(--app-border-subtle); +} + +.backup-panel .backup-section--import { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +.backup-section-title { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 8px; + font-size: 14px; + font-weight: 600; + color: var(--app-text-heading); +} + +.backup-section-desc { + font-size: 13px; + margin: 0 0 14px; +} + +.backup-actions-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 12px; +} + +.backup-preview { + margin-top: 16px; + padding: 14px 16px; + border-radius: var(--app-radius-card); + border: 1px solid var(--app-border-subtle); +} + +.backup-preview-title { + margin: 0 0 10px; + font-size: 16px; + font-weight: 600; + color: var(--app-text-heading); +} + +.backup-preview-stats { + margin: 0 0 8px; + padding-left: 18px; + font-size: 13px; + color: var(--app-text-muted); +} + +.backup-preview-date { + margin: 0; + font-size: 12px; +} + .app-tour-root { position: fixed; inset: 0; diff --git a/client/src/App.tsx b/client/src/App.tsx index efc7cfc..df142f2 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -425,7 +425,10 @@ function App() { */} {activeTab === 'settings' && ( - + )} diff --git a/client/src/components/LogbookBackupPanel.tsx b/client/src/components/LogbookBackupPanel.tsx new file mode 100644 index 0000000..ddb4d2b --- /dev/null +++ b/client/src/components/LogbookBackupPanel.tsx @@ -0,0 +1,327 @@ +import { useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Archive, Download, Upload, Check, AlertTriangle } from 'lucide-react' +import { useDialog } from './ModalDialog.tsx' +import { + downloadBackupBlob, + exportLogbookBackup, + parseLogbookBackupFile, + previewLogbookBackup, + restoreLogbookBackup, + type LogbookBackupFile, + type LogbookBackupPreview +} from '../services/logbookBackup.js' +import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' + +interface LogbookBackupPanelProps { + logbookId: string + onRestored?: (logbookId: string, title: string) => void +} + +function mapBackupError(code: string, t: (key: string) => string): string { + switch (code) { + case 'BACKUP_PASSPHRASE_TOO_SHORT': + return t('settings.backup_passphrase_short') + case 'BACKUP_NOT_OWNER': + return t('settings.backup_not_owner') + case 'BACKUP_INVALID_JSON': + return t('settings.backup_invalid_json') + case 'BACKUP_INVALID_FORMAT': + return t('settings.backup_invalid_format') + case 'BACKUP_NOT_AUTHENTICATED': + return t('settings.backup_not_authenticated') + case 'BACKUP_ID_CONFLICT': + return t('settings.backup_id_conflict') + default: + if (code.includes('decrypt') || code.includes('operation')) { + return t('settings.backup_wrong_passphrase') + } + return code + } +} + +export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBackupPanelProps) { + const { t } = useTranslation() + const { showConfirm } = useDialog() + const fileInputRef = useRef(null) + + const [exportPassphrase, setExportPassphrase] = useState('') + const [exportConfirm, setExportConfirm] = useState('') + const [exporting, setExporting] = useState(false) + + const [importPassphrase, setImportPassphrase] = useState('') + const [importFile, setImportFile] = useState(null) + const [importPreview, setImportPreview] = useState(null) + const [parsedBackup, setParsedBackup] = useState(null) + const [importing, setImporting] = useState(false) + const [previewing, setPreviewing] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + + const handleExport = async () => { + setError(null) + setSuccess(null) + + if (exportPassphrase.length < 8) { + setError(t('settings.backup_passphrase_short')) + return + } + if (exportPassphrase !== exportConfirm) { + setError(t('settings.backup_passphrase_mismatch')) + return + } + + setExporting(true) + try { + const { blob, filename, backup } = await exportLogbookBackup(logbookId, exportPassphrase) + downloadBackupBlob(blob, filename) + setSuccess(t('settings.backup_export_success', { count: backup.counts.entries })) + setExportPassphrase('') + setExportConfirm('') + trackPlausibleEvent(PlausibleEvents.BACKUP_EXPORTED, { + entries: backup.counts.entries, + photos: backup.counts.photos + }) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + setError(mapBackupError(message, t)) + } finally { + setExporting(false) + } + } + + const handleFileChange = async (e: React.ChangeEvent) => { + setError(null) + setSuccess(null) + setImportPreview(null) + setParsedBackup(null) + const file = e.target.files?.[0] + setImportFile(file ?? null) + if (!file) return + + try { + const backup = await parseLogbookBackupFile(file) + setParsedBackup(backup) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + setError(mapBackupError(message, t)) + setImportFile(null) + } + } + + const handlePreviewImport = async () => { + if (!parsedBackup || !importPassphrase) return + setPreviewing(true) + setError(null) + try { + const preview = await previewLogbookBackup(parsedBackup, importPassphrase) + setImportPreview(preview) + } catch (err: unknown) { + setImportPreview(null) + setError(t('settings.backup_wrong_passphrase')) + } finally { + setPreviewing(false) + } + } + + const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => { + if (!parsedBackup || !importPassphrase) return + + setImporting(true) + setError(null) + try { + const result = await restoreLogbookBackup(parsedBackup, importPassphrase, options) + setSuccess(t('settings.backup_restore_success', { title: result.title })) + setImportFile(null) + setImportPassphrase('') + setImportPreview(null) + setParsedBackup(null) + if (fileInputRef.current) fileInputRef.current.value = '' + trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, { + entries: parsedBackup.counts.entries, + photos: parsedBackup.counts.photos, + mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id' + }) + onRestored?.(result.logbookId, result.title) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + if (message === 'BACKUP_ID_CONFLICT') { + const overwrite = await showConfirm( + t('settings.backup_overwrite_confirm'), + t('settings.backup_restore_title'), + t('logs.confirm_yes'), + t('logs.confirm_no') + ) + if (overwrite) { + setImporting(false) + return handleRestore({ overwrite: true }) + } + const asNew = await showConfirm( + t('settings.backup_new_id_confirm'), + t('settings.backup_restore_title'), + t('logs.confirm_yes'), + t('logs.confirm_no') + ) + if (asNew) { + setImporting(false) + return handleRestore({ assignNewId: true }) + } + setError(t('settings.backup_restore_cancelled')) + } else { + setError(mapBackupError(message, t)) + } + } finally { + setImporting(false) + } + } + + return ( +
+
+ +

+ {t('settings.backup_title')} +

+
+ +

+ {t('settings.backup_desc')} +

+ + {error && ( +
+ + {error} +
+ )} + + {success && ( +
+ + {success} +
+ )} + +
+

+

+

{t('settings.backup_export_desc')}

+ +
+ + setExportPassphrase(e.target.value)} + placeholder={t('settings.backup_passphrase_placeholder')} + autoComplete="new-password" + disabled={exporting} + /> +
+
+ + setExportConfirm(e.target.value)} + autoComplete="new-password" + disabled={exporting} + /> +
+ +
+ +
+

+

+

{t('settings.backup_restore_desc')}

+ +
+ + +
+ + {importFile && ( + <> +
+ + { + setImportPassphrase(e.target.value) + setImportPreview(null) + }} + autoComplete="current-password" + disabled={importing} + /> +
+ +
+ + +
+ + )} + + {importPreview && ( +
+

{importPreview.title}

+
    +
  • {t('settings.backup_stat_entries', { count: importPreview.counts.entries })}
  • +
  • {t('settings.backup_stat_photos', { count: importPreview.counts.photos })}
  • +
  • {t('settings.backup_stat_crew', { count: importPreview.counts.crews })}
  • +
  • {t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}
  • +
+

+ {t('settings.backup_exported_at', { + date: new Date(importPreview.exportedAt).toLocaleString() + })} +

+
+ )} +
+
+ ) +} diff --git a/client/src/components/SettingsForm.tsx b/client/src/components/SettingsForm.tsx index dfa80e6..1902b33 100644 --- a/client/src/components/SettingsForm.tsx +++ b/client/src/components/SettingsForm.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, Compass } from 'lucide-react' import { ensureLogbookKey } from '../services/logbookKeys.js' +import LogbookBackupPanel from './LogbookBackupPanel.tsx' import AccountDangerZone from './AccountDangerZone.tsx' import PwaInstallPrompt from './PwaInstallPrompt.tsx' import { useDialog } from './ModalDialog.tsx' @@ -12,6 +13,7 @@ import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' interface SettingsFormProps { logbookId?: string | null + onLogbookRestored?: (logbookId: string, title: string) => void } interface Collaborator { @@ -29,7 +31,7 @@ const bufferToHex = (buffer: ArrayBuffer): string => { .join('') } -export default function SettingsForm({ logbookId }: SettingsFormProps) { +export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsFormProps) { const { t } = useTranslation() const { showConfirm, showAlert } = useDialog() const { restartTour } = useAppTour() @@ -454,6 +456,11 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) { )} + {/* Backup & Restore (owner only) */} + {logbookId && isOwner && ( + + )} + {/* Crew Collaboration Card (Only visible to Logbook Owner) */} {logbookId && isOwner && (
diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 1586af8..3028e6b 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -321,7 +321,41 @@ "deleting_account": "Konto wird gelöscht…", "tour_title": "App-Tour", "tour_desc": "Lassen Sie sich erneut durch die wichtigsten Bereiche der App führen.", - "tour_restart": "Tour erneut starten" + "tour_restart": "Tour erneut starten", + "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_export_title": "Backup erstellen", + "backup_export_desc": "Lädt alle lokalen Daten als .daagbok.json herunter. Bewahren Sie Datei und Passphrase getrennt und sicher auf.", + "backup_restore_title": "Backup wiederherstellen", + "backup_restore_desc": "Stellt ein Backup in Ihrem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.", + "backup_passphrase": "Backup-Passphrase", + "backup_passphrase_placeholder": "Mindestens 8 Zeichen", + "backup_passphrase_confirm": "Passphrase bestätigen", + "backup_passphrase_short": "Die Backup-Passphrase muss mindestens 8 Zeichen lang sein.", + "backup_passphrase_mismatch": "Passphrasen stimmen nicht überein.", + "backup_wrong_passphrase": "Passphrase falsch oder Backup beschädigt.", + "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_preview_btn": "Inhalt prüfen", + "backup_previewing": "Prüfe…", + "backup_restore_btn": "Wiederherstellen", + "backup_restoring": "Wird wiederhergestellt…", + "backup_restore_success": "Logbuch „{{title}}“ wurde wiederhergestellt.", + "backup_restore_cancelled": "Wiederherstellung abgebrochen.", + "backup_invalid_json": "Die Datei ist keine gültige JSON-Datei.", + "backup_invalid_format": "Unbekanntes oder veraltetes Backup-Format.", + "backup_not_owner": "Nur der Logbuch-Eigner kann Backups erstellen.", + "backup_not_authenticated": "Bitte melden Sie sich an, um ein Backup wiederherzustellen.", + "backup_id_conflict": "Ein Logbuch mit dieser ID existiert bereits.", + "backup_overwrite_confirm": "Das vorhandene Logbuch mit gleicher ID wird ersetzt. Fortfahren?", + "backup_new_id_confirm": "Das Backup als neues Logbuch mit neuer ID importieren?", + "backup_stat_entries": "{{count}} Reisetage", + "backup_stat_photos": "{{count}} Fotos", + "backup_stat_crew": "{{count}} Crew-Einträge", + "backup_stat_tracks": "{{count}} GPS-Tracks", + "backup_exported_at": "Exportiert: {{date}}" }, "disclaimer": { "title": "Wichtige Hinweise", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 06f5b18..9176934 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -321,7 +321,41 @@ "deleting_account": "Deleting account…", "tour_title": "App tour", "tour_desc": "Take a guided walkthrough of the main areas of the app again.", - "tour_restart": "Restart tour" + "tour_restart": "Restart tour", + "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_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_restore_title": "Restore backup", + "backup_restore_desc": "Restores a backup into your current account — including after registering a new account.", + "backup_passphrase": "Backup passphrase", + "backup_passphrase_placeholder": "At least 8 characters", + "backup_passphrase_confirm": "Confirm passphrase", + "backup_passphrase_short": "The backup passphrase must be at least 8 characters.", + "backup_passphrase_mismatch": "Passphrases do not match.", + "backup_wrong_passphrase": "Wrong passphrase or corrupted backup.", + "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_preview_btn": "Verify contents", + "backup_previewing": "Verifying…", + "backup_restore_btn": "Restore", + "backup_restoring": "Restoring…", + "backup_restore_success": "Logbook “{{title}}” has been restored.", + "backup_restore_cancelled": "Restore cancelled.", + "backup_invalid_json": "The file is not valid JSON.", + "backup_invalid_format": "Unknown or outdated backup format.", + "backup_not_owner": "Only the logbook owner can create backups.", + "backup_not_authenticated": "Please sign in to restore a backup.", + "backup_id_conflict": "A logbook with this ID already exists.", + "backup_overwrite_confirm": "The existing logbook with the same ID will be replaced. Continue?", + "backup_new_id_confirm": "Import the backup as a new logbook with a new ID?", + "backup_stat_entries": "{{count}} travel days", + "backup_stat_photos": "{{count}} photos", + "backup_stat_crew": "{{count}} crew records", + "backup_stat_tracks": "{{count}} GPS tracks", + "backup_exported_at": "Exported: {{date}}" }, "disclaimer": { "title": "Important notice", diff --git a/client/src/services/analytics.ts b/client/src/services/analytics.ts index 8b2e703..0c29c52 100644 --- a/client/src/services/analytics.ts +++ b/client/src/services/analytics.ts @@ -17,7 +17,9 @@ export const PlausibleEvents = { PDF_EXPORTED: 'PDF Exported', CSV_EXPORTED: 'CSV Exported', CSV_SHARED: 'CSV Shared', - PHOTO_UPLOADED: 'Photo Uploaded' + PHOTO_UPLOADED: 'Photo Uploaded', + BACKUP_EXPORTED: 'Backup Exported', + BACKUP_RESTORED: 'Backup Restored' } as const export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents] diff --git a/client/src/services/logbookBackup.ts b/client/src/services/logbookBackup.ts new file mode 100644 index 0000000..6ed8fa3 --- /dev/null +++ b/client/src/services/logbookBackup.ts @@ -0,0 +1,601 @@ +import { db } from './db.js' +import { getActiveMasterKey } from './auth.js' +import { + decryptJson, + encryptBuffer, + decryptBuffer +} from './crypto.js' +import { decryptLogbookTitle, deleteLocalLogbookCache } from './logbook.js' +import { ensureLogbookKey, getLogbookKey, saveLogbookKey } from './logbookKeys.js' +import { syncLogbook } from './sync.js' +import type { SyncQueueItem } from './db.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 + }> + gpsTracks: Array<{ + entryId: string + encryptedData: string + iv: string + tag: string + updatedAt: string + }> + } + counts: { + entries: number + photos: number + crews: number + gpsTracks: number + hasYacht: boolean + hasDeviation: boolean + } +} + +export interface LogbookBackupPreview { + title: string + exportedAt: string + sourceLogbookId: string + counts: LogbookBackupFile['counts'] +} + +async function deriveBackupPassphraseKey(passphrase: string): Promise { + const encoder = new TextEncoder() + const passphraseBytes = encoder.encode(passphrase.trim()) + const saltBytes = encoder.encode('KapteinsDaagbokBackupFileSalt_v1') + + const baseKey = await window.crypto.subtle.importKey( + 'raw', + passphraseBytes, + { name: 'PBKDF2' }, + false, + ['deriveKey'] + ) + + return window.crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: saltBytes, + iterations: 100_000, + hash: 'SHA-256' + }, + baseKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ) +} + +async function wrapLogbookKey(logbookKey: ArrayBuffer, passphrase: string) { + const key = await deriveBackupPassphraseKey(passphrase) + return encryptBuffer(logbookKey, key) +} + +async function unwrapLogbookKey( + wrapped: LogbookBackupFile['logbookKey'], + passphrase: string +): Promise { + const key = await deriveBackupPassphraseKey(passphrase) + return decryptBuffer(wrapped.ciphertext, wrapped.iv, wrapped.tag, key) +} + +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, + tag: string, + extra?: Record +): string { + return JSON.stringify({ + ciphertext: encryptedData, + iv, + tag, + ...extra + }) +} + +async function collectLogbookPayloads(logbookId: string): Promise { + const [yacht, deviation, crews, entries, photos, 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.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 + })), + 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 })), + gpsTracks: backup.payloads.gpsTracks.map((t) => ({ ...t })) + } + } +} + +async function queueRestoredLogbookForSync( + logbookId: string, + encryptedTitle: string, + logbookKey: ArrayBuffer, + payloads: LogbookBackupFile['payloads'] +): Promise { + const masterKey = getActiveMasterKey() + if (!masterKey) throw new Error('Master key not found') + + const aesMasterKey = await window.crypto.subtle.importKey( + 'raw', + masterKey, + { name: 'AES-GCM' }, + false, + ['encrypt'] + ) + const encryptedKey = await encryptBuffer(logbookKey, aesMasterKey) + const now = new Date().toISOString() + + const items: Omit[] = [ + { + action: 'create', + type: 'logbook', + payloadId: logbookId, + logbookId, + data: JSON.stringify({ + encryptedTitle, + encryptedKey: encryptedKey.ciphertext, + iv: encryptedKey.iv, + tag: encryptedKey.tag + }), + updatedAt: now + } + ] + + if (payloads.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 + }) + } + + if (payloads.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 + }) + } + + for (const crew of payloads.crews) { + items.push({ + action: 'create', + type: 'crew', + payloadId: crew.payloadId, + logbookId, + data: encryptedPayloadData(crew.encryptedData, crew.iv, crew.tag), + updatedAt: crew.updatedAt + }) + } + + for (const entry of payloads.entries) { + items.push({ + action: 'create', + type: 'entry', + payloadId: entry.payloadId, + logbookId, + data: encryptedPayloadData(entry.encryptedData, entry.iv, entry.tag), + updatedAt: entry.updatedAt + }) + } + + for (const photo of payloads.photos) { + items.push({ + action: 'create', + type: 'photo', + payloadId: photo.payloadId, + logbookId, + data: encryptedPayloadData(photo.encryptedData, photo.iv, photo.tag, { + entryId: photo.entryId + }), + updatedAt: photo.updatedAt + }) + } + + for (const track of payloads.gpsTracks) { + items.push({ + action: 'create', + type: 'gpsTrack', + payloadId: track.entryId, + logbookId, + data: encryptedPayloadData(track.encryptedData, track.iv, track.tag), + updatedAt: track.updatedAt + }) + } + + await db.syncQueue.bulkPut(items) +} + +async function writeBackupToDexie( + logbookId: string, + backup: LogbookBackupFile, + logbookKey: ArrayBuffer +): Promise { + const { logbook, payloads } = backup + + await db.logbooks.put({ + id: logbookId, + encryptedTitle: logbook.encryptedTitle, + updatedAt: logbook.updatedAt, + isSynced: 0, + isShared: 0, + isDemo: logbook.isDemo ? 1 : 0 + }) + + await saveLogbookKey(logbookId, logbookKey) + + if (payloads.yacht) { + await db.yachts.put({ + logbookId, + encryptedData: payloads.yacht.encryptedData, + iv: payloads.yacht.iv, + tag: payloads.yacht.tag, + updatedAt: payloads.yacht.updatedAt + }) + } + + if (payloads.deviation) { + await db.deviations.put({ + logbookId, + encryptedData: payloads.deviation.encryptedData, + iv: payloads.deviation.iv, + tag: payloads.deviation.tag, + updatedAt: payloads.deviation.updatedAt + }) + } + + if (payloads.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 + })) + ) + } + + if (payloads.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 + })) + ) + } + + if (payloads.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 + })) + ) + } + + if (payloads.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 + })) + ) + } +} + +export async function exportLogbookBackup( + logbookId: string, + passphrase: string +): Promise<{ blob: Blob; filename: string; backup: LogbookBackupFile }> { + if (!passphrase.trim() || passphrase.length < 8) { + throw new Error('BACKUP_PASSPHRASE_TOO_SHORT') + } + + const logbook = await db.logbooks.get(logbookId) + if (!logbook || logbook.isShared === 1) { + throw new Error('BACKUP_NOT_OWNER') + } + + if (navigator.onLine) { + await syncLogbook(logbookId).catch((err) => { + console.warn('Pre-backup sync failed, exporting local data:', err) + }) + } + + const logbookKey = (await getLogbookKey(logbookId)) ?? (await ensureLogbookKey(logbookId)) + const payloads = await collectLogbookPayloads(logbookId) + const wrappedKey = await wrapLogbookKey(logbookKey, passphrase) + + const backup: LogbookBackupFile = { + format: BACKUP_FORMAT, + version: BACKUP_VERSION, + 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, + crews: payloads.crews.length, + gpsTracks: payloads.gpsTracks.length, + hasYacht: !!payloads.yacht, + hasDeviation: !!payloads.deviation + } + } + + 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' }) + + return { blob, filename, backup } +} + +export async function parseLogbookBackupFile(file: File): Promise { + const text = await file.text() + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + throw new Error('BACKUP_INVALID_JSON') + } + + if (!isBackupFile(parsed)) { + throw new Error('BACKUP_INVALID_FORMAT') + } + + return parsed +} + +export async function previewLogbookBackup( + backup: LogbookBackupFile, + 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) + + return { + title, + exportedAt: backup.exportedAt, + sourceLogbookId: backup.logbook.id, + counts: backup.counts + } +} + +export interface RestoreLogbookOptions { + overwrite?: boolean + assignNewId?: boolean +} + +export async function restoreLogbookBackup( + backup: LogbookBackupFile, + passphrase: string, + options: RestoreLogbookOptions = {} +): Promise<{ logbookId: string; title: string }> { + if (!getActiveMasterKey()) { + 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 + ) + + let targetId = backup.logbook.id + const existing = await db.logbooks.get(targetId) + + if (existing && !options.overwrite && !options.assignNewId) { + throw new Error('BACKUP_ID_CONFLICT') + } + + if (existing && options.overwrite) { + await deleteLocalLogbookCache(targetId) + } + + if (options.assignNewId || (existing && !options.overwrite)) { + targetId = crypto.randomUUID() + } + + const prepared = targetId === backup.logbook.id ? backup : remapBackup(backup, targetId) + + await writeBackupToDexie(targetId, prepared, logbookKey) + await queueRestoredLogbookForSync( + targetId, + prepared.logbook.encryptedTitle, + logbookKey, + prepared.payloads + ) + + if (navigator.onLine) { + await syncLogbook(targetId).catch((err) => { + console.warn('Post-restore sync failed, data saved locally:', err) + }) + } + + return { logbookId: targetId, title } +} + +export function downloadBackupBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + anchor.click() + URL.revokeObjectURL(url) +} diff --git a/docs/plausible-events.md b/docs/plausible-events.md index bb71708..850661a 100644 --- a/docs/plausible-events.md +++ b/docs/plausible-events.md @@ -32,6 +32,8 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri | CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — | | CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — | | Photo Uploaded | Foto hochgeladen (`PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` | +| 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` | ## Bewusst nicht getrackt @@ -47,6 +49,7 @@ Empfohlene Goal-Ketten für Auswertung: 2. **Onboarding:** Account Created → Onboarding Tour Completed (vs. Onboarding Tour Skipped) 3. **Kollaboration:** Invite Generated → Invite Accepted 4. **Export:** Travel Day Saved → PDF Exported / CSV Exported +5. **Datensicherung:** Backup Exported → Backup Restored ## Entwicklung