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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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<string, Uint8Array>
|
||||
}
|
||||
|
||||
export interface ExportLogbookBackupOptions {
|
||||
onProgress?: (progress: BackupExportProgress) => void
|
||||
}
|
||||
|
||||
const BACKUP_PASSPHRASE_SALT = 'KapteinsDaagbokBackupFileSalt_v1'
|
||||
|
||||
async function deriveBackupPassphraseKey(passphrase: string): Promise<CryptoKey> {
|
||||
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<ArrayBuffer> {
|
||||
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<LogbookBackupFile>
|
||||
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<LogbookBackupFile['payloads']> {
|
||||
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<string, Uint8Array>
|
||||
): Promise<void> {
|
||||
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<string, Uint8Array>
|
||||
): Promise<void> {
|
||||
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<LogbookBackupFile> {
|
||||
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<ParsedLogbookBackup> {
|
||||
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<LogbookBackupPreview> {
|
||||
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
|
||||
|
||||
@@ -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<DexieEncFields & { payloadId: string; updatedAt: string }>
|
||||
entries: Array<DexieEncFields & { payloadId: string; updatedAt: string }>
|
||||
photos: Array<DexieEncFields & { payloadId: string; entryId: string; updatedAt: string }>
|
||||
voiceMemos: Array<DexieEncFields & { payloadId: string; entryId: string; updatedAt: string }>
|
||||
gpsTracks: Array<DexieEncFields & { entryId: string; updatedAt: string }>
|
||||
nmeaArchives: Array<DexieEncFields & { entryId: string; updatedAt: string }>
|
||||
}
|
||||
|
||||
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<CollectedBackupData> {
|
||||
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<string, Uint8Array>,
|
||||
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<string, Uint8Array> = {}
|
||||
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 }
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<BackupManifestV2>
|
||||
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)
|
||||
}
|
||||
@@ -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<string, Uint8Array>): Uint8Array {
|
||||
return zipSync(files, { level: ZIP_LEVEL })
|
||||
}
|
||||
|
||||
export function unzipArchive(data: Uint8Array): Record<string, Uint8Array> {
|
||||
try {
|
||||
return unzipSync(data)
|
||||
} catch {
|
||||
throw new Error('BACKUP_INVALID_ARCHIVE')
|
||||
}
|
||||
}
|
||||
|
||||
export function readManifestFromArchive(
|
||||
files: Record<string, Uint8Array>
|
||||
): 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<string, Uint8Array>, 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<string, Uint8Array>, 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
|
||||
}
|
||||
Reference in New Issue
Block a user