b317be5ae1
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 <cursoragent@cursor.com>
602 lines
15 KiB
TypeScript
602 lines
15 KiB
TypeScript
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<CryptoKey> {
|
|
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<ArrayBuffer> {
|
|
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<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,
|
|
tag: string,
|
|
extra?: Record<string, string>
|
|
): string {
|
|
return JSON.stringify({
|
|
ciphertext: encryptedData,
|
|
iv,
|
|
tag,
|
|
...extra
|
|
})
|
|
}
|
|
|
|
async function collectLogbookPayloads(logbookId: string): Promise<LogbookBackupFile['payloads']> {
|
|
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<void> {
|
|
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<SyncQueueItem, 'id'>[] = [
|
|
{
|
|
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<void> {
|
|
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<LogbookBackupFile> {
|
|
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<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)
|
|
|
|
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)
|
|
}
|