6f28ea0b16
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>
338 lines
14 KiB
Markdown
338 lines
14 KiB
Markdown
# 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<path, Uint8Array>`) — 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).*
|