import { formatAppDecimal } from '../utils/numberFormat.js' 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' 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 { BACKUP_FORMAT, BACKUP_VERSION } export type { BackupExportProgress, BackupManifestCounts, BackupManifestV2 } export interface LogbookBackupPreview { title: string exportedAt: string sourceLogbookId: string 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(BACKUP_PASSPHRASE_SALT) 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 unwrapLogbookKeyFromEnc( keyEnc: Uint8Array, passphrase: string ): Promise { 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 encryptedPayloadData( encryptedData: string, iv: string, tag: string, extra?: Record ): string { return JSON.stringify({ ciphertext: encryptedData, iv, tag, ...extra }) } async function queueRestoredLogbookForSync( logbookId: string, encryptedTitle: string, logbookKey: ArrayBuffer, manifest: BackupManifestV2, files: Record ): 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 } ] 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(yacht.encryptedData, yacht.iv, yacht.tag), updatedAt: now }) } const deviation = readFields(manifest.files.deviation) if (deviation) { items.push({ action: 'update', type: 'deviation', payloadId: logbookId, logbookId, data: encryptedPayloadData(deviation.encryptedData, deviation.iv, deviation.tag), updatedAt: now }) } 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(f!.encryptedData, f!.iv, f!.tag), updatedAt: crew.updatedAt }) } for (const entry of manifest.files.entries) { const f = readFields(entry.path) items.push({ action: 'create', type: 'entry', payloadId: entry.payloadId, logbookId, data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag), updatedAt: entry.updatedAt }) } for (const photo of manifest.files.photos) { const f = readFields(photo.path) items.push({ action: 'create', type: 'photo', payloadId: photo.payloadId, logbookId, data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag, { entryId: photo.entryId }), updatedAt: photo.updatedAt }) } for (const voice of manifest.files.voiceMemos) { const f = readFields(voice.path) items.push({ action: 'create', type: 'voiceMemo', payloadId: voice.payloadId, logbookId, data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag, { entryId: voice.entryId }), updatedAt: voice.updatedAt }) } for (const track of manifest.files.gpsTracks) { const f = readFields(track.path) items.push({ action: 'create', type: 'gpsTrack', payloadId: track.entryId, logbookId, data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag), updatedAt: track.updatedAt }) } await db.syncQueue.bulkPut(items) } async function writeBackupToDexie( logbookId: string, logbookMeta: LogbookMetaJson, logbookKey: ArrayBuffer, manifest: BackupManifestV2, files: Record ): Promise { await db.logbooks.put({ id: logbookId, encryptedTitle: logbookMeta.encryptedTitle, updatedAt: logbookMeta.updatedAt, isSynced: 0, isShared: 0, isDemo: logbookMeta.isDemo ? 1 : 0 }) await saveLogbookKey(logbookId, logbookKey) 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: yacht.encryptedData, iv: yacht.iv, tag: yacht.tag, updatedAt: logbookMeta.updatedAt }) } const deviation = readFields(manifest.files.deviation) if (deviation) { await db.deviations.put({ logbookId, encryptedData: deviation.encryptedData, iv: deviation.iv, tag: deviation.tag, updatedAt: logbookMeta.updatedAt }) } 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( 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 (manifest.files.entries.length > 0) { await db.entries.bulkPut( 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 (manifest.files.photos.length > 0) { await db.photos.bulkPut( 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 } }) ) } if (manifest.files.voiceMemos.length > 0) { await db.voiceMemos.bulkPut( 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 (manifest.files.gpsTracks.length > 0) { await db.gpsTracks.bulkPut( 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, options: ExportLogbookBackupOptions = {} ): Promise<{ blob: Blob; filename: string; manifest: BackupManifestV2 }> { 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) }) } options.onProgress?.({ phase: 'collect', current: 0, total: 1, bytesPacked: 0 }) const collected = await collectLogbookBackupData(logbookId) const logbookKey = (await getLogbookKey(logbookId)) ?? (await ensureLogbookKey(logbookId)) const wrapped = await wrapLogbookKey(logbookKey, passphrase) const keyEnc = encBytesFromDexieFields({ encryptedData: wrapped.ciphertext, iv: wrapped.iv, tag: wrapped.tag }) const { zipBytes, manifest } = buildArchiveFromCollected(collected, keyEnc, { exportedAt: new Date().toISOString(), 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` const blob = new Blob([zipBytes.slice()], { type: 'application/zip' }) return { blob, filename, manifest } } function detectLegacyJsonV1(text: string): boolean { const trimmed = text.trimStart() if (!trimmed.startsWith('{')) return false try { const parsed = JSON.parse(trimmed) as { format?: string; version?: number } return parsed.format === BACKUP_FORMAT && parsed.version === 1 } catch { return false } } export async function parseLogbookBackupFile(file: File): Promise { const buffer = await file.arrayBuffer() const bytes = new Uint8Array(buffer) 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: ParsedLogbookBackup, passphrase: string ): Promise { 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.manifest.exportedAt, sourceLogbookId: backup.manifest.logbookId, counts: backup.manifest.counts, totalUncompressedBytes: backup.manifest.totalUncompressedBytes } } export interface RestoreLogbookOptions { overwrite?: boolean assignNewId?: boolean } export async function restoreLogbookBackup( backup: ParsedLogbookBackup, passphrase: string, options: RestoreLogbookOptions = {} ): Promise<{ logbookId: string; title: string }> { if (!getActiveMasterKey()) { throw new Error('BACKUP_NOT_AUTHENTICATED') } 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.manifest.logbookId 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) } let prepared = backup if (options.assignNewId || (existing && !options.overwrite)) { targetId = crypto.randomUUID() prepared = remapParsedBackup(backup, targetId) } const finalMeta = JSON.parse( readTextFile(prepared.files, prepared.manifest.files.logbook) ) as LogbookMetaJson await writeBackupToDexie( targetId, finalMeta, logbookKey, prepared.manifest, prepared.files ) await queueRestoredLogbookForSync( targetId, finalMeta.encryptedTitle, logbookKey, prepared.manifest, prepared.files ) 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) } /** Human-readable size for UI warnings. */ export function formatBackupBytes(bytes: number): string { const fmt = (n: number) => formatAppDecimal(n, { minimumFractionDigits: 1, maximumFractionDigits: 1 }) if (bytes < 1024) return `${bytes} B` if (bytes < 1024 * 1024) return `${fmt(bytes / 1024)} KB` return `${fmt(bytes / (1024 * 1024))} MB` } export const BACKUP_SIZE_WARN_BYTES = 50_000_000 export const BACKUP_SIZE_CONFIRM_BYTES = 150_000_000