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 <cursoragent@cursor.com>
14 KiB
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)
- Logbuch mit 50 Fotos + 20 Voice-Memos exportieren → Datei
.daagbokdeutlich kleiner als vergleichbares v1-JSON (Kompression + kein Pretty-Print + binäre Blobs). - Restore auf frischem Gerät (eingeloggt) → alle Einträge, Medien abspielbar, Crew/Vessel-Selection vorhanden.
- Falsches Passphrase →
BACKUP_WRONG_PASSPHRASE(wie heute). - Beschädigtes ZIP / fehlende Manifest →
BACKUP_INVALID_FORMAT. - v1-Datei (
version: 1) →BACKUP_VERSION_UNSUPPORTEDmit 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 (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)
{
"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
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
- Pre-sync: wie heute
syncLogbook(logbookId)wenn online. - Sammlung:
collectLogbookPayloadsV2()— alle Tabellen aus Abschnitt B-01; Batches à 20 für Medien. - ZIP-Erzeugung:
fflatezipSyncoderzipmit Streaming-Callback; nicht das gesamte Archiv als ein Array im RAM, wenntotalUncompressedBytes > 80_000_000→ Warnung in UI + ggf.requestIdleCallbackzwischen Blobs. - Fortschritt:
onProgress({ phase, current, total, bytes }). - 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)
- ZIP öffnen (nur zentrales Directory lesen —
fflateunzip). manifest.jsonparsen →version === 2prüfen.key.encladen → Passphrase → Logbuch-Key.logbook.meta.json→ Titel entschlüsseln.- Counts aus Manifest anzeigen — keine Medien-Blobs dekodieren.
Restore
- Vollständig entpacken in temporäre Struktur (Object-URLs /
Map<path, Uint8Array>) — bei >150 MB Warnung. writeBackupToDexieV2()— analog v1, aber aus.encBytes.queueRestoredLogbookForSync()— unveränderte Sync-Queue-Semantik.listCacheauf 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 |
dexieRecordToEncBytes, encBytesToDexieFields |
client/src/services/logbookBackup/manifest.ts |
Typen BackupManifestV2, Validierung |
client/src/services/logbookBackup/zipArchive.ts |
ZIP pack/unpack mit fflate |
client/src/services/logbookBackup.ts |
Öffentliche API: exportLogbookBackup, parseLogbookBackupFile, preview…, restore… — ruft v2 intern auf |
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 |
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)
encBlob+manifest+zipArchiveModule- Export/Import/Preview/Restore auf v2
- Alle Dexie-Tabellen inkl. crew/vessel selection + nmeaArchives
- UI:
.daagbok, FehlerBACKUP_VERSION_UNSUPPORTEDfür v1-JSON - Tests:
encBlobunit tests
Phase 2 — UX & Robustheit
- Export-Fortschritt
- Größen-Warnung vor Import (>150 MB)
onProgresswä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)
- Schwellwert Speicher-Warnung: 150 MB uncompressed — anpassen nach ersten Real-Logbüchern?
- NMEA-Archive: oft groß — eigenes Subdirectory ausreichend; später ggf. „NMEA nicht ins Backup“ als Opt-out?
- Geteilte Logbücher (
isShared === 1): Export weiterhin nur Owner — unverändert.
Implementierung: client/src/services/logbookBackup/, API in client/src/services/logbookBackup.ts.