import { db, type LocalLogbook } from './db.js' import { getActiveMasterKey } from './auth.js' import { encryptJson, decryptJson, encryptBuffer, decryptBuffer } from './crypto.js' import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js' import { PlausibleEvents, trackPlausibleEvent } from './analytics.js' import { apiFetch } from './api.js' import { clearDemoLogbookRefs, getDemoLogbookStorageKey } from './demoLogbook.js' const API_BASE = '/api/logbooks' export type LogbookAccessRole = 'OWNER' | 'READ' | 'WRITE' export type CollaborationRole = 'READ' | 'WRITE' /** Validates server/cached collaboration role; warns and falls back to WRITE if missing or invalid. */ export function parseCollaborationRole(role: unknown, context: string): CollaborationRole { if (role === 'READ' || role === 'WRITE') { return role } if (role === undefined || role === null || role === '') { console.warn(`[collaboration] Missing role in ${context}; defaulting to WRITE.`) } else { console.warn(`[collaboration] Unexpected role in ${context}:`, role, '— defaulting to WRITE.') } return 'WRITE' } export interface DecryptedLogbook { id: string title: string updatedAt: string isSynced: boolean isShared: boolean accessRole: LogbookAccessRole isDemo?: boolean } // Helper to decrypt a logbook's title using the active logbook key or master key export async function decryptLogbookTitle(logbookId: string, encryptedTitle: string): Promise { const masterKey = getActiveMasterKey() if (!masterKey) { throw new Error('Master key not found. User must log in.') } try { const parsed = JSON.parse(encryptedTitle) const key = await getLogbookKey(logbookId) || masterKey const decrypted = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, key) return decrypted } catch (error) { console.error('Failed to decrypt logbook title:', error) return '[Decryption Failed]' } } // Fetch logbooks from the server (if online) and update local cache, falling back to cache if offline export async function fetchLogbooks(): Promise { const userId = localStorage.getItem('active_userid') if (!userId) { throw new Error('User not authenticated') } const masterKey = getActiveMasterKey() if (!masterKey) { throw new Error('Master key not found. User must log in.') } if (navigator.onLine) { try { const response = await apiFetch(API_BASE, { method: 'GET' }) if (response.ok) { const serverLogbooks = await response.json() // Decrypt and save logbook keys locally if they exist for (const lb of serverLogbooks) { const isShared = lb.userId !== userId const encryptedKeyStr = isShared ? lb.collaborators?.[0]?.encryptedLogbookKey : (lb.encryptedKey || lb.collaborators?.[0]?.encryptedLogbookKey) const ivStr = isShared ? lb.collaborators?.[0]?.iv : (lb.iv || lb.collaborators?.[0]?.iv) const tagStr = isShared ? lb.collaborators?.[0]?.tag : (lb.tag || lb.collaborators?.[0]?.tag) if (encryptedKeyStr && ivStr && tagStr) { try { const aesKey = await window.crypto.subtle.importKey( 'raw', masterKey, { name: 'AES-GCM' }, false, ['decrypt'] ) const decryptedKey = await decryptBuffer(encryptedKeyStr, ivStr, tagStr, aesKey) await saveLogbookKey(lb.id, decryptedKey) } catch (err) { console.error(`Failed to decrypt and save logbook key for logbook ${lb.id}:`, err) } } else if (isShared) { console.warn(`Shared logbook ${lb.id} is missing collaboration key on server`) } } // Clear local cache for any logbooks that are no longer on the server const serverIds = new Set(serverLogbooks.map((lb: any) => lb.id)) const localLogbooksArray = await db.logbooks.toArray() for (const lb of localLogbooksArray) { if (lb.isSynced === 1 && !serverIds.has(lb.id)) { await deleteLocalLogbookCache(lb.id) } } // Update Dexie database cache const localById = new Map(localLogbooksArray.map((lb) => [lb.id, lb])) const localLogbooks: LocalLogbook[] = serverLogbooks.map((lb: any) => { const isShared = lb.userId !== userId return { id: lb.id, encryptedTitle: lb.encryptedTitle, updatedAt: lb.updatedAt || new Date().toISOString(), isSynced: 1, isShared: isShared ? 1 : 0, collaborationRole: isShared ? parseCollaborationRole(lb.collaborators?.[0]?.role, `fetch logbook ${lb.id}`) : undefined, isDemo: localById.get(lb.id)?.isDemo } }) // Clear existing cache for this user and insert new ones await db.logbooks.bulkPut(localLogbooks) } } catch (error) { console.warn('Network request failed. Reading logbooks from offline cache:', error) } } // Retrieve all from Dexie cache const cachedLogbooks = await db.logbooks.toArray() // Decrypt titles const decrypted: DecryptedLogbook[] = [] for (const lb of cachedLogbooks) { const title = await decryptLogbookTitle(lb.id, lb.encryptedTitle) decrypted.push({ id: lb.id, title, updatedAt: lb.updatedAt, isSynced: lb.isSynced === 1, isShared: lb.isShared === 1, accessRole: lb.isShared === 1 ? parseCollaborationRole(lb.collaborationRole, `cached logbook ${lb.id}`) : 'OWNER', isDemo: lb.isDemo === 1 }) } return decrypted } // Create a new logbook. Encrypts the title and registers locally + on server export async function createLogbook(title: string): Promise { const userId = localStorage.getItem('active_userid') if (!userId) { throw new Error('User not authenticated') } const masterKey = getActiveMasterKey() if (!masterKey) { throw new Error('Master key not found. User must log in.') } // 1. Generate Logbook Key and save it locally const logbookKey = generateLogbookKey() await saveLogbookKey(localIdForCreate(), logbookKey) // Generate temporary ID to bind to key const localId = tempUUID const now = new Date().toISOString() // 2. Encrypt logbook key with user's master key const aesMasterKey = await window.crypto.subtle.importKey( 'raw', masterKey, { name: 'AES-GCM' }, false, ['encrypt'] ) const encryptedKey = await encryptBuffer(logbookKey, aesMasterKey) // 3. E2E Encrypt title using the Logbook Key const encrypted = await encryptJson(title, logbookKey) const encryptedTitleStr = JSON.stringify(encrypted) const payloadData = { encryptedTitle: encryptedTitleStr, encryptedKey: encryptedKey.ciphertext, iv: encryptedKey.iv, tag: encryptedKey.tag } if (navigator.onLine) { try { const response = await apiFetch(API_BASE, { method: 'POST', body: JSON.stringify({ id: localId, ...payloadData }) }) if (response.ok) { const serverLb = await response.json() if (serverLb.id !== localId) { await saveLogbookKey(serverLb.id, logbookKey) await db.logbookKeys.delete(localId) } await db.logbooks.put({ id: serverLb.id, encryptedTitle: serverLb.encryptedTitle, updatedAt: serverLb.updatedAt, isSynced: 1, isShared: 0 }) return { id: serverLb.id, title, updatedAt: serverLb.updatedAt, isSynced: true, isShared: false, accessRole: 'OWNER' } } } catch (error) { console.warn('Failed to save logbook to server, saving locally instead:', error) } } // If offline or request failed, store locally as unsynced and add to queue await db.logbooks.put({ id: localId, encryptedTitle: encryptedTitleStr, updatedAt: now, isSynced: 0, isShared: 0 }) await db.syncQueue.put({ action: 'create', type: 'logbook', payloadId: localId, logbookId: localId, data: JSON.stringify(payloadData), updatedAt: now }) return { id: localId, title, updatedAt: now, isSynced: false, isShared: false, accessRole: 'OWNER' } } // Temporary UUID helpers to preserve single localId assignment across generation let tempUUID = '' function localIdForCreate(): string { tempUUID = window.crypto.randomUUID() return tempUUID } // Perform cascading deletion on all local Dexie tables for a specific logbook ID export async function deleteLocalLogbookCache(id: string): Promise { await db.logbooks.delete(id) await db.yachts.where({ logbookId: id }).delete() await db.crews.where({ logbookId: id }).delete() await db.deviations.where({ logbookId: id }).delete() await db.entries.where({ logbookId: id }).delete() await db.photos.where({ logbookId: id }).delete() await db.gpsTracks.where({ logbookId: id }).delete() await db.syncQueue.where({ logbookId: id }).delete() await db.logbookKeys.where({ logbookId: id }).delete() } // Delete a logbook and all associated payloads locally and on server export async function deleteLogbook(id: string): Promise { const userId = localStorage.getItem('active_userid') if (!userId) { throw new Error('User not authenticated') } if (navigator.onLine) { try { const response = await apiFetch(`${API_BASE}/${id}`, { method: 'DELETE' }) if (!response.ok) { console.warn('Server deletion failed or was rejected') } } catch (error) { console.warn('Server delete request failed, queuing locally:', error) await db.syncQueue.put({ action: 'delete', type: 'logbook', payloadId: id, logbookId: id, data: '', updatedAt: new Date().toISOString() }) } } else { await db.syncQueue.put({ action: 'delete', type: 'logbook', payloadId: id, logbookId: id, data: '', updatedAt: new Date().toISOString() }) } // Perform local cascading cleanup await deleteLocalLogbookCache(id) if (userId && id === localStorage.getItem(getDemoLogbookStorageKey(userId))) { clearDemoLogbookRefs(userId, id) } trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED) } // Update the title of a logbook. Encrypts the title and updates locally + on server export async function updateLogbookTitle(id: string, newTitle: string): Promise { const userId = localStorage.getItem('active_userid') if (!userId) { throw new Error('User not authenticated') } const masterKey = getActiveMasterKey() if (!masterKey) { throw new Error('Master key not found. User must log in.') } const logbookKey = await getLogbookKey(id) || masterKey // E2E Encrypt the new title using the Logbook Key (or master key fallback) const encrypted = await encryptJson(newTitle, logbookKey) const encryptedTitleStr = JSON.stringify(encrypted) const now = new Date().toISOString() const payloadData = { encryptedTitle: encryptedTitleStr } if (navigator.onLine) { try { const response = await apiFetch(`${API_BASE}/${id}`, { method: 'PUT', body: JSON.stringify(payloadData) }) if (response.ok) { // Update local IndexedDB cache as synced await db.logbooks.update(id, { encryptedTitle: encryptedTitleStr, updatedAt: now, isSynced: 1 }) return } } catch (error) { console.warn('Failed to update logbook on server, saving locally instead:', error) } } // If offline or request failed, store locally as unsynced and add to queue await db.logbooks.update(id, { encryptedTitle: encryptedTitleStr, updatedAt: now, isSynced: 0 }) await db.syncQueue.put({ action: 'update', type: 'logbook', payloadId: id, logbookId: id, data: JSON.stringify(payloadData), updatedAt: now }) }