/** * Client-Side Backup Manager — encrypted backup push/pull to server. * * Reads already-encrypted blobs from IndexedDB (no double-encryption needed). * Compares local manifest vs server manifest, uploads only changed docs. * On restore: downloads all blobs, writes to IndexedDB. */ import type { DocumentId } from './document'; import type { EncryptedDocStore } from './storage'; export interface BackupResult { uploaded: number; skipped: number; errors: string[]; } export interface RestoreResult { downloaded: number; errors: string[]; } export interface BackupStatus { enabled: boolean; lastBackupAt: string | null; docCount: number; totalBytes: number; } interface ServerManifestEntry { docId: string; hash: string; size: number; updatedAt: string; } interface ServerManifest { spaceSlug: string; entries: ServerManifestEntry[]; updatedAt: string; } export class BackupSyncManager { #spaceId: string; #store: EncryptedDocStore; #baseUrl: string; #autoBackupTimer: ReturnType | null = null; constructor(spaceId: string, store: EncryptedDocStore, baseUrl?: string) { this.#spaceId = spaceId; this.#store = store; this.#baseUrl = baseUrl || ''; } /** * Push backup — upload changed docs to server. * Reads encrypted blobs from IndexedDB and compares with server manifest. */ async pushBackup(): Promise { const result: BackupResult = { uploaded: 0, skipped: 0, errors: [] }; const token = this.#getAuthToken(); if (!token) { result.errors.push('Not authenticated'); return result; } try { // Get server manifest const serverManifest = await this.#fetchManifest(token); const serverHashes = new Map( serverManifest.entries.map((e) => [e.docId, e.hash]), ); // List all local docs for this space const localDocs = await this.#store.listAll(); const spaceDocs = localDocs.filter((id) => id.startsWith(`${this.#spaceId}:`), ); for (const docId of spaceDocs) { try { const blob = await this.#store.loadRaw(docId); if (!blob) continue; // Hash local blob and compare const localHash = await this.#hashBlob(blob); if (serverHashes.get(docId) === localHash) { result.skipped++; continue; } // Upload await this.#uploadBlob(token, docId, blob); result.uploaded++; } catch (e) { result.errors.push(`${docId}: ${e}`); } } // Update last backup time try { localStorage.setItem( `rspace:${this.#spaceId}:last_backup`, new Date().toISOString(), ); } catch { /* SSR */ } } catch (e) { result.errors.push(`Manifest fetch failed: ${e}`); } return result; } /** * Pull restore — download all blobs from server to IndexedDB. * Used when setting up a new device or recovering data. */ async pullRestore(): Promise { const result: RestoreResult = { downloaded: 0, errors: [] }; const token = this.#getAuthToken(); if (!token) { result.errors.push('Not authenticated'); return result; } try { const manifest = await this.#fetchManifest(token); for (const entry of manifest.entries) { try { const blob = await this.#downloadBlob( token, entry.docId, ); if (blob) { await this.#store.saveImmediate( entry.docId as DocumentId, blob, ); result.downloaded++; } } catch (e) { result.errors.push(`${entry.docId}: ${e}`); } } } catch (e) { result.errors.push(`Manifest fetch failed: ${e}`); } return result; } /** * Get current backup status. */ async getStatus(): Promise { let lastBackupAt: string | null = null; let enabled = false; try { lastBackupAt = localStorage.getItem( `rspace:${this.#spaceId}:last_backup`, ); enabled = localStorage.getItem('encryptid_backup_enabled') === 'true'; } catch { /* SSR */ } const token = this.#getAuthToken(); if (!token || !enabled) { return { enabled, lastBackupAt, docCount: 0, totalBytes: 0 }; } try { const manifest = await this.#fetchManifest(token); const totalBytes = manifest.entries.reduce( (sum, e) => sum + e.size, 0, ); return { enabled, lastBackupAt, docCount: manifest.entries.length, totalBytes, }; } catch { return { enabled, lastBackupAt, docCount: 0, totalBytes: 0 }; } } /** * Enable/disable periodic auto-backup. */ setAutoBackup(enabled: boolean, intervalMs = 5 * 60 * 1000): void { if (this.#autoBackupTimer) { clearInterval(this.#autoBackupTimer); this.#autoBackupTimer = null; } if (enabled) { this.#autoBackupTimer = setInterval(() => { this.pushBackup().catch((e) => console.error('[BackupSync] Auto-backup failed:', e), ); }, intervalMs); } } destroy(): void { this.setAutoBackup(false); } // ---- Private ---- #getAuthToken(): string | null { try { const sess = JSON.parse( localStorage.getItem('encryptid_session') || '', ); return sess?.accessToken || null; } catch { return null; } } async #fetchManifest(token: string): Promise { const resp = await fetch( `${this.#baseUrl}/api/backup/${encodeURIComponent(this.#spaceId)}`, { headers: { Authorization: `Bearer ${token}` } }, ); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); return resp.json(); } async #uploadBlob( token: string, docId: string, blob: Uint8Array, ): Promise { const body = new Uint8Array(blob).buffer as ArrayBuffer; const resp = await fetch( `${this.#baseUrl}/api/backup/${encodeURIComponent(this.#spaceId)}/${encodeURIComponent(docId)}`, { method: 'PUT', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream', }, body, }, ); if (!resp.ok) throw new Error(`Upload failed: HTTP ${resp.status}`); } async #downloadBlob( token: string, docId: string, ): Promise { const resp = await fetch( `${this.#baseUrl}/api/backup/${encodeURIComponent(this.#spaceId)}/${encodeURIComponent(docId)}`, { headers: { Authorization: `Bearer ${token}` } }, ); if (resp.status === 404) return null; if (!resp.ok) throw new Error(`Download failed: HTTP ${resp.status}`); const buf = await resp.arrayBuffer(); return new Uint8Array(buf); } async #hashBlob(blob: Uint8Array): Promise { const buf = new Uint8Array(blob).buffer as ArrayBuffer; const hash = await crypto.subtle.digest('SHA-256', buf); return Array.from(new Uint8Array(hash)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } }