From 58ff544c46152ce3861690c5e952e006bff21fb1 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 4 Dec 2025 15:22:40 -0800 Subject: [PATCH] feat: implement Google Data Sovereignty module for local-first data control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core modules: - encryption.ts: WebCrypto AES-256-GCM, HKDF key derivation, PKCE utilities - database.ts: IndexedDB schema for gmail, drive, photos, calendar - oauth.ts: OAuth 2.0 PKCE flow with encrypted token storage - share.ts: Create tldraw shapes from encrypted data - backup.ts: R2 backup service with encrypted manifest Importers: - gmail.ts: Gmail import with pagination and batch storage - drive.ts: Drive import with folder navigation, Google Docs export - photos.ts: Photos thumbnail import (403 issue pending investigation) - calendar.ts: Calendar import with date range filtering Test interface at /google route for debugging OAuth flow. Known issue: Photos API returning 403 on some thumbnail URLs - needs further investigation with proper OAuth consent screen setup. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/App.tsx | 6 + src/components/GoogleDataTest.tsx | 468 ++++++++++++++++++++++ src/lib/google/backup.ts | 356 +++++++++++++++++ src/lib/google/database.ts | 567 +++++++++++++++++++++++++++ src/lib/google/encryption.ts | 292 ++++++++++++++ src/lib/google/importers/calendar.ts | 425 ++++++++++++++++++++ src/lib/google/importers/drive.ts | 406 +++++++++++++++++++ src/lib/google/importers/gmail.ts | 409 +++++++++++++++++++ src/lib/google/importers/index.ts | 5 + src/lib/google/importers/photos.ts | 424 ++++++++++++++++++++ src/lib/google/index.ts | 287 ++++++++++++++ src/lib/google/oauth.ts | 382 ++++++++++++++++++ src/lib/google/share.ts | 555 ++++++++++++++++++++++++++ src/lib/google/types.ts | 165 ++++++++ 14 files changed, 4747 insertions(+) create mode 100644 src/components/GoogleDataTest.tsx create mode 100644 src/lib/google/backup.ts create mode 100644 src/lib/google/database.ts create mode 100644 src/lib/google/encryption.ts create mode 100644 src/lib/google/importers/calendar.ts create mode 100644 src/lib/google/importers/drive.ts create mode 100644 src/lib/google/importers/gmail.ts create mode 100644 src/lib/google/importers/index.ts create mode 100644 src/lib/google/importers/photos.ts create mode 100644 src/lib/google/index.ts create mode 100644 src/lib/google/oauth.ts create mode 100644 src/lib/google/share.ts create mode 100644 src/lib/google/types.ts diff --git a/src/App.tsx b/src/App.tsx index 55564d8..3b6c939 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,6 +35,9 @@ import { ErrorBoundary } from './components/ErrorBoundary'; import CryptoLogin from './components/auth/CryptoLogin'; import CryptoDebug from './components/auth/CryptoDebug'; +// Import Google Data test component +import { GoogleDataTest } from './components/GoogleDataTest'; + inject(); // Initialize Daily.co call object with error handling @@ -168,6 +171,9 @@ const AppWithProviders = () => { } /> + {/* Google Data routes */} + } /> + } /> diff --git a/src/components/GoogleDataTest.tsx b/src/components/GoogleDataTest.tsx new file mode 100644 index 0000000..b929d16 --- /dev/null +++ b/src/components/GoogleDataTest.tsx @@ -0,0 +1,468 @@ +// Simple test component for Google Data Sovereignty OAuth flow +import { useState, useEffect } from 'react'; +import { + initiateGoogleAuth, + handleGoogleCallback, + parseCallbackParams, + isGoogleAuthenticated, + getGrantedScopes, + generateMasterKey, + importGmail, + importDrive, + importPhotos, + importCalendar, + gmailStore, + driveStore, + photosStore, + calendarStore, + deleteDatabase, + createShareService, + type GoogleService, + type ImportProgress, + type ShareableItem +} from '../lib/google'; + +export function GoogleDataTest() { + const [status, setStatus] = useState('Initializing...'); + const [isAuthed, setIsAuthed] = useState(false); + const [scopes, setScopes] = useState([]); + const [masterKey, setMasterKey] = useState(null); + const [error, setError] = useState(null); + const [importProgress, setImportProgress] = useState(null); + const [storedCounts, setStoredCounts] = useState<{gmail: number; drive: number; photos: number; calendar: number}>({ + gmail: 0, drive: 0, photos: 0, calendar: 0 + }); + const [logs, setLogs] = useState([]); + const [viewingService, setViewingService] = useState(null); + const [viewItems, setViewItems] = useState([]); + + const addLog = (msg: string) => { + console.log(msg); + setLogs(prev => [...prev.slice(-20), `${new Date().toLocaleTimeString()}: ${msg}`]); + }; + + // Initialize on mount + useEffect(() => { + initializeService(); + }, []); + + // Check for OAuth callback - wait for masterKey to be ready + useEffect(() => { + const url = window.location.href; + if (url.includes('/oauth/google/callback') && masterKey) { + handleCallback(url); + } + }, [masterKey]); // Re-run when masterKey becomes available + + async function initializeService() { + try { + // Generate or load master key + const key = await generateMasterKey(); + setMasterKey(key); + + // Check if already authenticated + const authed = await isGoogleAuthenticated(); + setIsAuthed(authed); + + if (authed) { + const grantedScopes = await getGrantedScopes(); + setScopes(grantedScopes); + setStatus('Authenticated with Google'); + } else { + setStatus('Ready to connect to Google'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Initialization failed'); + setStatus('Error'); + } + } + + async function handleCallback(url: string) { + setStatus('Processing OAuth callback...'); + + const params = parseCallbackParams(url); + + if (params.error) { + setError(`OAuth error: ${params.error_description || params.error}`); + setStatus('Error'); + return; + } + + if (params.code && params.state && masterKey) { + const result = await handleGoogleCallback(params.code, params.state, masterKey); + + if (result.success) { + setIsAuthed(true); + setScopes(result.scopes); + setStatus('Successfully connected to Google!'); + // Clean up URL + window.history.replaceState({}, '', '/'); + } else { + setError(result.error || 'Callback failed'); + setStatus('Error'); + } + } + } + + async function connectGoogle() { + setStatus('Redirecting to Google...'); + const services: GoogleService[] = ['gmail', 'drive', 'photos', 'calendar']; + await initiateGoogleAuth(services); + } + + async function resetAndReconnect() { + addLog('Resetting: Clearing all data...'); + try { + await deleteDatabase(); + addLog('Resetting: Database cleared'); + setIsAuthed(false); + setScopes([]); + setStoredCounts({ gmail: 0, drive: 0, photos: 0, calendar: 0 }); + setError(null); + setStatus('Database cleared. Click Connect to re-authenticate.'); + addLog('Resetting: Done. Please re-connect to Google.'); + } catch (err) { + addLog(`Resetting: ERROR - ${err}`); + } + } + + async function viewData(service: GoogleService) { + if (!masterKey) return; + addLog(`Viewing ${service} data...`); + try { + const shareService = createShareService(masterKey); + const items = await shareService.listShareableItems(service, 20); + addLog(`Found ${items.length} ${service} items`); + setViewItems(items); + setViewingService(service); + } catch (err) { + addLog(`View error: ${err}`); + setError(err instanceof Error ? err.message : String(err)); + } + } + + async function refreshCounts() { + const [gmail, drive, photos, calendar] = await Promise.all([ + gmailStore.count(), + driveStore.count(), + photosStore.count(), + calendarStore.count() + ]); + setStoredCounts({ gmail, drive, photos, calendar }); + } + + async function testImportGmail() { + addLog('Gmail: Starting...'); + if (!masterKey) { + addLog('Gmail: ERROR - No master key'); + setError('No master key available'); + return; + } + setError(null); + setImportProgress(null); + setStatus('Importing Gmail (max 10 messages)...'); + try { + addLog('Gmail: Calling importGmail...'); + const result = await importGmail(masterKey, { + maxMessages: 10, + onProgress: (p) => { + addLog(`Gmail: Progress ${p.imported}/${p.total} - ${p.status}`); + setImportProgress(p); + } + }); + addLog(`Gmail: Result - ${result.status}, ${result.imported} items`); + setImportProgress(result); + if (result.status === 'error') { + addLog(`Gmail: ERROR - ${result.errorMessage}`); + setError(result.errorMessage || 'Unknown error'); + setStatus('Gmail import failed'); + } else { + setStatus(`Gmail import ${result.status}: ${result.imported} messages`); + } + await refreshCounts(); + } catch (err) { + const errorMsg = err instanceof Error ? `${err.name}: ${err.message}` : String(err); + addLog(`Gmail: EXCEPTION - ${errorMsg}`); + setError(errorMsg); + setStatus('Gmail import error'); + } + } + + async function testImportDrive() { + if (!masterKey) return; + setError(null); + setStatus('Importing Drive (max 10 files)...'); + try { + const result = await importDrive(masterKey, { + maxFiles: 10, + onProgress: (p) => setImportProgress(p) + }); + setImportProgress(result); + setStatus(`Drive import ${result.status}: ${result.imported} files`); + await refreshCounts(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Import failed'); + setStatus('Error'); + } + } + + async function testImportPhotos() { + if (!masterKey) return; + setError(null); + setStatus('Importing Photos (max 10 thumbnails)...'); + try { + const result = await importPhotos(masterKey, { + maxPhotos: 10, + onProgress: (p) => setImportProgress(p) + }); + setImportProgress(result); + setStatus(`Photos import ${result.status}: ${result.imported} photos`); + await refreshCounts(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Import failed'); + setStatus('Error'); + } + } + + async function testImportCalendar() { + if (!masterKey) return; + setError(null); + setStatus('Importing Calendar (max 20 events)...'); + try { + const result = await importCalendar(masterKey, { + maxEvents: 20, + onProgress: (p) => setImportProgress(p) + }); + setImportProgress(result); + setStatus(`Calendar import ${result.status}: ${result.imported} events`); + await refreshCounts(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Import failed'); + setStatus('Error'); + } + } + + const buttonStyle = { + padding: '10px 16px', + fontSize: '14px', + background: '#1a73e8', + color: 'white', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + marginRight: '10px', + marginBottom: '10px' + }; + + return ( +
+

Google Data Sovereignty Test

+ +
+ Status: {status} + {error && ( +
+ Error: {error} +
+ )} +
+ + {!isAuthed ? ( + + ) : ( +
+

Connected!

+

Granted scopes:

+
    + {scopes.map(scope => ( +
  • + {scope.replace('https://www.googleapis.com/auth/', '')} +
  • + ))} +
+ +

Test Import (Small Batches)

+
+ + + + +
+ + {importProgress && ( +
+ {importProgress.service}: {importProgress.status} + {importProgress.status === 'importing' && ( + - {importProgress.imported}/{importProgress.total} + )} + {importProgress.status === 'completed' && ( + - {importProgress.imported} items imported + )} + {importProgress.errorMessage && ( +
{importProgress.errorMessage}
+ )} +
+ )} + +

Stored Data (Encrypted in IndexedDB)

+ + + + + + + + + + + + + + + + + + + + + + + +
Gmail{storedCounts.gmail} messages + {storedCounts.gmail > 0 && } +
Drive{storedCounts.drive} files + {storedCounts.drive > 0 && } +
Photos{storedCounts.photos} photos + {storedCounts.photos > 0 && } +
Calendar{storedCounts.calendar} events + {storedCounts.calendar > 0 && } +
+ + {viewingService && viewItems.length > 0 && ( +
+

+ {viewingService.charAt(0).toUpperCase() + viewingService.slice(1)} Items (Decrypted) + +

+
+ {viewItems.map((item, i) => ( +
+ {item.title} +
+ {new Date(item.date).toLocaleString()} +
+ {item.preview && ( +
+ {item.preview.substring(0, 100)}... +
+ )} +
+ ))} +
+
+ )} + + +
+ )} + +
+ +

Activity Log

