Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat: encrypted document backup export/import (#107)' (#118) from feat/encrypted-backup into main

scott badffb64 bf95fa20

+312
+167
src/backup.ts
··· 1 + /** 2 + * Encrypted Backup Archive — export/import documents as JSON bundles. 3 + * 4 + * The archive contains encrypted snapshots and metadata. Each document's 5 + * snapshot is already AES-256-GCM encrypted (the server never sees plaintext), 6 + * so the archive itself is safe to store anywhere. 7 + * 8 + * Format: JSON with magic header for validation. 9 + */ 10 + 11 + export const BACKUP_MAGIC = 'TOOLS-BACKUP'; 12 + export const BACKUP_VERSION = 1; 13 + 14 + export interface BackupDocEntry { 15 + id: string; 16 + type: 'doc' | 'sheet'; 17 + name_encrypted: string | null; 18 + snapshot: string; // base64-encoded encrypted binary 19 + created_at: string; 20 + updated_at: string; 21 + } 22 + 23 + export interface BackupManifest { 24 + magic: string; 25 + version: number; 26 + created_at: string; 27 + documents: BackupDocEntry[]; 28 + } 29 + 30 + /** 31 + * Create a backup manifest from document entries. 32 + */ 33 + export function createBackupManifest(documents: BackupDocEntry[]): BackupManifest { 34 + return { 35 + magic: BACKUP_MAGIC, 36 + version: BACKUP_VERSION, 37 + created_at: new Date().toISOString(), 38 + documents, 39 + }; 40 + } 41 + 42 + /** 43 + * Parse and validate a backup manifest from JSON string. 44 + * Returns null if the data is invalid or unsupported. 45 + */ 46 + export function parseBackupManifest(json: string): BackupManifest | null { 47 + try { 48 + const data = JSON.parse(json); 49 + if (data.magic !== BACKUP_MAGIC) return null; 50 + if (typeof data.version !== 'number' || data.version > BACKUP_VERSION) return null; 51 + if (!Array.isArray(data.documents)) return null; 52 + return data as BackupManifest; 53 + } catch { 54 + return null; 55 + } 56 + } 57 + 58 + /** 59 + * Fetch a document's encrypted snapshot as base64. 60 + */ 61 + async function fetchSnapshotBase64(docId: string): Promise<string | null> { 62 + const res = await fetch(`/api/documents/${docId}/snapshot`); 63 + if (!res.ok) return null; 64 + const buf = await res.arrayBuffer(); 65 + const bytes = new Uint8Array(buf); 66 + let binary = ''; 67 + for (let i = 0; i < bytes.length; i++) { 68 + binary += String.fromCharCode(bytes[i]); 69 + } 70 + return btoa(binary); 71 + } 72 + 73 + /** 74 + * Export selected documents as a backup archive and trigger download. 75 + */ 76 + export async function exportBackup( 77 + docs: Array<{ id: string; type: 'doc' | 'sheet'; name_encrypted: string | null; created_at: string; updated_at: string }>, 78 + ): Promise<void> { 79 + const entries: BackupDocEntry[] = []; 80 + 81 + for (const doc of docs) { 82 + const snapshot = await fetchSnapshotBase64(doc.id); 83 + if (!snapshot) continue; 84 + entries.push({ 85 + id: doc.id, 86 + type: doc.type, 87 + name_encrypted: doc.name_encrypted, 88 + snapshot, 89 + created_at: doc.created_at, 90 + updated_at: doc.updated_at, 91 + }); 92 + } 93 + 94 + if (entries.length === 0) return; 95 + 96 + const manifest = createBackupManifest(entries); 97 + const json = JSON.stringify(manifest); 98 + const blob = new Blob([json], { type: 'application/json' }); 99 + const url = URL.createObjectURL(blob); 100 + 101 + const a = document.createElement('a'); 102 + a.href = url; 103 + const date = new Date().toISOString().slice(0, 10); 104 + a.download = `tools-backup-${date}.json`; 105 + a.click(); 106 + URL.revokeObjectURL(url); 107 + } 108 + 109 + /** 110 + * Import documents from a backup archive. 111 + * For each document, uploads the snapshot and stores the key mapping. 112 + */ 113 + export async function importBackup( 114 + json: string, 115 + keyMap: Record<string, string>, 116 + ): Promise<{ imported: number; skipped: number }> { 117 + const manifest = parseBackupManifest(json); 118 + if (!manifest) return { imported: 0, skipped: 0 }; 119 + 120 + let imported = 0; 121 + let skipped = 0; 122 + 123 + for (const entry of manifest.documents) { 124 + const keyStr = keyMap[entry.id]; 125 + if (!keyStr) { 126 + skipped++; 127 + continue; 128 + } 129 + 130 + // Create the document on the server 131 + const createRes = await fetch('/api/documents', { 132 + method: 'POST', 133 + headers: { 'Content-Type': 'application/json' }, 134 + body: JSON.stringify({ 135 + type: entry.type, 136 + name_encrypted: entry.name_encrypted, 137 + id: entry.id, 138 + }), 139 + }); 140 + if (!createRes.ok) { 141 + skipped++; 142 + continue; 143 + } 144 + const { id } = await createRes.json(); 145 + 146 + // Upload the snapshot 147 + const binary = atob(entry.snapshot); 148 + const bytes = new Uint8Array(binary.length); 149 + for (let i = 0; i < binary.length; i++) { 150 + bytes[i] = binary.charCodeAt(i); 151 + } 152 + 153 + await fetch(`/api/documents/${id}/snapshot`, { 154 + method: 'PUT', 155 + body: bytes, 156 + }); 157 + 158 + // Store the key 159 + const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 160 + keys[id] = keyStr; 161 + localStorage.setItem('tools-keys', JSON.stringify(keys)); 162 + 163 + imported++; 164 + } 165 + 166 + return { imported, skipped }; 167 + }
+3
src/index.html
··· 73 73 </div> 74 74 </div> 75 75 <button class="btn-secondary" id="new-folder-btn" title="New Folder">+ Folder</button> 76 + <button class="btn-secondary" id="backup-export-btn" title="Export backup">&#8681; Backup</button> 77 + <button class="btn-secondary" id="backup-import-btn" title="Import backup">&#8679; Restore</button> 78 + <input type="file" id="backup-import-input" accept=".json" style="display:none"> 76 79 </div> 77 80 </div> 78 81 <div id="folder-list"></div>
+48
src/landing.ts
··· 3 3 import { createCommandPalette, type PaletteAction } from './command-palette.js'; 4 4 import { parseTags, addTag, removeTag, saveDocumentTags, collectAllTags, filterByTag } from './tags.js'; 5 5 import { formatDailyNoteName, findDailyNote, getDailyNoteTemplate } from './daily-notes.js'; 6 + import { exportBackup, importBackup } from './backup.js'; 6 7 import { 7 8 sortDocuments, 8 9 toggleStar, ··· 41 42 const sortLabel = document.getElementById('sort-label') as HTMLElement; 42 43 const sortMenu = document.getElementById('sort-menu') as HTMLElement; 43 44 const newFolderBtn = document.getElementById('new-folder-btn') as HTMLElement; 45 + const backupExportBtn = document.getElementById('backup-export-btn') as HTMLElement; 46 + const backupImportBtn = document.getElementById('backup-import-btn') as HTMLElement; 47 + const backupImportInput = document.getElementById('backup-import-input') as HTMLInputElement; 44 48 const breadcrumbsEl = document.getElementById('breadcrumbs') as HTMLElement; 45 49 const trashSection = document.getElementById('trash-section') as HTMLElement; 46 50 const trashToggle = document.getElementById('trash-toggle') as HTMLElement; ··· 238 242 } 239 243 240 244 dailyNoteBtn.addEventListener('click', (e) => { e.preventDefault(); openDailyNote(); }); 245 + 246 + // --- Backup Export/Import --- 247 + backupExportBtn.addEventListener('click', async () => { 248 + const docsToExport = allDocs.filter(d => !d.deleted_at && d._keyStr); 249 + if (docsToExport.length === 0) { 250 + showToast('No documents to export', 3000, true); 251 + return; 252 + } 253 + showToast(`Exporting ${docsToExport.length} document(s)...`); 254 + await exportBackup(docsToExport); 255 + showToast(`Backup exported (${docsToExport.length} documents)`); 256 + }); 257 + 258 + backupImportBtn.addEventListener('click', () => { backupImportInput.click(); }); 259 + backupImportInput.addEventListener('change', async () => { 260 + const file = backupImportInput.files?.[0]; 261 + if (!file) return; 262 + backupImportInput.value = ''; 263 + 264 + const json = await file.text(); 265 + const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 266 + const result = await importBackup(json, keys); 267 + 268 + if (result.imported === 0 && result.skipped === 0) { 269 + showToast('Invalid backup file', 4000, true); 270 + } else { 271 + showToast(`Restored ${result.imported} document(s)` + (result.skipped ? `, ${result.skipped} skipped` : '')); 272 + loadDocuments(); 273 + } 274 + }); 241 275 242 276 // --- Sort --- 243 277 sortLabel.textContent = SORT_LABELS[currentSort] || SORT_LABELS.updated; ··· 1055 1089 category: 'action', 1056 1090 icon: '\uD83D\uDCC5', 1057 1091 action: () => openDailyNote(), 1092 + }, 1093 + { 1094 + id: 'backup-export', 1095 + label: 'Export Backup', 1096 + category: 'action', 1097 + icon: '\u2913', 1098 + action: () => backupExportBtn.click(), 1099 + }, 1100 + { 1101 + id: 'backup-import', 1102 + label: 'Restore Backup', 1103 + category: 'action', 1104 + icon: '\u2912', 1105 + action: () => backupImportBtn.click(), 1058 1106 }, 1059 1107 ], 1060 1108 fetchDocuments: async (): Promise<PaletteAction[]> => {
+94
tests/backup.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createBackupManifest, 4 + parseBackupManifest, 5 + BACKUP_VERSION, 6 + BACKUP_MAGIC, 7 + type BackupManifest, 8 + type BackupDocEntry, 9 + } from '../src/backup.js'; 10 + 11 + describe('Encrypted Backup', () => { 12 + describe('createBackupManifest', () => { 13 + it('creates a manifest with correct version and magic', () => { 14 + const entries: BackupDocEntry[] = [ 15 + { id: 'doc1', type: 'doc', name_encrypted: 'enc1', snapshot: 'base64data', created_at: '2026-01-01', updated_at: '2026-01-02' }, 16 + ]; 17 + const manifest = createBackupManifest(entries); 18 + 19 + expect(manifest.magic).toBe(BACKUP_MAGIC); 20 + expect(manifest.version).toBe(BACKUP_VERSION); 21 + expect(manifest.documents).toHaveLength(1); 22 + expect(manifest.documents[0].id).toBe('doc1'); 23 + }); 24 + 25 + it('includes timestamp', () => { 26 + const manifest = createBackupManifest([]); 27 + expect(manifest.created_at).toBeDefined(); 28 + expect(new Date(manifest.created_at).getTime()).toBeGreaterThan(0); 29 + }); 30 + 31 + it('includes all document entries', () => { 32 + const entries: BackupDocEntry[] = [ 33 + { id: 'doc1', type: 'doc', name_encrypted: 'enc1', snapshot: 'snap1', created_at: '2026-01-01', updated_at: '2026-01-02' }, 34 + { id: 'sheet1', type: 'sheet', name_encrypted: 'enc2', snapshot: 'snap2', created_at: '2026-01-03', updated_at: '2026-01-04' }, 35 + ]; 36 + const manifest = createBackupManifest(entries); 37 + 38 + expect(manifest.documents).toHaveLength(2); 39 + expect(manifest.documents[0].type).toBe('doc'); 40 + expect(manifest.documents[1].type).toBe('sheet'); 41 + expect(manifest.documents[1].snapshot).toBe('snap2'); 42 + }); 43 + }); 44 + 45 + describe('parseBackupManifest', () => { 46 + it('parses a valid manifest', () => { 47 + const entries: BackupDocEntry[] = [ 48 + { id: 'doc1', type: 'doc', name_encrypted: 'enc1', snapshot: 'snap1', created_at: '2026-01-01', updated_at: '2026-01-02' }, 49 + ]; 50 + const manifest = createBackupManifest(entries); 51 + const json = JSON.stringify(manifest); 52 + 53 + const parsed = parseBackupManifest(json); 54 + expect(parsed).not.toBeNull(); 55 + expect(parsed!.documents).toHaveLength(1); 56 + expect(parsed!.documents[0].id).toBe('doc1'); 57 + }); 58 + 59 + it('returns null for invalid JSON', () => { 60 + expect(parseBackupManifest('not json')).toBeNull(); 61 + }); 62 + 63 + it('returns null for wrong magic', () => { 64 + const manifest = { magic: 'WRONG', version: 1, documents: [], created_at: new Date().toISOString() }; 65 + expect(parseBackupManifest(JSON.stringify(manifest))).toBeNull(); 66 + }); 67 + 68 + it('returns null for missing documents array', () => { 69 + const manifest = { magic: BACKUP_MAGIC, version: 1, created_at: new Date().toISOString() }; 70 + expect(parseBackupManifest(JSON.stringify(manifest))).toBeNull(); 71 + }); 72 + 73 + it('returns null for unsupported version', () => { 74 + const manifest = { magic: BACKUP_MAGIC, version: 999, documents: [], created_at: new Date().toISOString() }; 75 + expect(parseBackupManifest(JSON.stringify(manifest))).toBeNull(); 76 + }); 77 + }); 78 + 79 + describe('roundtrip', () => { 80 + it('create → serialize → parse preserves all data', () => { 81 + const entries: BackupDocEntry[] = [ 82 + { id: 'abc', type: 'doc', name_encrypted: 'encName', snapshot: 'binaryData==', created_at: '2026-03-01T00:00:00Z', updated_at: '2026-03-02T12:00:00Z' }, 83 + { id: 'def', type: 'sheet', name_encrypted: null, snapshot: 'otherData==', created_at: '2026-03-03T00:00:00Z', updated_at: '2026-03-04T12:00:00Z' }, 84 + ]; 85 + const original = createBackupManifest(entries); 86 + const json = JSON.stringify(original); 87 + const parsed = parseBackupManifest(json); 88 + 89 + expect(parsed).not.toBeNull(); 90 + expect(parsed!.documents).toEqual(original.documents); 91 + expect(parsed!.created_at).toBe(original.created_at); 92 + }); 93 + }); 94 + });