+
+ {logs.length === 0 ? ( + Click an import button to see activity... + ) : ( + logs.map((log, i) =>
{log}
) + )} +
+ +
+ Debug Info +
+{JSON.stringify({
+  isAuthed,
+  hasMasterKey: !!masterKey,
+  scopeCount: scopes.length,
+  storedCounts,
+  importProgress,
+  currentUrl: typeof window !== 'undefined' ? window.location.href : 'N/A'
+}, null, 2)}
+        
+
+
+ ); +} + +export default GoogleDataTest; diff --git a/src/lib/google/backup.ts b/src/lib/google/backup.ts new file mode 100644 index 0000000..ae96f2e --- /dev/null +++ b/src/lib/google/backup.ts @@ -0,0 +1,356 @@ +// R2 encrypted backup service +// Data is already encrypted in IndexedDB, uploaded as-is to R2 + +import type { + GoogleService, + EncryptedEmailStore, + EncryptedDriveDocument, + EncryptedPhotoReference, + EncryptedCalendarEvent +} from './types'; +import { exportAllData, clearServiceData } from './database'; +import { + encryptData, + decryptData, + deriveServiceKey, + encryptMasterKeyWithPassword, + decryptMasterKeyWithPassword, + base64UrlEncode, + base64UrlDecode +} from './encryption'; + +// Backup metadata stored with the backup +export interface BackupMetadata { + id: string; + createdAt: number; + services: GoogleService[]; + itemCounts: { + gmail: number; + drive: number; + photos: number; + calendar: number; + }; + sizeBytes: number; + version: number; +} + +// Backup manifest (encrypted, stored in R2) +interface BackupManifest { + version: 1; + createdAt: number; + services: GoogleService[]; + itemCounts: { + gmail: number; + drive: number; + photos: number; + calendar: number; + }; + checksum: string; +} + +// R2 backup service +export class R2BackupService { + private backupApiUrl: string; + + constructor( + private masterKey: CryptoKey, + backupApiUrl?: string + ) { + // Default to the canvas worker backup endpoint + this.backupApiUrl = backupApiUrl || '/api/backup'; + } + + // Create a backup of all Google data + async createBackup( + options: { + services?: GoogleService[]; + onProgress?: (progress: { stage: string; percent: number }) => void; + } = {} + ): Promise { + const services = options.services || ['gmail', 'drive', 'photos', 'calendar']; + + try { + options.onProgress?.({ stage: 'Gathering data', percent: 0 }); + + // Export all data from IndexedDB + const data = await exportAllData(); + + // Filter to requested services + const filteredData = { + gmail: services.includes('gmail') ? data.gmail : [], + drive: services.includes('drive') ? data.drive : [], + photos: services.includes('photos') ? data.photos : [], + calendar: services.includes('calendar') ? data.calendar : [], + syncMetadata: data.syncMetadata.filter(m => + services.includes(m.service as GoogleService) + ), + encryptionMeta: data.encryptionMeta + }; + + options.onProgress?.({ stage: 'Preparing backup', percent: 20 }); + + // Create manifest + const manifest: BackupManifest = { + version: 1, + createdAt: Date.now(), + services, + itemCounts: { + gmail: filteredData.gmail.length, + drive: filteredData.drive.length, + photos: filteredData.photos.length, + calendar: filteredData.calendar.length + }, + checksum: await this.createChecksum(filteredData) + }; + + options.onProgress?.({ stage: 'Encrypting manifest', percent: 30 }); + + // Encrypt manifest with backup key + const backupKey = await deriveServiceKey(this.masterKey, 'backup'); + const encryptedManifest = await encryptData( + JSON.stringify(manifest), + backupKey + ); + + options.onProgress?.({ stage: 'Serializing data', percent: 40 }); + + // Serialize data (already encrypted in IndexedDB) + const serializedData = JSON.stringify(filteredData); + const dataBlob = new Blob([serializedData], { type: 'application/json' }); + + options.onProgress?.({ stage: 'Uploading backup', percent: 50 }); + + // Upload to R2 via worker + const backupId = crypto.randomUUID(); + const response = await fetch(this.backupApiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Backup-Id': backupId, + 'X-Backup-Manifest': base64UrlEncode( + new Uint8Array(encryptedManifest.encrypted) + ), + 'X-Backup-Manifest-IV': base64UrlEncode(encryptedManifest.iv) + }, + body: dataBlob + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Backup upload failed: ${error}`); + } + + options.onProgress?.({ stage: 'Complete', percent: 100 }); + + return { + id: backupId, + createdAt: manifest.createdAt, + services, + itemCounts: manifest.itemCounts, + sizeBytes: dataBlob.size, + version: manifest.version + }; + + } catch (error) { + console.error('Backup creation failed:', error); + return null; + } + } + + // List available backups + async listBackups(): Promise { + try { + const response = await fetch(`${this.backupApiUrl}/list`, { + method: 'GET' + }); + + if (!response.ok) { + throw new Error('Failed to list backups'); + } + + const backups = await response.json() as BackupMetadata[]; + return backups; + + } catch (error) { + console.error('List backups failed:', error); + return []; + } + } + + // Restore a backup + async restoreBackup( + backupId: string, + options: { + services?: GoogleService[]; + clearExisting?: boolean; + onProgress?: (progress: { stage: string; percent: number }) => void; + } = {} + ): Promise { + try { + options.onProgress?.({ stage: 'Fetching backup', percent: 0 }); + + // Fetch backup from R2 + const response = await fetch(`${this.backupApiUrl}/${backupId}`, { + method: 'GET' + }); + + if (!response.ok) { + throw new Error('Backup not found'); + } + + options.onProgress?.({ stage: 'Parsing backup', percent: 20 }); + + // Get encrypted manifest from headers + const manifestBase64 = response.headers.get('X-Backup-Manifest'); + const manifestIvBase64 = response.headers.get('X-Backup-Manifest-IV'); + + if (!manifestBase64 || !manifestIvBase64) { + throw new Error('Invalid backup: missing manifest'); + } + + // Decrypt manifest + const backupKey = await deriveServiceKey(this.masterKey, 'backup'); + const manifestIv = base64UrlDecode(manifestIvBase64); + const manifestEncrypted = base64UrlDecode(manifestBase64); + const manifestData = await decryptData( + { + encrypted: manifestEncrypted.buffer as ArrayBuffer, + iv: manifestIv + }, + backupKey + ); + const manifest: BackupManifest = JSON.parse( + new TextDecoder().decode(manifestData) + ); + + options.onProgress?.({ stage: 'Verifying backup', percent: 30 }); + + // Parse backup data + interface BackupDataStructure { + gmail?: EncryptedEmailStore[]; + drive?: EncryptedDriveDocument[]; + photos?: EncryptedPhotoReference[]; + calendar?: EncryptedCalendarEvent[]; + } + const backupData = await response.json() as BackupDataStructure; + + // Verify checksum + const checksum = await this.createChecksum(backupData); + if (checksum !== manifest.checksum) { + throw new Error('Backup verification failed: checksum mismatch'); + } + + options.onProgress?.({ stage: 'Restoring data', percent: 50 }); + + // Clear existing data if requested + const servicesToRestore = options.services || manifest.services; + if (options.clearExisting) { + for (const service of servicesToRestore) { + await clearServiceData(service); + } + } + + // Restore data to IndexedDB + // Note: Data is already encrypted, just need to write it + const { gmailStore, driveStore, photosStore, calendarStore } = await import('./database'); + + if (servicesToRestore.includes('gmail') && backupData.gmail?.length) { + await gmailStore.putBatch(backupData.gmail); + } + if (servicesToRestore.includes('drive') && backupData.drive?.length) { + await driveStore.putBatch(backupData.drive); + } + if (servicesToRestore.includes('photos') && backupData.photos?.length) { + await photosStore.putBatch(backupData.photos); + } + if (servicesToRestore.includes('calendar') && backupData.calendar?.length) { + await calendarStore.putBatch(backupData.calendar); + } + + options.onProgress?.({ stage: 'Complete', percent: 100 }); + + return true; + + } catch (error) { + console.error('Backup restore failed:', error); + return false; + } + } + + // Delete a backup + async deleteBackup(backupId: string): Promise { + try { + const response = await fetch(`${this.backupApiUrl}/${backupId}`, { + method: 'DELETE' + }); + + return response.ok; + + } catch (error) { + console.error('Delete backup failed:', error); + return false; + } + } + + // Create checksum for data verification + private async createChecksum(data: unknown): Promise { + const serialized = JSON.stringify(data); + const encoder = new TextEncoder(); + const dataBuffer = encoder.encode(serialized); + const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer); + return base64UrlEncode(new Uint8Array(hashBuffer)); + } + + // Export master key encrypted with password (for backup recovery) + async exportMasterKeyBackup(password: string): Promise<{ + encryptedKey: string; + salt: string; + }> { + const { encryptedKey, salt } = await encryptMasterKeyWithPassword( + this.masterKey, + password + ); + + return { + encryptedKey: base64UrlEncode(new Uint8Array(encryptedKey.encrypted)) + + '.' + base64UrlEncode(encryptedKey.iv), + salt: base64UrlEncode(salt) + }; + } + + // Import master key from password-protected backup + static async importMasterKeyBackup( + encryptedKeyString: string, + salt: string, + password: string + ): Promise { + const [keyBase64, ivBase64] = encryptedKeyString.split('.'); + + const encryptedKey = { + encrypted: base64UrlDecode(keyBase64).buffer as ArrayBuffer, + iv: base64UrlDecode(ivBase64) + }; + + return decryptMasterKeyWithPassword( + encryptedKey, + password, + base64UrlDecode(salt) + ); + } +} + +// Progress callback for backups +export interface BackupProgress { + service: 'gmail' | 'drive' | 'photos' | 'calendar' | 'all'; + status: 'idle' | 'backing_up' | 'restoring' | 'completed' | 'error'; + progress: number; // 0-100 + errorMessage?: string; +} + +// Convenience function +export function createBackupService( + masterKey: CryptoKey, + backupApiUrl?: string +): R2BackupService { + return new R2BackupService(masterKey, backupApiUrl); +} diff --git a/src/lib/google/database.ts b/src/lib/google/database.ts new file mode 100644 index 0000000..f6f7ac7 --- /dev/null +++ b/src/lib/google/database.ts @@ -0,0 +1,567 @@ +// IndexedDB database for encrypted Google data storage +// All data stored here is already encrypted client-side + +import type { + EncryptedEmailStore, + EncryptedDriveDocument, + EncryptedPhotoReference, + EncryptedCalendarEvent, + SyncMetadata, + EncryptionMetadata, + EncryptedTokens, + GoogleService, + StorageQuotaInfo +} from './types'; +import { DB_STORES } from './types'; + +const DB_NAME = 'canvas-google-data'; +const DB_VERSION = 1; + +let dbInstance: IDBDatabase | null = null; + +// Open or create the database +export async function openDatabase(): Promise { + if (dbInstance) { + return dbInstance; + } + + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => { + console.error('Failed to open Google data database:', request.error); + reject(request.error); + }; + + request.onsuccess = () => { + dbInstance = request.result; + resolve(dbInstance); + }; + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + createStores(db); + }; + }); +} + +// Create all object stores +function createStores(db: IDBDatabase): void { + // Gmail messages store + if (!db.objectStoreNames.contains(DB_STORES.gmail)) { + const gmailStore = db.createObjectStore(DB_STORES.gmail, { keyPath: 'id' }); + gmailStore.createIndex('threadId', 'threadId', { unique: false }); + gmailStore.createIndex('date', 'date', { unique: false }); + gmailStore.createIndex('syncedAt', 'syncedAt', { unique: false }); + gmailStore.createIndex('localOnly', 'localOnly', { unique: false }); + } + + // Drive documents store + if (!db.objectStoreNames.contains(DB_STORES.drive)) { + const driveStore = db.createObjectStore(DB_STORES.drive, { keyPath: 'id' }); + driveStore.createIndex('parentId', 'parentId', { unique: false }); + driveStore.createIndex('modifiedTime', 'modifiedTime', { unique: false }); + driveStore.createIndex('syncedAt', 'syncedAt', { unique: false }); + } + + // Photos store + if (!db.objectStoreNames.contains(DB_STORES.photos)) { + const photosStore = db.createObjectStore(DB_STORES.photos, { keyPath: 'id' }); + photosStore.createIndex('creationTime', 'creationTime', { unique: false }); + photosStore.createIndex('mediaType', 'mediaType', { unique: false }); + photosStore.createIndex('syncedAt', 'syncedAt', { unique: false }); + } + + // Calendar events store + if (!db.objectStoreNames.contains(DB_STORES.calendar)) { + const calendarStore = db.createObjectStore(DB_STORES.calendar, { keyPath: 'id' }); + calendarStore.createIndex('calendarId', 'calendarId', { unique: false }); + calendarStore.createIndex('startTime', 'startTime', { unique: false }); + calendarStore.createIndex('endTime', 'endTime', { unique: false }); + calendarStore.createIndex('syncedAt', 'syncedAt', { unique: false }); + } + + // Sync metadata store + if (!db.objectStoreNames.contains(DB_STORES.syncMetadata)) { + db.createObjectStore(DB_STORES.syncMetadata, { keyPath: 'service' }); + } + + // Encryption metadata store + if (!db.objectStoreNames.contains(DB_STORES.encryptionMeta)) { + db.createObjectStore(DB_STORES.encryptionMeta, { keyPath: 'purpose' }); + } + + // Tokens store + if (!db.objectStoreNames.contains(DB_STORES.tokens)) { + db.createObjectStore(DB_STORES.tokens, { keyPath: 'id' }); + } +} + +// Close the database connection +export function closeDatabase(): void { + if (dbInstance) { + dbInstance.close(); + dbInstance = null; + } +} + +// Delete the entire database (for user data wipe) +export async function deleteDatabase(): Promise { + closeDatabase(); + + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(DB_NAME); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); +} + +// Generic put operation +async function putItem(storeName: string, item: T): Promise { + const db = await openDatabase(); + + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, 'readwrite'); + const store = tx.objectStore(storeName); + const request = store.put(item); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); +} + +// Generic get operation +async function getItem(storeName: string, key: string): Promise { + const db = await openDatabase(); + + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, 'readonly'); + const store = tx.objectStore(storeName); + const request = store.get(key); + + request.onsuccess = () => resolve(request.result || null); + request.onerror = () => reject(request.error); + }); +} + +// Generic delete operation +async function deleteItem(storeName: string, key: string): Promise { + const db = await openDatabase(); + + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, 'readwrite'); + const store = tx.objectStore(storeName); + const request = store.delete(key); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); +} + +// Generic getAll operation +async function getAllItems(storeName: string): Promise { + const db = await openDatabase(); + + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, 'readonly'); + const store = tx.objectStore(storeName); + const request = store.getAll(); + + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => reject(request.error); + }); +} + +// Generic count operation +async function countItems(storeName: string): Promise { + const db = await openDatabase(); + + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, 'readonly'); + const store = tx.objectStore(storeName); + const request = store.count(); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +// Get items by index with optional range +async function getItemsByIndex( + storeName: string, + indexName: string, + query?: IDBKeyRange | IDBValidKey +): Promise { + const db = await openDatabase(); + + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, 'readonly'); + const store = tx.objectStore(storeName); + const index = store.index(indexName); + const request = query ? index.getAll(query) : index.getAll(); + + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => reject(request.error); + }); +} + +// Gmail operations +export const gmailStore = { + put: (email: EncryptedEmailStore) => putItem(DB_STORES.gmail, email), + get: (id: string) => getItem(DB_STORES.gmail, id), + delete: (id: string) => deleteItem(DB_STORES.gmail, id), + getAll: () => getAllItems(DB_STORES.gmail), + count: () => countItems(DB_STORES.gmail), + + getByThread: (threadId: string) => + getItemsByIndex(DB_STORES.gmail, 'threadId', threadId), + + getByDateRange: (startDate: number, endDate: number) => + getItemsByIndex( + DB_STORES.gmail, + 'date', + IDBKeyRange.bound(startDate, endDate) + ), + + getLocalOnly: async () => { + const all = await getAllItems(DB_STORES.gmail); + return all.filter(email => email.localOnly === true); + }, + + async putBatch(emails: EncryptedEmailStore[]): Promise { + const db = await openDatabase(); + + return new Promise((resolve, reject) => { + const tx = db.transaction(DB_STORES.gmail, 'readwrite'); + const store = tx.objectStore(DB_STORES.gmail); + + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + + for (const email of emails) { + store.put(email); + } + }); + } +}; + +// Drive operations +export const driveStore = { + put: (doc: EncryptedDriveDocument) => putItem(DB_STORES.drive, doc), + get: (id: string) => getItem(DB_STORES.drive, id), + delete: (id: string) => deleteItem(DB_STORES.drive, id), + getAll: () => getAllItems(DB_STORES.drive), + count: () => countItems(DB_STORES.drive), + + getByParent: (parentId: string | null) => + getItemsByIndex( + DB_STORES.drive, + 'parentId', + parentId ?? '' + ), + + getRecent: (limit: number = 50) => + getItemsByIndex(DB_STORES.drive, 'modifiedTime') + .then(items => items.sort((a, b) => b.modifiedTime - a.modifiedTime).slice(0, limit)), + + async putBatch(docs: EncryptedDriveDocument[]): Promise { + const db = await openDatabase(); + + return new Promise((resolve, reject) => { + const tx = db.transaction(DB_STORES.drive, 'readwrite'); + const store = tx.objectStore(DB_STORES.drive); + + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + + for (const doc of docs) { + store.put(doc); + } + }); + } +}; + +// Photos operations +export const photosStore = { + put: (photo: EncryptedPhotoReference) => putItem(DB_STORES.photos, photo), + get: (id: string) => getItem(DB_STORES.photos, id), + delete: (id: string) => deleteItem(DB_STORES.photos, id), + getAll: () => getAllItems(DB_STORES.photos), + count: () => countItems(DB_STORES.photos), + + getByMediaType: (mediaType: 'image' | 'video') => + getItemsByIndex(DB_STORES.photos, 'mediaType', mediaType), + + getByDateRange: (startDate: number, endDate: number) => + getItemsByIndex( + DB_STORES.photos, + 'creationTime', + IDBKeyRange.bound(startDate, endDate) + ), + + async putBatch(photos: EncryptedPhotoReference[]): Promise { + const db = await openDatabase(); + + return new Promise((resolve, reject) => { + const tx = db.transaction(DB_STORES.photos, 'readwrite'); + const store = tx.objectStore(DB_STORES.photos); + + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + + for (const photo of photos) { + store.put(photo); + } + }); + } +}; + +// Calendar operations +export const calendarStore = { + put: (event: EncryptedCalendarEvent) => putItem(DB_STORES.calendar, event), + get: (id: string) => getItem(DB_STORES.calendar, id), + delete: (id: string) => deleteItem(DB_STORES.calendar, id), + getAll: () => getAllItems(DB_STORES.calendar), + count: () => countItems(DB_STORES.calendar), + + getByCalendar: (calendarId: string) => + getItemsByIndex(DB_STORES.calendar, 'calendarId', calendarId), + + getByDateRange: (startTime: number, endTime: number) => + getItemsByIndex( + DB_STORES.calendar, + 'startTime', + IDBKeyRange.bound(startTime, endTime) + ), + + getUpcoming: (fromTime: number = Date.now(), limit: number = 50) => + getItemsByIndex( + DB_STORES.calendar, + 'startTime', + IDBKeyRange.lowerBound(fromTime) + ).then(items => items.slice(0, limit)), + + async putBatch(events: EncryptedCalendarEvent[]): Promise { + const db = await openDatabase(); + + return new Promise((resolve, reject) => { + const tx = db.transaction(DB_STORES.calendar, 'readwrite'); + const store = tx.objectStore(DB_STORES.calendar); + + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + + for (const event of events) { + store.put(event); + } + }); + } +}; + +// Sync metadata operations +export const syncMetadataStore = { + put: (metadata: SyncMetadata) => putItem(DB_STORES.syncMetadata, metadata), + get: (service: GoogleService) => getItem(DB_STORES.syncMetadata, service), + getAll: () => getAllItems(DB_STORES.syncMetadata), + + async updateProgress( + service: GoogleService, + current: number, + total: number + ): Promise { + const existing = await this.get(service); + await this.put({ + ...existing, + service, + status: 'syncing', + progressCurrent: current, + progressTotal: total, + lastSyncTime: existing?.lastSyncTime ?? Date.now() + } as SyncMetadata); + }, + + async markComplete(service: GoogleService, itemCount: number): Promise { + const existing = await this.get(service); + await this.put({ + ...existing, + service, + status: 'idle', + itemCount, + lastSyncTime: Date.now(), + progressCurrent: undefined, + progressTotal: undefined + } as SyncMetadata); + }, + + async markError(service: GoogleService, errorMessage: string): Promise { + const existing = await this.get(service); + await this.put({ + ...existing, + service, + status: 'error', + errorMessage, + lastSyncTime: existing?.lastSyncTime ?? Date.now() + } as SyncMetadata); + } +}; + +// Encryption metadata operations +export const encryptionMetaStore = { + put: (metadata: EncryptionMetadata) => putItem(DB_STORES.encryptionMeta, metadata), + get: (purpose: string) => getItem(DB_STORES.encryptionMeta, purpose), + getAll: () => getAllItems(DB_STORES.encryptionMeta) +}; + +// Token operations +export const tokensStore = { + async put(tokens: EncryptedTokens): Promise { + await putItem(DB_STORES.tokens, { id: 'google', ...tokens }); + }, + + async get(): Promise { + const result = await getItem(DB_STORES.tokens, 'google'); + if (result) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id, ...tokens } = result; + return tokens; + } + return null; + }, + + async delete(): Promise { + await deleteItem(DB_STORES.tokens, 'google'); + }, + + async isExpired(): Promise { + const tokens = await this.get(); + if (!tokens) return true; + // Add 5 minute buffer + return tokens.expiresAt <= Date.now() + 5 * 60 * 1000; + } +}; + +// Storage quota utilities +export async function requestPersistentStorage(): Promise { + if (navigator.storage && navigator.storage.persist) { + const isPersisted = await navigator.storage.persist(); + console.log(`Persistent storage ${isPersisted ? 'granted' : 'denied'}`); + return isPersisted; + } + return false; +} + +export async function checkStorageQuota(): Promise { + const defaultQuota: StorageQuotaInfo = { + used: 0, + quota: 0, + isPersistent: false, + byService: { gmail: 0, drive: 0, photos: 0, calendar: 0 } + }; + + if (!navigator.storage || !navigator.storage.estimate) { + return defaultQuota; + } + + const estimate = await navigator.storage.estimate(); + const isPersistent = navigator.storage.persisted + ? await navigator.storage.persisted() + : false; + + // Estimate per-service usage based on item counts + // (rough approximation - actual size would require iterating all items) + const [gmailCount, driveCount, photosCount, calendarCount] = await Promise.all([ + gmailStore.count(), + driveStore.count(), + photosStore.count(), + calendarStore.count() + ]); + + // Rough size estimates per item (in bytes) + const AVG_EMAIL_SIZE = 25000; // 25KB + const AVG_DOC_SIZE = 50000; // 50KB + const AVG_PHOTO_SIZE = 50000; // 50KB (thumbnail only) + const AVG_EVENT_SIZE = 5000; // 5KB + + return { + used: estimate.usage || 0, + quota: estimate.quota || 0, + isPersistent, + byService: { + gmail: gmailCount * AVG_EMAIL_SIZE, + drive: driveCount * AVG_DOC_SIZE, + photos: photosCount * AVG_PHOTO_SIZE, + calendar: calendarCount * AVG_EVENT_SIZE + } + }; +} + +// Safari-specific handling +export function hasSafariLimitations(): boolean { + const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); + const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); + return isSafari || isIOS; +} + +// Touch data to prevent Safari 7-day eviction +export async function touchLocalData(): Promise { + const db = await openDatabase(); + + return new Promise((resolve, reject) => { + const tx = db.transaction(DB_STORES.encryptionMeta, 'readwrite'); + const store = tx.objectStore(DB_STORES.encryptionMeta); + + // Just update a timestamp in encryption metadata + store.put({ + purpose: 'master', + salt: new Uint8Array(0), + createdAt: Date.now() + } as EncryptionMetadata); + + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +// Clear all data for a specific service +export async function clearServiceData(service: GoogleService): Promise { + const db = await openDatabase(); + + return new Promise((resolve, reject) => { + const tx = db.transaction(service, 'readwrite'); + const store = tx.objectStore(service); + const request = store.clear(); + + request.onsuccess = async () => { + // Also clear sync metadata for this service + await syncMetadataStore.put({ + service, + lastSyncTime: Date.now(), + itemCount: 0, + status: 'idle' + }); + resolve(); + }; + request.onerror = () => reject(request.error); + }); +} + +// Export all data for backup +export async function exportAllData(): Promise<{ + gmail: EncryptedEmailStore[]; + drive: EncryptedDriveDocument[]; + photos: EncryptedPhotoReference[]; + calendar: EncryptedCalendarEvent[]; + syncMetadata: SyncMetadata[]; + encryptionMeta: EncryptionMetadata[]; +}> { + const [gmail, drive, photos, calendar, syncMetadata, encryptionMeta] = await Promise.all([ + gmailStore.getAll(), + driveStore.getAll(), + photosStore.getAll(), + calendarStore.getAll(), + syncMetadataStore.getAll(), + encryptionMetaStore.getAll() + ]); + + return { gmail, drive, photos, calendar, syncMetadata, encryptionMeta }; +} diff --git a/src/lib/google/encryption.ts b/src/lib/google/encryption.ts new file mode 100644 index 0000000..fd07b6c --- /dev/null +++ b/src/lib/google/encryption.ts @@ -0,0 +1,292 @@ +// WebCrypto encryption utilities for Google Data Sovereignty +// Uses AES-256-GCM for symmetric encryption and HKDF for key derivation + +import type { EncryptedData, GoogleService } from './types'; + +// Check if we're in a browser environment with WebCrypto +export const hasWebCrypto = (): boolean => { + return typeof window !== 'undefined' && + window.crypto !== undefined && + window.crypto.subtle !== undefined; +}; + +// Generate a random master key for new users +export async function generateMasterKey(): Promise { + if (!hasWebCrypto()) { + throw new Error('WebCrypto not available'); + } + + return await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, // extractable for backup + ['encrypt', 'decrypt'] + ); +} + +// Export master key to raw format for backup +export async function exportMasterKey(key: CryptoKey): Promise { + if (!hasWebCrypto()) { + throw new Error('WebCrypto not available'); + } + + return await crypto.subtle.exportKey('raw', key); +} + +// Import master key from raw format (for restore) +export async function importMasterKey(keyData: ArrayBuffer): Promise { + if (!hasWebCrypto()) { + throw new Error('WebCrypto not available'); + } + + return await crypto.subtle.importKey( + 'raw', + keyData, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); +} + +// Derive a service-specific encryption key from master key using HKDF +export async function deriveServiceKey( + masterKey: CryptoKey, + service: GoogleService | 'tokens' | 'backup' +): Promise { + if (!hasWebCrypto()) { + throw new Error('WebCrypto not available'); + } + + const encoder = new TextEncoder(); + const info = encoder.encode(`canvas-google-data-${service}`); + + // Export master key to use as HKDF base + const masterKeyRaw = await crypto.subtle.exportKey('raw', masterKey); + + // Import as HKDF key + const hkdfKey = await crypto.subtle.importKey( + 'raw', + masterKeyRaw, + 'HKDF', + false, + ['deriveKey'] + ); + + // Generate a deterministic salt based on service + const salt = encoder.encode(`canvas-salt-${service}`); + + // Derive the service-specific key + return await crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt: salt, + info: info + }, + hkdfKey, + { name: 'AES-GCM', length: 256 }, + false, // not extractable for security + ['encrypt', 'decrypt'] + ); +} + +// Encrypt data with AES-256-GCM +export async function encryptData( + data: string | ArrayBuffer, + key: CryptoKey +): Promise { + if (!hasWebCrypto()) { + throw new Error('WebCrypto not available'); + } + + // Generate random 96-bit IV (recommended for AES-GCM) + const iv = crypto.getRandomValues(new Uint8Array(12)); + + // Convert string to ArrayBuffer if needed + const dataBuffer = typeof data === 'string' + ? new TextEncoder().encode(data) + : data; + + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + dataBuffer + ); + + return { encrypted, iv }; +} + +// Decrypt data with AES-256-GCM +export async function decryptData( + encryptedData: EncryptedData, + key: CryptoKey +): Promise { + if (!hasWebCrypto()) { + throw new Error('WebCrypto not available'); + } + + return await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: new Uint8Array(encryptedData.iv) as Uint8Array }, + key, + encryptedData.encrypted + ); +} + +// Decrypt data to string (convenience method) +export async function decryptDataToString( + encryptedData: EncryptedData, + key: CryptoKey +): Promise { + const decrypted = await decryptData(encryptedData, key); + return new TextDecoder().decode(decrypted); +} + +// Encrypt multiple fields of an object +export async function encryptFields>( + obj: T, + fieldsToEncrypt: (keyof T)[], + key: CryptoKey +): Promise> { + const result: Record = {}; + + for (const [field, value] of Object.entries(obj)) { + if (fieldsToEncrypt.includes(field as keyof T) && value !== null && value !== undefined) { + const strValue = typeof value === 'string' ? value : JSON.stringify(value); + result[`encrypted${field.charAt(0).toUpperCase()}${field.slice(1)}`] = + await encryptData(strValue, key); + } else if (!fieldsToEncrypt.includes(field as keyof T)) { + result[field] = value; + } + } + + return result; +} + +// Serialize EncryptedData for IndexedDB storage +export function serializeEncryptedData(data: EncryptedData): { encrypted: ArrayBuffer; iv: number[] } { + return { + encrypted: data.encrypted, + iv: Array.from(data.iv) + }; +} + +// Deserialize EncryptedData from IndexedDB +export function deserializeEncryptedData(data: { encrypted: ArrayBuffer; iv: number[] }): EncryptedData { + return { + encrypted: data.encrypted, + iv: new Uint8Array(data.iv) + }; +} + +// Base64 URL encoding for PKCE +export function base64UrlEncode(buffer: ArrayBuffer | Uint8Array): string { + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +// Base64 URL decoding +export function base64UrlDecode(str: string): Uint8Array { + // Add padding if needed + let base64 = str.replace(/-/g, '+').replace(/_/g, '/'); + const padding = base64.length % 4; + if (padding) { + base64 += '='.repeat(4 - padding); + } + + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +// Generate PKCE code verifier (43-128 chars, URL-safe) +export function generateCodeVerifier(): string { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return base64UrlEncode(array); +} + +// Generate PKCE code challenge from verifier +export async function generateCodeChallenge(verifier: string): Promise { + if (!hasWebCrypto()) { + throw new Error('WebCrypto not available'); + } + + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const hash = await crypto.subtle.digest('SHA-256', data); + return base64UrlEncode(hash); +} + +// Derive a key from password for master key encryption (for backup) +export async function deriveKeyFromPassword( + password: string, + salt: Uint8Array +): Promise { + if (!hasWebCrypto()) { + throw new Error('WebCrypto not available'); + } + + const encoder = new TextEncoder(); + const passwordBuffer = encoder.encode(password); + + // Import password as raw key for PBKDF2 + const passwordKey = await crypto.subtle.importKey( + 'raw', + passwordBuffer, + 'PBKDF2', + false, + ['deriveKey'] + ); + + // Derive encryption key using PBKDF2 + return await crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: new Uint8Array(salt) as Uint8Array, + iterations: 100000, // High iteration count for security + hash: 'SHA-256' + }, + passwordKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); +} + +// Generate random salt for password derivation +export function generateSalt(): Uint8Array { + return crypto.getRandomValues(new Uint8Array(16)); +} + +// Encrypt master key with password-derived key for backup +export async function encryptMasterKeyWithPassword( + masterKey: CryptoKey, + password: string +): Promise<{ encryptedKey: EncryptedData; salt: Uint8Array }> { + const salt = generateSalt(); + const passwordKey = await deriveKeyFromPassword(password, salt); + const masterKeyRaw = await exportMasterKey(masterKey); + const encryptedKey = await encryptData(masterKeyRaw, passwordKey); + + return { encryptedKey, salt }; +} + +// Decrypt master key with password +export async function decryptMasterKeyWithPassword( + encryptedKey: EncryptedData, + password: string, + salt: Uint8Array +): Promise { + const passwordKey = await deriveKeyFromPassword(password, salt); + const masterKeyRaw = await decryptData(encryptedKey, passwordKey); + return await importMasterKey(masterKeyRaw); +} diff --git a/src/lib/google/importers/calendar.ts b/src/lib/google/importers/calendar.ts new file mode 100644 index 0000000..b18c6ef --- /dev/null +++ b/src/lib/google/importers/calendar.ts @@ -0,0 +1,425 @@ +// Google Calendar import with event encryption +// All data is encrypted before storage + +import type { EncryptedCalendarEvent, ImportProgress, EncryptedData } from '../types'; +import { encryptData, deriveServiceKey } from '../encryption'; +import { calendarStore, syncMetadataStore } from '../database'; +import { getAccessToken } from '../oauth'; + +const CALENDAR_API_BASE = 'https://www.googleapis.com/calendar/v3'; + +// Import options +export interface CalendarImportOptions { + maxEvents?: number; // Limit total events to import + calendarIds?: string[]; // Specific calendars (null for primary) + timeMin?: Date; // Only import events after this date + timeMax?: Date; // Only import events before this date + includeDeleted?: boolean; // Include deleted events + onProgress?: (progress: ImportProgress) => void; +} + +// Calendar API response types +interface CalendarListResponse { + items?: CalendarListEntry[]; + nextPageToken?: string; +} + +interface CalendarListEntry { + id: string; + summary?: string; + description?: string; + primary?: boolean; + backgroundColor?: string; + foregroundColor?: string; + accessRole?: string; +} + +interface EventsListResponse { + items?: CalendarEvent[]; + nextPageToken?: string; + nextSyncToken?: string; +} + +interface CalendarEvent { + id: string; + status?: string; + htmlLink?: string; + created?: string; + updated?: string; + summary?: string; + description?: string; + location?: string; + colorId?: string; + creator?: { email?: string; displayName?: string }; + organizer?: { email?: string; displayName?: string }; + start?: { date?: string; dateTime?: string; timeZone?: string }; + end?: { date?: string; dateTime?: string; timeZone?: string }; + recurrence?: string[]; + recurringEventId?: string; + attendees?: { email?: string; displayName?: string; responseStatus?: string }[]; + hangoutLink?: string; + conferenceData?: { + entryPoints?: { entryPointType?: string; uri?: string; label?: string }[]; + conferenceSolution?: { name?: string }; + }; + reminders?: { + useDefault?: boolean; + overrides?: { method: string; minutes: number }[]; + }; +} + +// Parse event time to timestamp +function parseEventTime(eventTime?: { date?: string; dateTime?: string }): number { + if (!eventTime) return 0; + + if (eventTime.dateTime) { + return new Date(eventTime.dateTime).getTime(); + } + if (eventTime.date) { + return new Date(eventTime.date).getTime(); + } + return 0; +} + +// Check if event is all-day +function isAllDayEvent(event: CalendarEvent): boolean { + return !!(event.start?.date && !event.start?.dateTime); +} + +// Get meeting link from event +function getMeetingLink(event: CalendarEvent): string | null { + // Check hangouts link + if (event.hangoutLink) { + return event.hangoutLink; + } + + // Check conference data + const videoEntry = event.conferenceData?.entryPoints?.find( + e => e.entryPointType === 'video' + ); + if (videoEntry?.uri) { + return videoEntry.uri; + } + + return null; +} + +// Main Calendar import class +export class CalendarImporter { + private accessToken: string | null = null; + private encryptionKey: CryptoKey | null = null; + private abortController: AbortController | null = null; + + constructor( + private masterKey: CryptoKey + ) {} + + // Initialize importer + async initialize(): Promise { + this.accessToken = await getAccessToken(this.masterKey); + if (!this.accessToken) { + console.error('No access token available for Calendar'); + return false; + } + + this.encryptionKey = await deriveServiceKey(this.masterKey, 'calendar'); + return true; + } + + // Abort current import + abort(): void { + this.abortController?.abort(); + } + + // Import calendar events + async import(options: CalendarImportOptions = {}): Promise { + const progress: ImportProgress = { + service: 'calendar', + total: 0, + imported: 0, + status: 'importing' + }; + + if (!await this.initialize()) { + progress.status = 'error'; + progress.errorMessage = 'Failed to initialize Calendar importer'; + return progress; + } + + this.abortController = new AbortController(); + progress.startedAt = Date.now(); + + try { + // Get calendars to import from + const calendarIds = options.calendarIds?.length + ? options.calendarIds + : ['primary']; + + // Default time range: 2 years back, 1 year forward + const timeMin = options.timeMin || new Date(Date.now() - 2 * 365 * 24 * 60 * 60 * 1000); + const timeMax = options.timeMax || new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); + + const eventBatch: EncryptedCalendarEvent[] = []; + + for (const calendarId of calendarIds) { + if (this.abortController.signal.aborted) { + progress.status = 'paused'; + break; + } + + let pageToken: string | undefined; + + do { + if (this.abortController.signal.aborted) break; + + const params: Record = { + maxResults: '250', + singleEvents: 'true', // Expand recurring events + orderBy: 'startTime', + timeMin: timeMin.toISOString(), + timeMax: timeMax.toISOString() + }; + if (pageToken) { + params.pageToken = pageToken; + } + if (options.includeDeleted) { + params.showDeleted = 'true'; + } + + const response = await this.fetchEvents(calendarId, params); + + if (!response.items?.length) { + break; + } + + // Update total + progress.total += response.items.length; + + // Process events + for (const event of response.items) { + if (this.abortController.signal.aborted) break; + + // Skip cancelled events unless including deleted + if (event.status === 'cancelled' && !options.includeDeleted) { + continue; + } + + const encrypted = await this.processEvent(event, calendarId); + if (encrypted) { + eventBatch.push(encrypted); + progress.imported++; + + // Save batch every 50 events + if (eventBatch.length >= 50) { + await calendarStore.putBatch(eventBatch); + eventBatch.length = 0; + } + + options.onProgress?.(progress); + } + + // Check limit + if (options.maxEvents && progress.imported >= options.maxEvents) { + break; + } + } + + pageToken = response.nextPageToken; + + // Check limit + if (options.maxEvents && progress.imported >= options.maxEvents) { + break; + } + + } while (pageToken); + + // Check limit + if (options.maxEvents && progress.imported >= options.maxEvents) { + break; + } + } + + // Save remaining events + if (eventBatch.length > 0) { + await calendarStore.putBatch(eventBatch); + } + + progress.status = 'completed'; + progress.completedAt = Date.now(); + await syncMetadataStore.markComplete('calendar', progress.imported); + + } catch (error) { + console.error('Calendar import error:', error); + progress.status = 'error'; + progress.errorMessage = error instanceof Error ? error.message : 'Unknown error'; + await syncMetadataStore.markError('calendar', progress.errorMessage); + } + + options.onProgress?.(progress); + return progress; + } + + // Fetch events from Calendar API + private async fetchEvents( + calendarId: string, + params: Record + ): Promise { + const url = new URL(`${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events`); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${this.accessToken}` + }, + signal: this.abortController?.signal + }); + + if (!response.ok) { + throw new Error(`Calendar API error: ${response.status} ${response.statusText}`); + } + + return response.json(); + } + + // Process a single event + private async processEvent( + event: CalendarEvent, + calendarId: string + ): Promise { + if (!this.encryptionKey) { + throw new Error('Encryption key not initialized'); + } + + // Helper to encrypt + const encrypt = async (data: string): Promise => { + return encryptData(data, this.encryptionKey!); + }; + + const startTime = parseEventTime(event.start); + const endTime = parseEventTime(event.end); + const timezone = event.start?.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone; + const meetingLink = getMeetingLink(event); + + // Serialize attendees for encryption + const attendeesData = event.attendees + ? JSON.stringify(event.attendees) + : null; + + // Serialize recurrence for encryption + const recurrenceData = event.recurrence + ? JSON.stringify(event.recurrence) + : null; + + // Get reminders + const reminders: { method: string; minutes: number }[] = []; + if (event.reminders?.overrides) { + reminders.push(...event.reminders.overrides); + } else if (event.reminders?.useDefault) { + // Default reminders are typically 10 and 30 minutes + reminders.push({ method: 'popup', minutes: 10 }); + } + + return { + id: event.id, + calendarId, + encryptedSummary: await encrypt(event.summary || ''), + encryptedDescription: event.description ? await encrypt(event.description) : null, + encryptedLocation: event.location ? await encrypt(event.location) : null, + startTime, + endTime, + isAllDay: isAllDayEvent(event), + timezone, + isRecurring: !!event.recurringEventId || !!event.recurrence?.length, + encryptedRecurrence: recurrenceData ? await encrypt(recurrenceData) : null, + encryptedAttendees: attendeesData ? await encrypt(attendeesData) : null, + reminders, + encryptedMeetingLink: meetingLink ? await encrypt(meetingLink) : null, + syncedAt: Date.now() + }; + } + + // List available calendars + async listCalendars(): Promise<{ + id: string; + name: string; + primary: boolean; + accessRole: string; + }[]> { + if (!await this.initialize()) { + return []; + } + + try { + const calendars: CalendarListEntry[] = []; + let pageToken: string | undefined; + + do { + const url = new URL(`${CALENDAR_API_BASE}/users/me/calendarList`); + url.searchParams.set('maxResults', '100'); + if (pageToken) { + url.searchParams.set('pageToken', pageToken); + } + + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${this.accessToken}` + } + }); + + if (!response.ok) break; + + const data: CalendarListResponse = await response.json(); + if (data.items) { + calendars.push(...data.items); + } + pageToken = data.nextPageToken; + + } while (pageToken); + + return calendars.map(c => ({ + id: c.id, + name: c.summary || 'Untitled', + primary: c.primary || false, + accessRole: c.accessRole || 'reader' + })); + + } catch (error) { + console.error('List calendars error:', error); + return []; + } + } + + // Get upcoming events (decrypted, for quick display) + async getUpcomingEvents(limit: number = 10): Promise { + if (!await this.initialize()) { + return []; + } + + try { + const params: Record = { + maxResults: String(limit), + singleEvents: 'true', + orderBy: 'startTime', + timeMin: new Date().toISOString() + }; + + const response = await this.fetchEvents('primary', params); + return response.items || []; + + } catch (error) { + console.error('Get upcoming events error:', error); + return []; + } + } +} + +// Convenience function +export async function importCalendar( + masterKey: CryptoKey, + options: CalendarImportOptions = {} +): Promise { + const importer = new CalendarImporter(masterKey); + return importer.import(options); +} diff --git a/src/lib/google/importers/drive.ts b/src/lib/google/importers/drive.ts new file mode 100644 index 0000000..a7f3edd --- /dev/null +++ b/src/lib/google/importers/drive.ts @@ -0,0 +1,406 @@ +// Google Drive import with folder navigation and progress tracking +// All data is encrypted before storage + +import type { EncryptedDriveDocument, ImportProgress, EncryptedData } from '../types'; +import { encryptData, deriveServiceKey } from '../encryption'; +import { driveStore, syncMetadataStore } from '../database'; +import { getAccessToken } from '../oauth'; + +const DRIVE_API_BASE = 'https://www.googleapis.com/drive/v3'; + +// Import options +export interface DriveImportOptions { + maxFiles?: number; // Limit total files to import + folderId?: string; // Start from specific folder (null for root) + mimeTypesFilter?: string[]; // Only import these MIME types + includeShared?: boolean; // Include shared files + includeTrashed?: boolean; // Include trashed files + exportFormats?: Record; // Google Docs export formats + onProgress?: (progress: ImportProgress) => void; +} + +// Drive file list response +interface DriveFileListResponse { + files?: DriveFile[]; + nextPageToken?: string; +} + +// Drive file metadata +interface DriveFile { + id: string; + name: string; + mimeType: string; + size?: string; + modifiedTime?: string; + createdTime?: string; + parents?: string[]; + shared?: boolean; + trashed?: boolean; + webViewLink?: string; + thumbnailLink?: string; +} + +// Default export formats for Google Docs +const DEFAULT_EXPORT_FORMATS: Record = { + 'application/vnd.google-apps.document': 'text/markdown', + 'application/vnd.google-apps.spreadsheet': 'text/csv', + 'application/vnd.google-apps.presentation': 'application/pdf', + 'application/vnd.google-apps.drawing': 'image/png' +}; + +// Determine content strategy based on file size and type +function getContentStrategy(file: DriveFile): 'inline' | 'reference' | 'chunked' { + const size = parseInt(file.size || '0'); + + // Google Docs don't have a size, always inline + if (file.mimeType.startsWith('application/vnd.google-apps.')) { + return 'inline'; + } + + // Small files (< 1MB) inline + if (size < 1024 * 1024) { + return 'inline'; + } + + // Medium files (1-10MB) chunked + if (size < 10 * 1024 * 1024) { + return 'chunked'; + } + + // Large files just store reference + return 'reference'; +} + +// Check if file is a Google Workspace file +function isGoogleWorkspaceFile(mimeType: string): boolean { + return mimeType.startsWith('application/vnd.google-apps.'); +} + +// Main Drive import class +export class DriveImporter { + private accessToken: string | null = null; + private encryptionKey: CryptoKey | null = null; + private abortController: AbortController | null = null; + + constructor( + private masterKey: CryptoKey + ) {} + + // Initialize importer + async initialize(): Promise { + this.accessToken = await getAccessToken(this.masterKey); + if (!this.accessToken) { + console.error('No access token available for Drive'); + return false; + } + + this.encryptionKey = await deriveServiceKey(this.masterKey, 'drive'); + return true; + } + + // Abort current import + abort(): void { + this.abortController?.abort(); + } + + // Import Drive files + async import(options: DriveImportOptions = {}): Promise { + const progress: ImportProgress = { + service: 'drive', + total: 0, + imported: 0, + status: 'importing' + }; + + if (!await this.initialize()) { + progress.status = 'error'; + progress.errorMessage = 'Failed to initialize Drive importer'; + return progress; + } + + this.abortController = new AbortController(); + progress.startedAt = Date.now(); + + const exportFormats = options.exportFormats || DEFAULT_EXPORT_FORMATS; + + try { + // Build query + const queryParts: string[] = []; + if (options.folderId) { + queryParts.push(`'${options.folderId}' in parents`); + } + if (options.mimeTypesFilter?.length) { + const mimeQuery = options.mimeTypesFilter + .map(m => `mimeType='${m}'`) + .join(' or '); + queryParts.push(`(${mimeQuery})`); + } + if (!options.includeTrashed) { + queryParts.push('trashed=false'); + } + + // Get file list + let pageToken: string | undefined; + const batchSize = 100; + const fileBatch: EncryptedDriveDocument[] = []; + + do { + if (this.abortController.signal.aborted) { + progress.status = 'paused'; + break; + } + + const params: Record = { + pageSize: String(batchSize), + fields: 'nextPageToken,files(id,name,mimeType,size,modifiedTime,parents,shared,trashed,thumbnailLink)', + q: queryParts.join(' and ') || 'trashed=false' + }; + if (pageToken) { + params.pageToken = pageToken; + } + + const listResponse = await this.fetchApi('/files', params); + + if (!listResponse.files?.length) { + break; + } + + // Update total on first page + if (progress.total === 0) { + progress.total = listResponse.files.length; + } + + // Process files + for (const file of listResponse.files) { + if (this.abortController.signal.aborted) break; + + // Skip shared files if not requested + if (file.shared && !options.includeShared) { + continue; + } + + const encrypted = await this.processFile(file, exportFormats); + if (encrypted) { + fileBatch.push(encrypted); + progress.imported++; + + // Save batch every 25 files + if (fileBatch.length >= 25) { + await driveStore.putBatch(fileBatch); + fileBatch.length = 0; + } + + options.onProgress?.(progress); + } + + // Check limit + if (options.maxFiles && progress.imported >= options.maxFiles) { + break; + } + } + + pageToken = listResponse.nextPageToken; + + // Check limit + if (options.maxFiles && progress.imported >= options.maxFiles) { + break; + } + + } while (pageToken); + + // Save remaining files + if (fileBatch.length > 0) { + await driveStore.putBatch(fileBatch); + } + + progress.status = 'completed'; + progress.completedAt = Date.now(); + await syncMetadataStore.markComplete('drive', progress.imported); + + } catch (error) { + console.error('Drive import error:', error); + progress.status = 'error'; + progress.errorMessage = error instanceof Error ? error.message : 'Unknown error'; + await syncMetadataStore.markError('drive', progress.errorMessage); + } + + options.onProgress?.(progress); + return progress; + } + + // Fetch from Drive API + private async fetchApi( + endpoint: string, + params: Record = {} + ): Promise { + const url = new URL(`${DRIVE_API_BASE}${endpoint}`); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${this.accessToken}` + }, + signal: this.abortController?.signal + }); + + if (!response.ok) { + throw new Error(`Drive API error: ${response.status} ${response.statusText}`); + } + + return response.json(); + } + + // Process a single file + private async processFile( + file: DriveFile, + exportFormats: Record + ): Promise { + if (!this.encryptionKey) { + throw new Error('Encryption key not initialized'); + } + + const strategy = getContentStrategy(file); + let content: string | null = null; + let preview: ArrayBuffer | null = null; + + try { + // Get content based on strategy + if (strategy === 'inline' || strategy === 'chunked') { + if (isGoogleWorkspaceFile(file.mimeType)) { + // Export Google Workspace file + const exportFormat = exportFormats[file.mimeType]; + if (exportFormat) { + content = await this.exportFile(file.id, exportFormat); + } + } else { + // Download regular file + content = await this.downloadFile(file.id); + } + } + + // Get thumbnail if available + if (file.thumbnailLink) { + try { + preview = await this.fetchThumbnail(file.thumbnailLink); + } catch { + // Thumbnail fetch failed, continue without it + } + } + + } catch (error) { + console.warn(`Failed to get content for file ${file.name}:`, error); + // Continue with reference-only storage + } + + // Helper to encrypt + const encrypt = async (data: string): Promise => { + return encryptData(data, this.encryptionKey!); + }; + + return { + id: file.id, + encryptedName: await encrypt(file.name), + encryptedMimeType: await encrypt(file.mimeType), + encryptedContent: content ? await encrypt(content) : null, + encryptedPreview: preview ? await encryptData(preview, this.encryptionKey) : null, + contentStrategy: strategy, + parentId: file.parents?.[0] || null, + encryptedPath: await encrypt(file.name), // TODO: build full path + isShared: file.shared || false, + modifiedTime: new Date(file.modifiedTime || 0).getTime(), + size: parseInt(file.size || '0'), + syncedAt: Date.now() + }; + } + + // Export a Google Workspace file + private async exportFile(fileId: string, mimeType: string): Promise { + const response = await fetch( + `${DRIVE_API_BASE}/files/${fileId}/export?mimeType=${encodeURIComponent(mimeType)}`, + { + headers: { + Authorization: `Bearer ${this.accessToken}` + }, + signal: this.abortController?.signal + } + ); + + if (!response.ok) { + throw new Error(`Export failed: ${response.status}`); + } + + return response.text(); + } + + // Download a regular file + private async downloadFile(fileId: string): Promise { + const response = await fetch( + `${DRIVE_API_BASE}/files/${fileId}?alt=media`, + { + headers: { + Authorization: `Bearer ${this.accessToken}` + }, + signal: this.abortController?.signal + } + ); + + if (!response.ok) { + throw new Error(`Download failed: ${response.status}`); + } + + return response.text(); + } + + // Fetch thumbnail + private async fetchThumbnail(thumbnailLink: string): Promise { + const response = await fetch(thumbnailLink, { + headers: { + Authorization: `Bearer ${this.accessToken}` + }, + signal: this.abortController?.signal + }); + + if (!response.ok) { + throw new Error(`Thumbnail fetch failed: ${response.status}`); + } + + return response.arrayBuffer(); + } + + // List folders for navigation + async listFolders(parentId?: string): Promise<{ id: string; name: string }[]> { + if (!await this.initialize()) { + return []; + } + + const query = [ + "mimeType='application/vnd.google-apps.folder'", + 'trashed=false', + parentId ? `'${parentId}' in parents` : "'root' in parents" + ].join(' and '); + + try { + const response = await this.fetchApi('/files', { + q: query, + fields: 'files(id,name)', + pageSize: '100' + }); + + return response.files?.map(f => ({ id: f.id, name: f.name })) || []; + } catch (error) { + console.error('List folders error:', error); + return []; + } + } +} + +// Convenience function +export async function importDrive( + masterKey: CryptoKey, + options: DriveImportOptions = {} +): Promise { + const importer = new DriveImporter(masterKey); + return importer.import(options); +} diff --git a/src/lib/google/importers/gmail.ts b/src/lib/google/importers/gmail.ts new file mode 100644 index 0000000..d9ad729 --- /dev/null +++ b/src/lib/google/importers/gmail.ts @@ -0,0 +1,409 @@ +// Gmail import with pagination and progress tracking +// All data is encrypted before storage + +import type { EncryptedEmailStore, ImportProgress, EncryptedData } from '../types'; +import { encryptData, deriveServiceKey } from '../encryption'; +import { gmailStore, syncMetadataStore } from '../database'; +import { getAccessToken } from '../oauth'; + +const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me'; + +// Import options +export interface GmailImportOptions { + maxMessages?: number; // Limit total messages to import + labelsFilter?: string[]; // Only import from these labels + dateAfter?: Date; // Only import messages after this date + dateBefore?: Date; // Only import messages before this date + includeSpam?: boolean; // Include spam folder + includeTrash?: boolean; // Include trash folder + onProgress?: (progress: ImportProgress) => void; // Progress callback +} + +// Gmail message list response +interface GmailMessageListResponse { + messages?: { id: string; threadId: string }[]; + nextPageToken?: string; + resultSizeEstimate?: number; +} + +// Gmail message response +interface GmailMessageResponse { + id: string; + threadId: string; + labelIds?: string[]; + snippet?: string; + historyId?: string; + internalDate?: string; + payload?: { + mimeType?: string; + headers?: { name: string; value: string }[]; + body?: { data?: string; size?: number }; + parts?: GmailMessagePart[]; + }; +} + +interface GmailMessagePart { + mimeType?: string; + body?: { data?: string; size?: number }; + parts?: GmailMessagePart[]; +} + +// Extract header value from message +function getHeader(message: GmailMessageResponse, name: string): string { + const header = message.payload?.headers?.find( + h => h.name.toLowerCase() === name.toLowerCase() + ); + return header?.value || ''; +} + +// Decode base64url encoded content +function decodeBase64Url(data: string): string { + try { + // Replace URL-safe characters and add padding + const base64 = data.replace(/-/g, '+').replace(/_/g, '/'); + const padding = base64.length % 4; + const paddedBase64 = padding ? base64 + '='.repeat(4 - padding) : base64; + return atob(paddedBase64); + } catch { + return ''; + } +} + +// Extract message body from parts +function extractBody(message: GmailMessageResponse): string { + const payload = message.payload; + if (!payload) return ''; + + // Check direct body + if (payload.body?.data) { + return decodeBase64Url(payload.body.data); + } + + // Check parts for text/plain or text/html + if (payload.parts) { + return extractBodyFromParts(payload.parts); + } + + return ''; +} + +function extractBodyFromParts(parts: GmailMessagePart[]): string { + // Prefer text/plain, fall back to text/html + let plainText = ''; + let htmlText = ''; + + for (const part of parts) { + if (part.mimeType === 'text/plain' && part.body?.data) { + plainText = decodeBase64Url(part.body.data); + } else if (part.mimeType === 'text/html' && part.body?.data) { + htmlText = decodeBase64Url(part.body.data); + } else if (part.parts) { + // Recursively check nested parts + const nested = extractBodyFromParts(part.parts); + if (nested) return nested; + } + } + + return plainText || htmlText; +} + +// Check if message has attachments +function hasAttachments(message: GmailMessageResponse): boolean { + const parts = message.payload?.parts || []; + return parts.some(part => + part.body?.size && part.body.size > 0 && + part.mimeType !== 'text/plain' && part.mimeType !== 'text/html' + ); +} + +// Build query string from options +function buildQuery(options: GmailImportOptions): string { + const queryParts: string[] = []; + + if (options.dateAfter) { + queryParts.push(`after:${Math.floor(options.dateAfter.getTime() / 1000)}`); + } + if (options.dateBefore) { + queryParts.push(`before:${Math.floor(options.dateBefore.getTime() / 1000)}`); + } + if (!options.includeSpam) { + queryParts.push('-in:spam'); + } + if (!options.includeTrash) { + queryParts.push('-in:trash'); + } + + return queryParts.join(' '); +} + +// Main Gmail import class +export class GmailImporter { + private accessToken: string | null = null; + private encryptionKey: CryptoKey | null = null; + private abortController: AbortController | null = null; + + constructor( + private masterKey: CryptoKey + ) {} + + // Initialize importer (get token and derive key) + async initialize(): Promise { + this.accessToken = await getAccessToken(this.masterKey); + if (!this.accessToken) { + console.error('No access token available for Gmail'); + return false; + } + + this.encryptionKey = await deriveServiceKey(this.masterKey, 'gmail'); + return true; + } + + // Abort current import + abort(): void { + this.abortController?.abort(); + } + + // Import Gmail messages + async import(options: GmailImportOptions = {}): Promise { + const progress: ImportProgress = { + service: 'gmail', + total: 0, + imported: 0, + status: 'importing' + }; + + if (!await this.initialize()) { + progress.status = 'error'; + progress.errorMessage = 'Failed to initialize Gmail importer'; + return progress; + } + + this.abortController = new AbortController(); + progress.startedAt = Date.now(); + + try { + // First, get total count + const countResponse = await this.fetchApi('/messages', { + maxResults: '1', + q: buildQuery(options) + }); + + progress.total = countResponse.resultSizeEstimate || 0; + if (options.maxMessages) { + progress.total = Math.min(progress.total, options.maxMessages); + } + + options.onProgress?.(progress); + + // Fetch messages with pagination + let pageToken: string | undefined; + const batchSize = 100; + const messageBatch: EncryptedEmailStore[] = []; + + do { + // Check for abort + if (this.abortController.signal.aborted) { + progress.status = 'paused'; + break; + } + + // Fetch message list + const listParams: Record = { + maxResults: String(batchSize), + q: buildQuery(options) + }; + if (pageToken) { + listParams.pageToken = pageToken; + } + if (options.labelsFilter?.length) { + listParams.labelIds = options.labelsFilter.join(','); + } + + const listResponse: GmailMessageListResponse = await this.fetchApi('/messages', listParams); + + if (!listResponse.messages?.length) { + break; + } + + // Fetch full message details in parallel (batches of 10) + const messages = listResponse.messages; + for (let i = 0; i < messages.length; i += 10) { + if (this.abortController.signal.aborted) break; + + const batch = messages.slice(i, i + 10); + const fullMessages = await Promise.all( + batch.map(msg => this.fetchMessage(msg.id)) + ); + + // Encrypt and store each message + for (const message of fullMessages) { + if (message) { + const encrypted = await this.encryptMessage(message); + messageBatch.push(encrypted); + progress.imported++; + + // Save batch every 50 messages + if (messageBatch.length >= 50) { + await gmailStore.putBatch(messageBatch); + messageBatch.length = 0; + } + + options.onProgress?.(progress); + + // Check max messages limit + if (options.maxMessages && progress.imported >= options.maxMessages) { + break; + } + } + } + + // Small delay to avoid rate limiting + await new Promise(r => setTimeout(r, 50)); + } + + pageToken = listResponse.nextPageToken; + + // Check max messages limit + if (options.maxMessages && progress.imported >= options.maxMessages) { + break; + } + + } while (pageToken); + + // Save remaining messages + if (messageBatch.length > 0) { + await gmailStore.putBatch(messageBatch); + } + + // Update sync metadata + progress.status = 'completed'; + progress.completedAt = Date.now(); + await syncMetadataStore.markComplete('gmail', progress.imported); + + } catch (error) { + console.error('Gmail import error:', error); + progress.status = 'error'; + progress.errorMessage = error instanceof Error ? error.message : 'Unknown error'; + await syncMetadataStore.markError('gmail', progress.errorMessage); + } + + options.onProgress?.(progress); + return progress; + } + + // Fetch from Gmail API + private async fetchApi( + endpoint: string, + params: Record = {} + ): Promise { + const url = new URL(`${GMAIL_API_BASE}${endpoint}`); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${this.accessToken}` + }, + signal: this.abortController?.signal + }); + + if (!response.ok) { + throw new Error(`Gmail API error: ${response.status} ${response.statusText}`); + } + + return response.json(); + } + + // Fetch a single message with full content + private async fetchMessage(messageId: string): Promise { + try { + const response = await fetch( + `${GMAIL_API_BASE}/messages/${messageId}?format=full`, + { + headers: { + Authorization: `Bearer ${this.accessToken}` + }, + signal: this.abortController?.signal + } + ); + + if (!response.ok) { + console.warn(`Failed to fetch message ${messageId}`); + return null; + } + + return response.json(); + } catch (error) { + console.warn(`Error fetching message ${messageId}:`, error); + return null; + } + } + + // Encrypt a message for storage + private async encryptMessage(message: GmailMessageResponse): Promise { + if (!this.encryptionKey) { + throw new Error('Encryption key not initialized'); + } + + const subject = getHeader(message, 'Subject'); + const from = getHeader(message, 'From'); + const to = getHeader(message, 'To'); + const body = extractBody(message); + const snippet = message.snippet || ''; + + // Helper to encrypt with null handling + const encrypt = async (data: string): Promise => { + return encryptData(data, this.encryptionKey!); + }; + + return { + id: message.id, + threadId: message.threadId, + encryptedSubject: await encrypt(subject), + encryptedBody: await encrypt(body), + encryptedFrom: await encrypt(from), + encryptedTo: await encrypt(to), + date: parseInt(message.internalDate || '0'), + labels: message.labelIds || [], + hasAttachments: hasAttachments(message), + encryptedSnippet: await encrypt(snippet), + syncedAt: Date.now(), + localOnly: true + }; + } + + // Get Gmail labels + async getLabels(): Promise<{ id: string; name: string; type: string }[]> { + if (!await this.initialize()) { + return []; + } + + try { + const response = await fetch(`${GMAIL_API_BASE}/labels`, { + headers: { + Authorization: `Bearer ${this.accessToken}` + } + }); + + if (!response.ok) { + return []; + } + + const data = await response.json() as { labels?: { id: string; name: string; type: string }[] }; + return data.labels || []; + } catch (error) { + console.error('Get labels error:', error); + return []; + } + } +} + +// Convenience function to create and run importer +export async function importGmail( + masterKey: CryptoKey, + options: GmailImportOptions = {} +): Promise { + const importer = new GmailImporter(masterKey); + return importer.import(options); +} diff --git a/src/lib/google/importers/index.ts b/src/lib/google/importers/index.ts new file mode 100644 index 0000000..60aec7e --- /dev/null +++ b/src/lib/google/importers/index.ts @@ -0,0 +1,5 @@ +// Export all importers +export { GmailImporter, importGmail, type GmailImportOptions } from './gmail'; +export { DriveImporter, importDrive, type DriveImportOptions } from './drive'; +export { PhotosImporter, importPhotos, type PhotosImportOptions } from './photos'; +export { CalendarImporter, importCalendar, type CalendarImportOptions } from './calendar'; diff --git a/src/lib/google/importers/photos.ts b/src/lib/google/importers/photos.ts new file mode 100644 index 0000000..7752554 --- /dev/null +++ b/src/lib/google/importers/photos.ts @@ -0,0 +1,424 @@ +// Google Photos import with thumbnail storage +// Full resolution images are NOT stored locally - fetch on demand +// All data is encrypted before storage + +import type { EncryptedPhotoReference, ImportProgress, EncryptedData } from '../types'; +import { encryptData, deriveServiceKey } from '../encryption'; +import { photosStore, syncMetadataStore } from '../database'; +import { getAccessToken } from '../oauth'; + +const PHOTOS_API_BASE = 'https://photoslibrary.googleapis.com/v1'; + +// Import options +export interface PhotosImportOptions { + maxPhotos?: number; // Limit total photos to import + albumId?: string; // Only import from specific album + dateAfter?: Date; // Only import photos after this date + dateBefore?: Date; // Only import photos before this date + mediaTypes?: ('image' | 'video')[]; // Filter by media type + thumbnailSize?: number; // Thumbnail width (default 256) + onProgress?: (progress: ImportProgress) => void; +} + +// Photos API response types +interface PhotosListResponse { + mediaItems?: PhotosMediaItem[]; + nextPageToken?: string; +} + +interface PhotosMediaItem { + id: string; + productUrl?: string; + baseUrl?: string; + mimeType?: string; + filename?: string; + description?: string; + mediaMetadata?: { + creationTime?: string; + width?: string; + height?: string; + photo?: { + cameraMake?: string; + cameraModel?: string; + focalLength?: number; + apertureFNumber?: number; + isoEquivalent?: number; + }; + video?: { + fps?: number; + status?: string; + }; + }; + contributorInfo?: { + profilePictureBaseUrl?: string; + displayName?: string; + }; +} + +interface PhotosAlbum { + id: string; + title?: string; + productUrl?: string; + mediaItemsCount?: string; + coverPhotoBaseUrl?: string; + coverPhotoMediaItemId?: string; +} + +// Main Photos import class +export class PhotosImporter { + private accessToken: string | null = null; + private encryptionKey: CryptoKey | null = null; + private abortController: AbortController | null = null; + + constructor( + private masterKey: CryptoKey + ) {} + + // Initialize importer + async initialize(): Promise { + this.accessToken = await getAccessToken(this.masterKey); + if (!this.accessToken) { + console.error('No access token available for Photos'); + return false; + } + + this.encryptionKey = await deriveServiceKey(this.masterKey, 'photos'); + return true; + } + + // Abort current import + abort(): void { + this.abortController?.abort(); + } + + // Import photos + async import(options: PhotosImportOptions = {}): Promise { + const progress: ImportProgress = { + service: 'photos', + total: 0, + imported: 0, + status: 'importing' + }; + + if (!await this.initialize()) { + progress.status = 'error'; + progress.errorMessage = 'Failed to initialize Photos importer'; + return progress; + } + + this.abortController = new AbortController(); + progress.startedAt = Date.now(); + + const thumbnailSize = options.thumbnailSize || 256; + + try { + let pageToken: string | undefined; + const batchSize = 100; + const photoBatch: EncryptedPhotoReference[] = []; + + do { + if (this.abortController.signal.aborted) { + progress.status = 'paused'; + break; + } + + // Fetch media items + const listResponse = await this.fetchMediaItems(options, pageToken, batchSize); + + if (!listResponse.mediaItems?.length) { + break; + } + + // Update total on first page + if (progress.total === 0) { + progress.total = listResponse.mediaItems.length; + } + + // Process media items + for (const item of listResponse.mediaItems) { + if (this.abortController.signal.aborted) break; + + // Filter by media type if specified + const isVideo = !!item.mediaMetadata?.video; + const mediaType = isVideo ? 'video' : 'image'; + + if (options.mediaTypes?.length && !options.mediaTypes.includes(mediaType)) { + continue; + } + + // Filter by date if specified + const creationTime = item.mediaMetadata?.creationTime + ? new Date(item.mediaMetadata.creationTime).getTime() + : 0; + + if (options.dateAfter && creationTime < options.dateAfter.getTime()) { + continue; + } + if (options.dateBefore && creationTime > options.dateBefore.getTime()) { + continue; + } + + const encrypted = await this.processMediaItem(item, thumbnailSize); + if (encrypted) { + photoBatch.push(encrypted); + progress.imported++; + + // Save batch every 25 items + if (photoBatch.length >= 25) { + await photosStore.putBatch(photoBatch); + photoBatch.length = 0; + } + + options.onProgress?.(progress); + } + + // Check limit + if (options.maxPhotos && progress.imported >= options.maxPhotos) { + break; + } + + // Small delay for rate limiting + await new Promise(r => setTimeout(r, 20)); + } + + pageToken = listResponse.nextPageToken; + + // Check limit + if (options.maxPhotos && progress.imported >= options.maxPhotos) { + break; + } + + } while (pageToken); + + // Save remaining photos + if (photoBatch.length > 0) { + await photosStore.putBatch(photoBatch); + } + + progress.status = 'completed'; + progress.completedAt = Date.now(); + await syncMetadataStore.markComplete('photos', progress.imported); + + } catch (error) { + console.error('Photos import error:', error); + progress.status = 'error'; + progress.errorMessage = error instanceof Error ? error.message : 'Unknown error'; + await syncMetadataStore.markError('photos', progress.errorMessage); + } + + options.onProgress?.(progress); + return progress; + } + + // Fetch media items from API + private async fetchMediaItems( + options: PhotosImportOptions, + pageToken: string | undefined, + pageSize: number + ): Promise { + // If album specified, use album search + if (options.albumId) { + return this.searchByAlbum(options.albumId, pageToken, pageSize); + } + + // Otherwise use list all + const url = new URL(`${PHOTOS_API_BASE}/mediaItems`); + url.searchParams.set('pageSize', String(pageSize)); + if (pageToken) { + url.searchParams.set('pageToken', pageToken); + } + + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${this.accessToken}` + }, + signal: this.abortController?.signal + }); + + if (!response.ok) { + throw new Error(`Photos API error: ${response.status} ${response.statusText}`); + } + + return response.json(); + } + + // Search by album + private async searchByAlbum( + albumId: string, + pageToken: string | undefined, + pageSize: number + ): Promise { + const body: Record = { + albumId, + pageSize + }; + if (pageToken) { + body.pageToken = pageToken; + } + + const response = await fetch(`${PHOTOS_API_BASE}/mediaItems:search`, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.accessToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body), + signal: this.abortController?.signal + }); + + if (!response.ok) { + throw new Error(`Photos search error: ${response.status}`); + } + + return response.json(); + } + + // Process a single media item + private async processMediaItem( + item: PhotosMediaItem, + thumbnailSize: number + ): Promise { + if (!this.encryptionKey) { + throw new Error('Encryption key not initialized'); + } + + const isVideo = !!item.mediaMetadata?.video; + const mediaType: 'image' | 'video' = isVideo ? 'video' : 'image'; + + // Fetch thumbnail + let thumbnailData: EncryptedData | null = null; + if (item.baseUrl) { + try { + const thumbnailUrl = isVideo + ? `${item.baseUrl}=w${thumbnailSize}-h${thumbnailSize}` // Video thumbnail + : `${item.baseUrl}=w${thumbnailSize}-h${thumbnailSize}-c`; // Image thumbnail (cropped) + + const thumbResponse = await fetch(thumbnailUrl, { + signal: this.abortController?.signal + }); + + if (thumbResponse.ok) { + const thumbBuffer = await thumbResponse.arrayBuffer(); + thumbnailData = await encryptData(thumbBuffer, this.encryptionKey); + } + } catch (error) { + console.warn(`Failed to fetch thumbnail for ${item.id}:`, error); + } + } + + // Helper to encrypt + const encrypt = async (data: string): Promise => { + return encryptData(data, this.encryptionKey!); + }; + + const width = parseInt(item.mediaMetadata?.width || '0'); + const height = parseInt(item.mediaMetadata?.height || '0'); + const creationTime = item.mediaMetadata?.creationTime + ? new Date(item.mediaMetadata.creationTime).getTime() + : Date.now(); + + return { + id: item.id, + encryptedFilename: await encrypt(item.filename || ''), + encryptedDescription: item.description ? await encrypt(item.description) : null, + thumbnail: thumbnailData ? { + width: Math.min(thumbnailSize, width), + height: Math.min(thumbnailSize, height), + encryptedData: thumbnailData + } : null, + fullResolution: { + width, + height + }, + mediaType, + creationTime, + albumIds: [], // Would need separate album lookup + encryptedLocation: null, // Location data not available in basic API + syncedAt: Date.now() + }; + } + + // List albums + async listAlbums(): Promise<{ id: string; title: string; count: number }[]> { + if (!await this.initialize()) { + return []; + } + + try { + const albums: PhotosAlbum[] = []; + let pageToken: string | undefined; + + do { + const url = new URL(`${PHOTOS_API_BASE}/albums`); + url.searchParams.set('pageSize', '50'); + if (pageToken) { + url.searchParams.set('pageToken', pageToken); + } + + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${this.accessToken}` + } + }); + + if (!response.ok) break; + + const data = await response.json() as { albums?: PhotosAlbum[]; nextPageToken?: string }; + if (data.albums) { + albums.push(...data.albums); + } + pageToken = data.nextPageToken; + + } while (pageToken); + + return albums.map(a => ({ + id: a.id, + title: a.title || 'Untitled', + count: parseInt(a.mediaItemsCount || '0') + })); + + } catch (error) { + console.error('List albums error:', error); + return []; + } + } + + // Get full resolution URL for a photo (requires fresh baseUrl) + async getFullResolutionUrl(mediaItemId: string): Promise { + if (!await this.initialize()) { + return null; + } + + try { + const response = await fetch(`${PHOTOS_API_BASE}/mediaItems/${mediaItemId}`, { + headers: { + Authorization: `Bearer ${this.accessToken}` + } + }); + + if (!response.ok) return null; + + const item: PhotosMediaItem = await response.json(); + + if (!item.baseUrl) return null; + + // Full resolution URL with download parameter + const isVideo = !!item.mediaMetadata?.video; + return isVideo + ? `${item.baseUrl}=dv` // Download video + : `${item.baseUrl}=d`; // Download image + } catch (error) { + console.error('Get full resolution error:', error); + return null; + } + } +} + +// Convenience function +export async function importPhotos( + masterKey: CryptoKey, + options: PhotosImportOptions = {} +): Promise { + const importer = new PhotosImporter(masterKey); + return importer.import(options); +} diff --git a/src/lib/google/index.ts b/src/lib/google/index.ts new file mode 100644 index 0000000..4c2944b --- /dev/null +++ b/src/lib/google/index.ts @@ -0,0 +1,287 @@ +// Google Data Sovereignty Module +// Local-first, encrypted storage for Google Workspace data + +// Types +export type { + EncryptedData, + EncryptedEmailStore, + EncryptedDriveDocument, + EncryptedPhotoReference, + EncryptedCalendarEvent, + SyncMetadata, + EncryptionMetadata, + EncryptedTokens, + ImportProgress, + StorageQuotaInfo, + ShareableItem, + GoogleService +} from './types'; + +export { GOOGLE_SCOPES, DB_STORES } from './types'; + +// Encryption utilities +export { + hasWebCrypto, + generateMasterKey, + exportMasterKey, + importMasterKey, + deriveServiceKey, + encryptData, + decryptData, + decryptDataToString, + generateCodeVerifier, + generateCodeChallenge, + generateSalt, + encryptMasterKeyWithPassword, + decryptMasterKeyWithPassword +} from './encryption'; + +// Database operations +export { + openDatabase, + closeDatabase, + deleteDatabase, + gmailStore, + driveStore, + photosStore, + calendarStore, + syncMetadataStore, + encryptionMetaStore, + tokensStore, + requestPersistentStorage, + checkStorageQuota, + hasSafariLimitations, + touchLocalData, + clearServiceData, + exportAllData +} from './database'; + +// OAuth +export { + initiateGoogleAuth, + handleGoogleCallback, + getAccessToken, + isGoogleAuthenticated, + getGrantedScopes, + isServiceAuthorized, + revokeGoogleAccess, + getGoogleUserInfo, + parseCallbackParams +} from './oauth'; + +// Importers +export { + GmailImporter, + importGmail, + DriveImporter, + importDrive, + PhotosImporter, + importPhotos, + CalendarImporter, + importCalendar +} from './importers'; + +export type { + GmailImportOptions, + DriveImportOptions, + PhotosImportOptions, + CalendarImportOptions +} from './importers'; + +// Share to board +export { + ShareService, + createShareService +} from './share'; + +export type { + EmailCardShape, + DocumentCardShape, + PhotoCardShape, + EventCardShape, + GoogleDataShape +} from './share'; + +// R2 Backup +export { + R2BackupService, + createBackupService +} from './backup'; + +export type { + BackupMetadata, + BackupProgress +} from './backup'; + +// Main service class that ties everything together +import { generateMasterKey, importMasterKey, exportMasterKey } from './encryption'; +import { openDatabase, checkStorageQuota, touchLocalData, hasSafariLimitations, requestPersistentStorage } from './database'; +import { isGoogleAuthenticated, getGoogleUserInfo, initiateGoogleAuth, revokeGoogleAccess } from './oauth'; +import { importGmail, importDrive, importPhotos, importCalendar } from './importers'; +import type { GmailImportOptions, DriveImportOptions, PhotosImportOptions, CalendarImportOptions } from './importers'; +import { createShareService, ShareService } from './share'; +import { createBackupService, R2BackupService } from './backup'; +import type { GoogleService, ImportProgress } from './types'; + +export class GoogleDataService { + private masterKey: CryptoKey | null = null; + private shareService: ShareService | null = null; + private backupService: R2BackupService | null = null; + private initialized = false; + + // Initialize the service with an existing master key or generate new one + async initialize(existingKeyData?: ArrayBuffer): Promise { + try { + // Open database + await openDatabase(); + + // Set up master key + if (existingKeyData) { + this.masterKey = await importMasterKey(existingKeyData); + } else { + this.masterKey = await generateMasterKey(); + } + + // Request persistent storage (especially important for Safari) + if (hasSafariLimitations()) { + console.warn('Safari detected: Data may be evicted after 7 days of non-use'); + await requestPersistentStorage(); + // Schedule periodic touch to prevent eviction + this.scheduleTouchInterval(); + } + + // Initialize sub-services + this.shareService = createShareService(this.masterKey); + this.backupService = createBackupService(this.masterKey); + + this.initialized = true; + return true; + + } catch (error) { + console.error('Failed to initialize GoogleDataService:', error); + return false; + } + } + + // Check if initialized + isInitialized(): boolean { + return this.initialized && this.masterKey !== null; + } + + // Export master key for backup + async exportKey(): Promise { + if (!this.masterKey) return null; + return await exportMasterKey(this.masterKey); + } + + // Check Google authentication status + async isAuthenticated(): Promise { + return await isGoogleAuthenticated(); + } + + // Get Google user info + async getUserInfo(): Promise<{ email: string; name: string; picture: string } | null> { + if (!this.masterKey) return null; + return await getGoogleUserInfo(this.masterKey); + } + + // Start Google OAuth flow + async authenticate(services: GoogleService[]): Promise { + await initiateGoogleAuth(services); + } + + // Revoke Google access + async signOut(): Promise { + if (!this.masterKey) return false; + return await revokeGoogleAccess(this.masterKey); + } + + // Import data from Google services + async importData( + service: GoogleService, + options: { + gmail?: GmailImportOptions; + drive?: DriveImportOptions; + photos?: PhotosImportOptions; + calendar?: CalendarImportOptions; + } = {} + ): Promise { + if (!this.masterKey) { + return { + service, + total: 0, + imported: 0, + status: 'error', + errorMessage: 'Service not initialized' + }; + } + + switch (service) { + case 'gmail': + return await importGmail(this.masterKey, options.gmail || {}); + case 'drive': + return await importDrive(this.masterKey, options.drive || {}); + case 'photos': + return await importPhotos(this.masterKey, options.photos || {}); + case 'calendar': + return await importCalendar(this.masterKey, options.calendar || {}); + default: + return { + service, + total: 0, + imported: 0, + status: 'error', + errorMessage: 'Unknown service' + }; + } + } + + // Get share service for board integration + getShareService(): ShareService | null { + return this.shareService; + } + + // Get backup service for R2 operations + getBackupService(): R2BackupService | null { + return this.backupService; + } + + // Get storage quota info + async getStorageInfo(): Promise<{ + used: number; + quota: number; + isPersistent: boolean; + byService: { gmail: number; drive: number; photos: number; calendar: number }; + }> { + return await checkStorageQuota(); + } + + // Schedule periodic touch for Safari + private scheduleTouchInterval(): void { + // Touch data every 6 hours to prevent 7-day eviction + const TOUCH_INTERVAL = 6 * 60 * 60 * 1000; + + setInterval(async () => { + try { + await touchLocalData(); + console.log('Touched local data to prevent Safari eviction'); + } catch (error) { + console.warn('Failed to touch local data:', error); + } + }, TOUCH_INTERVAL); + } +} + +// Singleton instance +let serviceInstance: GoogleDataService | null = null; + +export function getGoogleDataService(): GoogleDataService { + if (!serviceInstance) { + serviceInstance = new GoogleDataService(); + } + return serviceInstance; +} + +export function resetGoogleDataService(): void { + serviceInstance = null; +} diff --git a/src/lib/google/oauth.ts b/src/lib/google/oauth.ts new file mode 100644 index 0000000..ddad534 --- /dev/null +++ b/src/lib/google/oauth.ts @@ -0,0 +1,382 @@ +// Google OAuth 2.0 with PKCE flow +// All tokens are encrypted before storage + +import { GOOGLE_SCOPES, type GoogleService } from './types'; +import { + generateCodeVerifier, + generateCodeChallenge, + encryptData, + decryptDataToString, + deriveServiceKey +} from './encryption'; +import { tokensStore } from './database'; + +// OAuth configuration +const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; + +// Auth state stored in sessionStorage during OAuth flow +interface GoogleAuthState { + codeVerifier: string; + redirectUri: string; + state: string; + requestedServices: GoogleService[]; +} + +// Get the Google Client ID from environment +function getGoogleClientId(): string { + const clientId = import.meta.env.VITE_GOOGLE_CLIENT_ID; + if (!clientId) { + throw new Error('VITE_GOOGLE_CLIENT_ID environment variable is not set'); + } + return clientId; +} + +// Get the Google Client Secret from environment +function getGoogleClientSecret(): string { + const clientSecret = import.meta.env.VITE_GOOGLE_CLIENT_SECRET; + if (!clientSecret) { + throw new Error('VITE_GOOGLE_CLIENT_SECRET environment variable is not set'); + } + return clientSecret; +} + +// Build the OAuth redirect URI +function getRedirectUri(): string { + return `${window.location.origin}/oauth/google/callback`; +} + +// Get requested scopes based on selected services +function getRequestedScopes(services: GoogleService[]): string { + const scopes: string[] = [GOOGLE_SCOPES.profile, GOOGLE_SCOPES.email]; + + for (const service of services) { + const scope = GOOGLE_SCOPES[service]; + if (scope) { + scopes.push(scope); + } + } + + return scopes.join(' '); +} + +// Initiate the Google OAuth flow +export async function initiateGoogleAuth(services: GoogleService[]): Promise { + if (services.length === 0) { + throw new Error('At least one service must be selected'); + } + + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await generateCodeChallenge(codeVerifier); + const state = crypto.randomUUID(); + const redirectUri = getRedirectUri(); + + // Store auth state for callback verification + const authState: GoogleAuthState = { + codeVerifier, + redirectUri, + state, + requestedServices: services + }; + sessionStorage.setItem('google_auth_state', JSON.stringify(authState)); + + // Build authorization URL + const params = new URLSearchParams({ + client_id: getGoogleClientId(), + redirect_uri: redirectUri, + response_type: 'code', + scope: getRequestedScopes(services), + access_type: 'offline', // Get refresh token + prompt: 'consent', // Always show consent to get refresh token + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state + }); + + // Redirect to Google OAuth + window.location.href = `${GOOGLE_AUTH_URL}?${params.toString()}`; +} + +// Handle the OAuth callback +export async function handleGoogleCallback( + code: string, + state: string, + masterKey: CryptoKey +): Promise<{ + success: boolean; + scopes: string[]; + error?: string; +}> { + // Retrieve and validate stored state + const storedStateJson = sessionStorage.getItem('google_auth_state'); + if (!storedStateJson) { + return { success: false, scopes: [], error: 'No auth state found' }; + } + + const storedState: GoogleAuthState = JSON.parse(storedStateJson); + + // Verify state matches + if (storedState.state !== state) { + return { success: false, scopes: [], error: 'State mismatch - possible CSRF attack' }; + } + + // Clean up session storage + sessionStorage.removeItem('google_auth_state'); + + try { + // Exchange code for tokens + const tokenResponse = await fetch(GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + client_id: getGoogleClientId(), + client_secret: getGoogleClientSecret(), + code, + code_verifier: storedState.codeVerifier, + grant_type: 'authorization_code', + redirect_uri: storedState.redirectUri + }) + }); + + if (!tokenResponse.ok) { + const error = await tokenResponse.json() as { error_description?: string }; + return { + success: false, + scopes: [], + error: error.error_description || 'Token exchange failed' + }; + } + + const tokens = await tokenResponse.json() as { + access_token: string; + refresh_token?: string; + expires_in: number; + scope?: string; + }; + + // Encrypt and store tokens + await storeEncryptedTokens(tokens, masterKey); + + // Parse scopes from response + const grantedScopes = (tokens.scope || '').split(' '); + + return { + success: true, + scopes: grantedScopes + }; + + } catch (error) { + console.error('OAuth callback error:', error); + return { + success: false, + scopes: [], + error: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + +// Store encrypted tokens +async function storeEncryptedTokens( + tokens: { + access_token: string; + refresh_token?: string; + expires_in: number; + scope?: string; + }, + masterKey: CryptoKey +): Promise { + const tokenKey = await deriveServiceKey(masterKey, 'tokens'); + + const encryptedAccessToken = await encryptData(tokens.access_token, tokenKey); + + let encryptedRefreshToken = null; + if (tokens.refresh_token) { + encryptedRefreshToken = await encryptData(tokens.refresh_token, tokenKey); + } + + await tokensStore.put({ + encryptedAccessToken, + encryptedRefreshToken, + expiresAt: Date.now() + tokens.expires_in * 1000, + scopes: (tokens.scope || '').split(' ') + }); +} + +// Get decrypted access token (refreshing if needed) +export async function getAccessToken(masterKey: CryptoKey): Promise { + const tokens = await tokensStore.get(); + if (!tokens) { + return null; + } + + const tokenKey = await deriveServiceKey(masterKey, 'tokens'); + + // Check if token is expired + if (await tokensStore.isExpired()) { + // Try to refresh + if (tokens.encryptedRefreshToken) { + const refreshed = await refreshAccessToken( + tokens.encryptedRefreshToken, + tokenKey, + masterKey + ); + if (refreshed) { + return refreshed; + } + } + return null; // Token expired and can't refresh + } + + // Decrypt and return access token + return await decryptDataToString(tokens.encryptedAccessToken, tokenKey); +} + +// Refresh access token using refresh token +async function refreshAccessToken( + encryptedRefreshToken: { encrypted: ArrayBuffer; iv: Uint8Array }, + tokenKey: CryptoKey, + masterKey: CryptoKey +): Promise { + try { + const refreshToken = await decryptDataToString(encryptedRefreshToken, tokenKey); + + const response = await fetch(GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + client_id: getGoogleClientId(), + client_secret: getGoogleClientSecret(), + refresh_token: refreshToken, + grant_type: 'refresh_token' + }) + }); + + if (!response.ok) { + console.error('Token refresh failed:', await response.text()); + return null; + } + + const tokens = await response.json() as { + access_token: string; + expires_in: number; + }; + + // Store new tokens (refresh token may not be returned on refresh) + const newTokenKey = await deriveServiceKey(masterKey, 'tokens'); + const encryptedAccessToken = await encryptData(tokens.access_token, newTokenKey); + + const existingTokens = await tokensStore.get(); + await tokensStore.put({ + encryptedAccessToken, + encryptedRefreshToken: existingTokens?.encryptedRefreshToken || null, + expiresAt: Date.now() + tokens.expires_in * 1000, + scopes: existingTokens?.scopes || [] + }); + + return tokens.access_token; + + } catch (error) { + console.error('Token refresh error:', error); + return null; + } +} + +// Check if user is authenticated with Google +export async function isGoogleAuthenticated(): Promise { + const tokens = await tokensStore.get(); + return tokens !== null; +} + +// Get granted scopes +export async function getGrantedScopes(): Promise { + const tokens = await tokensStore.get(); + return tokens?.scopes || []; +} + +// Check if a specific service is authorized +export async function isServiceAuthorized(service: GoogleService): Promise { + const scopes = await getGrantedScopes(); + return scopes.includes(GOOGLE_SCOPES[service]); +} + +// Revoke Google access +export async function revokeGoogleAccess(masterKey: CryptoKey): Promise { + try { + const accessToken = await getAccessToken(masterKey); + + if (accessToken) { + // Revoke token with Google + await fetch(`https://oauth2.googleapis.com/revoke?token=${accessToken}`, { + method: 'POST' + }); + } + + // Clear stored tokens + await tokensStore.delete(); + + return true; + } catch (error) { + console.error('Revoke error:', error); + // Still delete local tokens even if revocation fails + await tokensStore.delete(); + return false; + } +} + +// Get user info from Google +export async function getGoogleUserInfo(masterKey: CryptoKey): Promise<{ + email: string; + name: string; + picture: string; +} | null> { + const accessToken = await getAccessToken(masterKey); + if (!accessToken) { + return null; + } + + try { + const response = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + + if (!response.ok) { + return null; + } + + const userInfo = await response.json() as { + email: string; + name: string; + picture: string; + }; + return { + email: userInfo.email, + name: userInfo.name, + picture: userInfo.picture + }; + } catch (error) { + console.error('Get user info error:', error); + return null; + } +} + +// Parse callback URL parameters +export function parseCallbackParams(url: string): { + code?: string; + state?: string; + error?: string; + error_description?: string; +} { + const urlObj = new URL(url); + return { + code: urlObj.searchParams.get('code') || undefined, + state: urlObj.searchParams.get('state') || undefined, + error: urlObj.searchParams.get('error') || undefined, + error_description: urlObj.searchParams.get('error_description') || undefined + }; +} diff --git a/src/lib/google/share.ts b/src/lib/google/share.ts new file mode 100644 index 0000000..cb4c8fb --- /dev/null +++ b/src/lib/google/share.ts @@ -0,0 +1,555 @@ +// Share encrypted data to the canvas board +// Decrypts items and creates tldraw shapes + +import type { + EncryptedEmailStore, + EncryptedDriveDocument, + EncryptedPhotoReference, + EncryptedCalendarEvent, + ShareableItem, + GoogleService +} from './types'; +import { + decryptDataToString, + deriveServiceKey +} from './encryption'; +import { + gmailStore, + driveStore, + photosStore, + calendarStore +} from './database'; +import type { TLShapeId } from 'tldraw'; +import { createShapeId } from 'tldraw'; + +// Shape types for canvas +export interface EmailCardShape { + id: TLShapeId; + type: 'email-card'; + x: number; + y: number; + props: { + subject: string; + from: string; + date: number; + snippet: string; + messageId: string; + hasAttachments: boolean; + }; +} + +export interface DocumentCardShape { + id: TLShapeId; + type: 'document-card'; + x: number; + y: number; + props: { + name: string; + mimeType: string; + content: string | null; + documentId: string; + size: number; + modifiedTime: number; + }; +} + +export interface PhotoCardShape { + id: TLShapeId; + type: 'photo-card'; + x: number; + y: number; + props: { + filename: string; + description: string | null; + thumbnailDataUrl: string | null; + mediaItemId: string; + mediaType: 'image' | 'video'; + width: number; + height: number; + creationTime: number; + }; +} + +export interface EventCardShape { + id: TLShapeId; + type: 'event-card'; + x: number; + y: number; + props: { + summary: string; + description: string | null; + location: string | null; + startTime: number; + endTime: number; + isAllDay: boolean; + eventId: string; + calendarId: string; + meetingLink: string | null; + }; +} + +export type GoogleDataShape = + | EmailCardShape + | DocumentCardShape + | PhotoCardShape + | EventCardShape; + +// Service to manage sharing to board +export class ShareService { + private serviceKeys: Map = new Map(); + + constructor(private masterKey: CryptoKey) {} + + // Initialize service keys for decryption + private async getServiceKey(service: GoogleService): Promise { + let key = this.serviceKeys.get(service); + if (!key) { + key = await deriveServiceKey(this.masterKey, service); + this.serviceKeys.set(service, key); + } + return key; + } + + // List items available for sharing (with decrypted previews) + async listShareableItems( + service: GoogleService, + limit: number = 50 + ): Promise { + const key = await this.getServiceKey(service); + + switch (service) { + case 'gmail': + return this.listShareableEmails(key, limit); + case 'drive': + return this.listShareableDocuments(key, limit); + case 'photos': + return this.listShareablePhotos(key, limit); + case 'calendar': + return this.listShareableEvents(key, limit); + default: + return []; + } + } + + // List shareable emails + private async listShareableEmails( + key: CryptoKey, + limit: number + ): Promise { + const emails = await gmailStore.getAll(); + const items: ShareableItem[] = []; + + for (const email of emails.slice(0, limit)) { + try { + const subject = await decryptDataToString(email.encryptedSubject, key); + const snippet = await decryptDataToString(email.encryptedSnippet, key); + + items.push({ + type: 'email', + id: email.id, + title: subject || '(No Subject)', + preview: snippet, + date: email.date + }); + } catch (error) { + console.warn(`Failed to decrypt email ${email.id}:`, error); + } + } + + return items.sort((a, b) => b.date - a.date); + } + + // List shareable documents + private async listShareableDocuments( + key: CryptoKey, + limit: number + ): Promise { + const docs = await driveStore.getRecent(limit); + const items: ShareableItem[] = []; + + for (const doc of docs) { + try { + const name = await decryptDataToString(doc.encryptedName, key); + + items.push({ + type: 'document', + id: doc.id, + title: name || 'Untitled', + date: doc.modifiedTime + }); + } catch (error) { + console.warn(`Failed to decrypt document ${doc.id}:`, error); + } + } + + return items; + } + + // List shareable photos + private async listShareablePhotos( + key: CryptoKey, + limit: number + ): Promise { + const photos = await photosStore.getAll(); + const items: ShareableItem[] = []; + + for (const photo of photos.slice(0, limit)) { + try { + const filename = await decryptDataToString(photo.encryptedFilename, key); + + items.push({ + type: 'photo', + id: photo.id, + title: filename || 'Untitled', + date: photo.creationTime + }); + } catch (error) { + console.warn(`Failed to decrypt photo ${photo.id}:`, error); + } + } + + return items.sort((a, b) => b.date - a.date); + } + + // List shareable events + private async listShareableEvents( + key: CryptoKey, + limit: number + ): Promise { + // Get all events, not just upcoming + const events = await calendarStore.getAll(); + const items: ShareableItem[] = []; + + for (const event of events.slice(0, limit)) { + try { + const summary = await decryptDataToString(event.encryptedSummary, key); + + items.push({ + type: 'event', + id: event.id, + title: summary || 'Untitled Event', + date: event.startTime + }); + } catch (error) { + console.warn(`Failed to decrypt event ${event.id}:`, error); + } + } + + return items; + } + + // Create a shape from an item for the board + async createShapeFromItem( + itemId: string, + itemType: ShareableItem['type'], + position: { x: number; y: number } + ): Promise { + switch (itemType) { + case 'email': + return this.createEmailShape(itemId, position); + case 'document': + return this.createDocumentShape(itemId, position); + case 'photo': + return this.createPhotoShape(itemId, position); + case 'event': + return this.createEventShape(itemId, position); + default: + return null; + } + } + + // Create email shape + private async createEmailShape( + emailId: string, + position: { x: number; y: number } + ): Promise { + const email = await gmailStore.get(emailId); + if (!email) return null; + + const key = await this.getServiceKey('gmail'); + + try { + const subject = await decryptDataToString(email.encryptedSubject, key); + const from = await decryptDataToString(email.encryptedFrom, key); + const snippet = await decryptDataToString(email.encryptedSnippet, key); + + return { + id: createShapeId(), + type: 'email-card', + x: position.x, + y: position.y, + props: { + subject: subject || '(No Subject)', + from, + date: email.date, + snippet, + messageId: email.id, + hasAttachments: email.hasAttachments + } + }; + } catch (error) { + console.error('Failed to create email shape:', error); + return null; + } + } + + // Create document shape + private async createDocumentShape( + docId: string, + position: { x: number; y: number } + ): Promise { + const doc = await driveStore.get(docId); + if (!doc) return null; + + const key = await this.getServiceKey('drive'); + + try { + const name = await decryptDataToString(doc.encryptedName, key); + const mimeType = await decryptDataToString(doc.encryptedMimeType, key); + const content = doc.encryptedContent + ? await decryptDataToString(doc.encryptedContent, key) + : null; + + return { + id: createShapeId(), + type: 'document-card', + x: position.x, + y: position.y, + props: { + name: name || 'Untitled', + mimeType, + content, + documentId: doc.id, + size: doc.size, + modifiedTime: doc.modifiedTime + } + }; + } catch (error) { + console.error('Failed to create document shape:', error); + return null; + } + } + + // Create photo shape + private async createPhotoShape( + photoId: string, + position: { x: number; y: number } + ): Promise { + const photo = await photosStore.get(photoId); + if (!photo) return null; + + const key = await this.getServiceKey('photos'); + + try { + const filename = await decryptDataToString(photo.encryptedFilename, key); + const description = photo.encryptedDescription + ? await decryptDataToString(photo.encryptedDescription, key) + : null; + + // Convert thumbnail to data URL if available + let thumbnailDataUrl: string | null = null; + if (photo.thumbnail?.encryptedData) { + const thumbBuffer = await (await this.getServiceKey('photos')).algorithm; + // Decrypt thumbnail and convert to base64 + // Note: This is simplified - actual implementation would need proper blob handling + thumbnailDataUrl = null; // TODO: implement thumbnail decryption + } + + return { + id: createShapeId(), + type: 'photo-card', + x: position.x, + y: position.y, + props: { + filename: filename || 'Untitled', + description, + thumbnailDataUrl, + mediaItemId: photo.id, + mediaType: photo.mediaType, + width: photo.fullResolution.width, + height: photo.fullResolution.height, + creationTime: photo.creationTime + } + }; + } catch (error) { + console.error('Failed to create photo shape:', error); + return null; + } + } + + // Create event shape + private async createEventShape( + eventId: string, + position: { x: number; y: number } + ): Promise { + const event = await calendarStore.get(eventId); + if (!event) return null; + + const key = await this.getServiceKey('calendar'); + + try { + const summary = await decryptDataToString(event.encryptedSummary, key); + const description = event.encryptedDescription + ? await decryptDataToString(event.encryptedDescription, key) + : null; + const location = event.encryptedLocation + ? await decryptDataToString(event.encryptedLocation, key) + : null; + const meetingLink = event.encryptedMeetingLink + ? await decryptDataToString(event.encryptedMeetingLink, key) + : null; + + return { + id: createShapeId(), + type: 'event-card', + x: position.x, + y: position.y, + props: { + summary: summary || 'Untitled Event', + description, + location, + startTime: event.startTime, + endTime: event.endTime, + isAllDay: event.isAllDay, + eventId: event.id, + calendarId: event.calendarId, + meetingLink + } + }; + } catch (error) { + console.error('Failed to create event shape:', error); + return null; + } + } + + // Mark an item as shared (no longer local-only) + async markAsShared(itemId: string, itemType: ShareableItem['type']): Promise { + switch (itemType) { + case 'email': { + const email = await gmailStore.get(itemId); + if (email) { + email.localOnly = false; + await gmailStore.put(email); + } + break; + } + // Drive, Photos, Calendar don't have localOnly flag in current schema + // Would need to add if sharing tracking is needed + } + } + + // Get full decrypted content for an item + async getFullContent( + itemId: string, + itemType: ShareableItem['type'] + ): Promise | null> { + switch (itemType) { + case 'email': + return this.getFullEmailContent(itemId); + case 'document': + return this.getFullDocumentContent(itemId); + case 'event': + return this.getFullEventContent(itemId); + default: + return null; + } + } + + // Get full email content + private async getFullEmailContent( + emailId: string + ): Promise | null> { + const email = await gmailStore.get(emailId); + if (!email) return null; + + const key = await this.getServiceKey('gmail'); + + try { + return { + id: email.id, + threadId: email.threadId, + subject: await decryptDataToString(email.encryptedSubject, key), + body: await decryptDataToString(email.encryptedBody, key), + from: await decryptDataToString(email.encryptedFrom, key), + to: await decryptDataToString(email.encryptedTo, key), + date: email.date, + labels: email.labels, + hasAttachments: email.hasAttachments + }; + } catch (error) { + console.error('Failed to get full email content:', error); + return null; + } + } + + // Get full document content + private async getFullDocumentContent( + docId: string + ): Promise | null> { + const doc = await driveStore.get(docId); + if (!doc) return null; + + const key = await this.getServiceKey('drive'); + + try { + return { + id: doc.id, + name: await decryptDataToString(doc.encryptedName, key), + mimeType: await decryptDataToString(doc.encryptedMimeType, key), + content: doc.encryptedContent + ? await decryptDataToString(doc.encryptedContent, key) + : null, + size: doc.size, + modifiedTime: doc.modifiedTime, + isShared: doc.isShared + }; + } catch (error) { + console.error('Failed to get full document content:', error); + return null; + } + } + + // Get full event content + private async getFullEventContent( + eventId: string + ): Promise | null> { + const event = await calendarStore.get(eventId); + if (!event) return null; + + const key = await this.getServiceKey('calendar'); + + try { + return { + id: event.id, + calendarId: event.calendarId, + summary: await decryptDataToString(event.encryptedSummary, key), + description: event.encryptedDescription + ? await decryptDataToString(event.encryptedDescription, key) + : null, + location: event.encryptedLocation + ? await decryptDataToString(event.encryptedLocation, key) + : null, + startTime: event.startTime, + endTime: event.endTime, + isAllDay: event.isAllDay, + timezone: event.timezone, + isRecurring: event.isRecurring, + attendees: event.encryptedAttendees + ? JSON.parse(await decryptDataToString(event.encryptedAttendees, key)) + : [], + reminders: event.reminders, + meetingLink: event.encryptedMeetingLink + ? await decryptDataToString(event.encryptedMeetingLink, key) + : null + }; + } catch (error) { + console.error('Failed to get full event content:', error); + return null; + } + } +} + +// Convenience function +export function createShareService(masterKey: CryptoKey): ShareService { + return new ShareService(masterKey); +} diff --git a/src/lib/google/types.ts b/src/lib/google/types.ts new file mode 100644 index 0000000..79bb098 --- /dev/null +++ b/src/lib/google/types.ts @@ -0,0 +1,165 @@ +// Type definitions for Google Data Sovereignty module +// All data is encrypted client-side before storage + +// Base interface for encrypted data +export interface EncryptedData { + encrypted: ArrayBuffer; + iv: Uint8Array; +} + +// Encrypted Email Storage +export interface EncryptedEmailStore { + id: string; // Gmail message ID + threadId: string; // Thread ID for grouping + encryptedSubject: EncryptedData; + encryptedBody: EncryptedData; + encryptedFrom: EncryptedData; + encryptedTo: EncryptedData; + date: number; // Timestamp (unencrypted for sorting) + labels: string[]; // Gmail labels + hasAttachments: boolean; + encryptedSnippet: EncryptedData; + syncedAt: number; + localOnly: boolean; // Not yet shared to board +} + +// Encrypted Drive Document Storage +export interface EncryptedDriveDocument { + id: string; // Drive file ID + encryptedName: EncryptedData; + encryptedMimeType: EncryptedData; + encryptedContent: EncryptedData | null; // For text-based docs + encryptedPreview: EncryptedData | null; // Thumbnail or preview + contentStrategy: 'inline' | 'reference' | 'chunked'; + chunks?: string[]; // IDs of content chunks if chunked + parentId: string | null; + encryptedPath: EncryptedData; + isShared: boolean; + modifiedTime: number; + size: number; // Unencrypted for quota management + syncedAt: number; +} + +// Encrypted Photo Reference Storage +export interface EncryptedPhotoReference { + id: string; // Photos media item ID + encryptedFilename: EncryptedData; + encryptedDescription: EncryptedData | null; + thumbnail: { + width: number; + height: number; + encryptedData: EncryptedData; // Base64 or blob + } | null; + fullResolution: { + width: number; + height: number; + }; + mediaType: 'image' | 'video'; + creationTime: number; + albumIds: string[]; + encryptedLocation: EncryptedData | null; // Location data (highly sensitive) + syncedAt: number; +} + +// Encrypted Calendar Event Storage +export interface EncryptedCalendarEvent { + id: string; // Calendar event ID + calendarId: string; + encryptedSummary: EncryptedData; + encryptedDescription: EncryptedData | null; + encryptedLocation: EncryptedData | null; + startTime: number; // Unencrypted for query/sort + endTime: number; + isAllDay: boolean; + timezone: string; + isRecurring: boolean; + encryptedRecurrence: EncryptedData | null; + encryptedAttendees: EncryptedData | null; + reminders: { method: string; minutes: number }[]; + encryptedMeetingLink: EncryptedData | null; + syncedAt: number; +} + +// Sync Metadata +export interface SyncMetadata { + service: 'gmail' | 'drive' | 'photos' | 'calendar'; + lastSyncToken?: string; + lastSyncTime: number; + itemCount: number; + status: 'idle' | 'syncing' | 'error'; + errorMessage?: string; + progressCurrent?: number; + progressTotal?: number; +} + +// Encryption Metadata +export interface EncryptionMetadata { + purpose: 'gmail' | 'drive' | 'photos' | 'calendar' | 'google_tokens' | 'master'; + salt: Uint8Array; + createdAt: number; +} + +// OAuth Token Storage (encrypted) +export interface EncryptedTokens { + encryptedAccessToken: EncryptedData; + encryptedRefreshToken: EncryptedData | null; + expiresAt: number; + scopes: string[]; +} + +// Import Progress +export interface ImportProgress { + service: 'gmail' | 'drive' | 'photos' | 'calendar'; + total: number; + imported: number; + status: 'idle' | 'importing' | 'paused' | 'completed' | 'error'; + errorMessage?: string; + startedAt?: number; + completedAt?: number; +} + +// Storage Quota Info +export interface StorageQuotaInfo { + used: number; + quota: number; + isPersistent: boolean; + byService: { + gmail: number; + drive: number; + photos: number; + calendar: number; + }; +} + +// Share Item for Board +export interface ShareableItem { + type: 'email' | 'document' | 'photo' | 'event'; + id: string; + title: string; // Decrypted for display + preview?: string; // Decrypted snippet/preview + date: number; +} + +// Google Service Types +export type GoogleService = 'gmail' | 'drive' | 'photos' | 'calendar'; + +// OAuth Scopes +export const GOOGLE_SCOPES = { + gmail: 'https://www.googleapis.com/auth/gmail.readonly', + drive: 'https://www.googleapis.com/auth/drive.readonly', + photos: 'https://www.googleapis.com/auth/photoslibrary.readonly', + calendar: 'https://www.googleapis.com/auth/calendar.readonly', + profile: 'https://www.googleapis.com/auth/userinfo.profile', + email: 'https://www.googleapis.com/auth/userinfo.email' +} as const; + +// Database Store Names +export const DB_STORES = { + gmail: 'gmail', + drive: 'drive', + photos: 'photos', + calendar: 'calendar', + syncMetadata: 'syncMetadata', + encryptionMeta: 'encryptionMeta', + tokens: 'tokens' +} as const;