feat: implement Google Data Sovereignty module for local-first data control
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 <noreply@anthropic.com>
This commit is contained in:
parent
f8790c9934
commit
58ff544c46
|
|
@ -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 = () => {
|
|||
<LocationDashboardRoute />
|
||||
</OptionalAuthRoute>
|
||||
} />
|
||||
{/* Google Data routes */}
|
||||
<Route path="/google" element={<GoogleDataTest />} />
|
||||
<Route path="/oauth/google/callback" element={<GoogleDataTest />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</DailyProvider>
|
||||
|
|
|
|||
|
|
@ -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<string>('Initializing...');
|
||||
const [isAuthed, setIsAuthed] = useState(false);
|
||||
const [scopes, setScopes] = useState<string[]>([]);
|
||||
const [masterKey, setMasterKey] = useState<CryptoKey | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [importProgress, setImportProgress] = useState<ImportProgress | null>(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<string[]>([]);
|
||||
const [viewingService, setViewingService] = useState<GoogleService | null>(null);
|
||||
const [viewItems, setViewItems] = useState<ShareableItem[]>([]);
|
||||
|
||||
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 (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
maxWidth: '600px',
|
||||
margin: '40px auto'
|
||||
}}>
|
||||
<h1>Google Data Sovereignty Test</h1>
|
||||
|
||||
<div style={{
|
||||
padding: '15px',
|
||||
background: error ? '#fee' : '#f0f0f0',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<strong>Status:</strong> {status}
|
||||
{error && (
|
||||
<div style={{
|
||||
color: 'red',
|
||||
marginTop: '10px',
|
||||
padding: '10px',
|
||||
background: '#fdd',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all'
|
||||
}}>
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isAuthed ? (
|
||||
<button
|
||||
onClick={connectGoogle}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '16px',
|
||||
background: '#4285f4',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Connect Google Account
|
||||
</button>
|
||||
) : (
|
||||
<div>
|
||||
<h3 style={{ color: 'green' }}>Connected!</h3>
|
||||
<p><strong>Granted scopes:</strong></p>
|
||||
<ul>
|
||||
{scopes.map(scope => (
|
||||
<li key={scope} style={{ fontSize: '12px', fontFamily: 'monospace' }}>
|
||||
{scope.replace('https://www.googleapis.com/auth/', '')}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3>Test Import (Small Batches)</h3>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<button style={buttonStyle} onClick={testImportGmail}>
|
||||
Import Gmail (10)
|
||||
</button>
|
||||
<button style={buttonStyle} onClick={testImportDrive}>
|
||||
Import Drive (10)
|
||||
</button>
|
||||
<button style={buttonStyle} onClick={testImportPhotos}>
|
||||
Import Photos (10)
|
||||
</button>
|
||||
<button style={buttonStyle} onClick={testImportCalendar}>
|
||||
Import Calendar (20)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{importProgress && (
|
||||
<div style={{
|
||||
padding: '10px',
|
||||
background: importProgress.status === 'error' ? '#fee' :
|
||||
importProgress.status === 'completed' ? '#efe' : '#fff3e0',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '15px'
|
||||
}}>
|
||||
<strong>{importProgress.service}:</strong> {importProgress.status}
|
||||
{importProgress.status === 'importing' && (
|
||||
<span> - {importProgress.imported}/{importProgress.total}</span>
|
||||
)}
|
||||
{importProgress.status === 'completed' && (
|
||||
<span> - {importProgress.imported} items imported</span>
|
||||
)}
|
||||
{importProgress.errorMessage && (
|
||||
<div style={{ color: 'red', marginTop: '5px' }}>{importProgress.errorMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3>Stored Data (Encrypted in IndexedDB)</h3>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style={{ padding: '8px', borderBottom: '1px solid #ddd' }}>Gmail</td>
|
||||
<td style={{ padding: '8px', borderBottom: '1px solid #ddd', textAlign: 'right' }}>{storedCounts.gmail} messages</td>
|
||||
<td style={{ padding: '8px', borderBottom: '1px solid #ddd', textAlign: 'right' }}>
|
||||
{storedCounts.gmail > 0 && <button onClick={() => viewData('gmail')} style={{ fontSize: '12px', padding: '4px 8px' }}>View</button>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={{ padding: '8px', borderBottom: '1px solid #ddd' }}>Drive</td>
|
||||
<td style={{ padding: '8px', borderBottom: '1px solid #ddd', textAlign: 'right' }}>{storedCounts.drive} files</td>
|
||||
<td style={{ padding: '8px', borderBottom: '1px solid #ddd', textAlign: 'right' }}>
|
||||
{storedCounts.drive > 0 && <button onClick={() => viewData('drive')} style={{ fontSize: '12px', padding: '4px 8px' }}>View</button>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={{ padding: '8px', borderBottom: '1px solid #ddd' }}>Photos</td>
|
||||
<td style={{ padding: '8px', borderBottom: '1px solid #ddd', textAlign: 'right' }}>{storedCounts.photos} photos</td>
|
||||
<td style={{ padding: '8px', borderBottom: '1px solid #ddd', textAlign: 'right' }}>
|
||||
{storedCounts.photos > 0 && <button onClick={() => viewData('photos')} style={{ fontSize: '12px', padding: '4px 8px' }}>View</button>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={{ padding: '8px', borderBottom: '1px solid #ddd' }}>Calendar</td>
|
||||
<td style={{ padding: '8px', borderBottom: '1px solid #ddd', textAlign: 'right' }}>{storedCounts.calendar} events</td>
|
||||
<td style={{ padding: '8px', borderBottom: '1px solid #ddd', textAlign: 'right' }}>
|
||||
{storedCounts.calendar > 0 && <button onClick={() => viewData('calendar')} style={{ fontSize: '12px', padding: '4px 8px' }}>View</button>}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{viewingService && viewItems.length > 0 && (
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<h4>
|
||||
{viewingService.charAt(0).toUpperCase() + viewingService.slice(1)} Items (Decrypted)
|
||||
<button onClick={() => { setViewingService(null); setViewItems([]); }} style={{ marginLeft: '10px', fontSize: '12px' }}>Close</button>
|
||||
</h4>
|
||||
<div style={{ maxHeight: '300px', overflow: 'auto', border: '1px solid #ddd', borderRadius: '4px' }}>
|
||||
{viewItems.map((item, i) => (
|
||||
<div key={item.id} style={{
|
||||
padding: '10px',
|
||||
borderBottom: '1px solid #eee',
|
||||
background: i % 2 === 0 ? '#fff' : '#f9f9f9'
|
||||
}}>
|
||||
<strong>{item.title}</strong>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>
|
||||
{new Date(item.date).toLocaleString()}
|
||||
</div>
|
||||
{item.preview && (
|
||||
<div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
{item.preview.substring(0, 100)}...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={refreshCounts}
|
||||
style={{ ...buttonStyle, background: '#666', marginTop: '10px' }}
|
||||
>
|
||||
Refresh Counts
|
||||
</button>
|
||||
<button
|
||||
onClick={resetAndReconnect}
|
||||
style={{ ...buttonStyle, background: '#c00', marginTop: '10px' }}
|
||||
>
|
||||
Reset & Clear All Data
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<hr style={{ margin: '30px 0' }} />
|
||||
|
||||
<h3>Activity Log</h3>
|
||||
<div style={{
|
||||
background: '#1a1a1a',
|
||||
color: '#0f0',
|
||||
padding: '10px',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '11px',
|
||||
height: '150px',
|
||||
overflow: 'auto',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
{logs.length === 0 ? (
|
||||
<span style={{ color: '#666' }}>Click an import button to see activity...</span>
|
||||
) : (
|
||||
logs.map((log, i) => <div key={i}>{log}</div>)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary style={{ cursor: 'pointer' }}>Debug Info</summary>
|
||||
<pre style={{ fontSize: '11px', background: '#f5f5f5', padding: '10px', overflow: 'auto' }}>
|
||||
{JSON.stringify({
|
||||
isAuthed,
|
||||
hasMasterKey: !!masterKey,
|
||||
scopeCount: scopes.length,
|
||||
storedCounts,
|
||||
importProgress,
|
||||
currentUrl: typeof window !== 'undefined' ? window.location.href : 'N/A'
|
||||
}, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GoogleDataTest;
|
||||
|
|
@ -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<BackupMetadata | null> {
|
||||
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<BackupMetadata[]> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<string> {
|
||||
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<CryptoKey> {
|
||||
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);
|
||||
}
|
||||
|
|
@ -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<IDBDatabase> {
|
||||
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<void> {
|
||||
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<T>(storeName: string, item: T): Promise<void> {
|
||||
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<T>(storeName: string, key: string): Promise<T | null> {
|
||||
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<void> {
|
||||
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<T>(storeName: string): Promise<T[]> {
|
||||
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<number> {
|
||||
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<T>(
|
||||
storeName: string,
|
||||
indexName: string,
|
||||
query?: IDBKeyRange | IDBValidKey
|
||||
): Promise<T[]> {
|
||||
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<EncryptedEmailStore>(DB_STORES.gmail, id),
|
||||
delete: (id: string) => deleteItem(DB_STORES.gmail, id),
|
||||
getAll: () => getAllItems<EncryptedEmailStore>(DB_STORES.gmail),
|
||||
count: () => countItems(DB_STORES.gmail),
|
||||
|
||||
getByThread: (threadId: string) =>
|
||||
getItemsByIndex<EncryptedEmailStore>(DB_STORES.gmail, 'threadId', threadId),
|
||||
|
||||
getByDateRange: (startDate: number, endDate: number) =>
|
||||
getItemsByIndex<EncryptedEmailStore>(
|
||||
DB_STORES.gmail,
|
||||
'date',
|
||||
IDBKeyRange.bound(startDate, endDate)
|
||||
),
|
||||
|
||||
getLocalOnly: async () => {
|
||||
const all = await getAllItems<EncryptedEmailStore>(DB_STORES.gmail);
|
||||
return all.filter(email => email.localOnly === true);
|
||||
},
|
||||
|
||||
async putBatch(emails: EncryptedEmailStore[]): Promise<void> {
|
||||
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<EncryptedDriveDocument>(DB_STORES.drive, id),
|
||||
delete: (id: string) => deleteItem(DB_STORES.drive, id),
|
||||
getAll: () => getAllItems<EncryptedDriveDocument>(DB_STORES.drive),
|
||||
count: () => countItems(DB_STORES.drive),
|
||||
|
||||
getByParent: (parentId: string | null) =>
|
||||
getItemsByIndex<EncryptedDriveDocument>(
|
||||
DB_STORES.drive,
|
||||
'parentId',
|
||||
parentId ?? ''
|
||||
),
|
||||
|
||||
getRecent: (limit: number = 50) =>
|
||||
getItemsByIndex<EncryptedDriveDocument>(DB_STORES.drive, 'modifiedTime')
|
||||
.then(items => items.sort((a, b) => b.modifiedTime - a.modifiedTime).slice(0, limit)),
|
||||
|
||||
async putBatch(docs: EncryptedDriveDocument[]): Promise<void> {
|
||||
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<EncryptedPhotoReference>(DB_STORES.photos, id),
|
||||
delete: (id: string) => deleteItem(DB_STORES.photos, id),
|
||||
getAll: () => getAllItems<EncryptedPhotoReference>(DB_STORES.photos),
|
||||
count: () => countItems(DB_STORES.photos),
|
||||
|
||||
getByMediaType: (mediaType: 'image' | 'video') =>
|
||||
getItemsByIndex<EncryptedPhotoReference>(DB_STORES.photos, 'mediaType', mediaType),
|
||||
|
||||
getByDateRange: (startDate: number, endDate: number) =>
|
||||
getItemsByIndex<EncryptedPhotoReference>(
|
||||
DB_STORES.photos,
|
||||
'creationTime',
|
||||
IDBKeyRange.bound(startDate, endDate)
|
||||
),
|
||||
|
||||
async putBatch(photos: EncryptedPhotoReference[]): Promise<void> {
|
||||
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<EncryptedCalendarEvent>(DB_STORES.calendar, id),
|
||||
delete: (id: string) => deleteItem(DB_STORES.calendar, id),
|
||||
getAll: () => getAllItems<EncryptedCalendarEvent>(DB_STORES.calendar),
|
||||
count: () => countItems(DB_STORES.calendar),
|
||||
|
||||
getByCalendar: (calendarId: string) =>
|
||||
getItemsByIndex<EncryptedCalendarEvent>(DB_STORES.calendar, 'calendarId', calendarId),
|
||||
|
||||
getByDateRange: (startTime: number, endTime: number) =>
|
||||
getItemsByIndex<EncryptedCalendarEvent>(
|
||||
DB_STORES.calendar,
|
||||
'startTime',
|
||||
IDBKeyRange.bound(startTime, endTime)
|
||||
),
|
||||
|
||||
getUpcoming: (fromTime: number = Date.now(), limit: number = 50) =>
|
||||
getItemsByIndex<EncryptedCalendarEvent>(
|
||||
DB_STORES.calendar,
|
||||
'startTime',
|
||||
IDBKeyRange.lowerBound(fromTime)
|
||||
).then(items => items.slice(0, limit)),
|
||||
|
||||
async putBatch(events: EncryptedCalendarEvent[]): Promise<void> {
|
||||
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<SyncMetadata>(DB_STORES.syncMetadata, service),
|
||||
getAll: () => getAllItems<SyncMetadata>(DB_STORES.syncMetadata),
|
||||
|
||||
async updateProgress(
|
||||
service: GoogleService,
|
||||
current: number,
|
||||
total: number
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<EncryptionMetadata>(DB_STORES.encryptionMeta, purpose),
|
||||
getAll: () => getAllItems<EncryptionMetadata>(DB_STORES.encryptionMeta)
|
||||
};
|
||||
|
||||
// Token operations
|
||||
export const tokensStore = {
|
||||
async put(tokens: EncryptedTokens): Promise<void> {
|
||||
await putItem(DB_STORES.tokens, { id: 'google', ...tokens });
|
||||
},
|
||||
|
||||
async get(): Promise<EncryptedTokens | null> {
|
||||
const result = await getItem<EncryptedTokens & { id: string }>(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<void> {
|
||||
await deleteItem(DB_STORES.tokens, 'google');
|
||||
},
|
||||
|
||||
async isExpired(): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<StorageQuotaInfo> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 };
|
||||
}
|
||||
|
|
@ -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<CryptoKey> {
|
||||
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<ArrayBuffer> {
|
||||
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<CryptoKey> {
|
||||
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<CryptoKey> {
|
||||
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<EncryptedData> {
|
||||
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<ArrayBuffer> {
|
||||
if (!hasWebCrypto()) {
|
||||
throw new Error('WebCrypto not available');
|
||||
}
|
||||
|
||||
return await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: new Uint8Array(encryptedData.iv) as Uint8Array<ArrayBuffer> },
|
||||
key,
|
||||
encryptedData.encrypted
|
||||
);
|
||||
}
|
||||
|
||||
// Decrypt data to string (convenience method)
|
||||
export async function decryptDataToString(
|
||||
encryptedData: EncryptedData,
|
||||
key: CryptoKey
|
||||
): Promise<string> {
|
||||
const decrypted = await decryptData(encryptedData, key);
|
||||
return new TextDecoder().decode(decrypted);
|
||||
}
|
||||
|
||||
// Encrypt multiple fields of an object
|
||||
export async function encryptFields<T extends Record<string, unknown>>(
|
||||
obj: T,
|
||||
fieldsToEncrypt: (keyof T)[],
|
||||
key: CryptoKey
|
||||
): Promise<Record<string, EncryptedData | unknown>> {
|
||||
const result: Record<string, EncryptedData | unknown> = {};
|
||||
|
||||
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<string> {
|
||||
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<CryptoKey> {
|
||||
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<ArrayBuffer>,
|
||||
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<CryptoKey> {
|
||||
const passwordKey = await deriveKeyFromPassword(password, salt);
|
||||
const masterKeyRaw = await decryptData(encryptedKey, passwordKey);
|
||||
return await importMasterKey(masterKeyRaw);
|
||||
}
|
||||
|
|
@ -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<boolean> {
|
||||
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<ImportProgress> {
|
||||
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<string, string> = {
|
||||
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<string, string>
|
||||
): Promise<EventsListResponse> {
|
||||
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<EncryptedCalendarEvent | null> {
|
||||
if (!this.encryptionKey) {
|
||||
throw new Error('Encryption key not initialized');
|
||||
}
|
||||
|
||||
// Helper to encrypt
|
||||
const encrypt = async (data: string): Promise<EncryptedData> => {
|
||||
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<CalendarEvent[]> {
|
||||
if (!await this.initialize()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const params: Record<string, string> = {
|
||||
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<ImportProgress> {
|
||||
const importer = new CalendarImporter(masterKey);
|
||||
return importer.import(options);
|
||||
}
|
||||
|
|
@ -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<string, string>; // 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<string, string> = {
|
||||
'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<boolean> {
|
||||
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<ImportProgress> {
|
||||
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<string, string> = {
|
||||
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<string, string> = {}
|
||||
): Promise<DriveFileListResponse> {
|
||||
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<string, string>
|
||||
): Promise<EncryptedDriveDocument | null> {
|
||||
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<EncryptedData> => {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<ArrayBuffer> {
|
||||
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<ImportProgress> {
|
||||
const importer = new DriveImporter(masterKey);
|
||||
return importer.import(options);
|
||||
}
|
||||
|
|
@ -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<boolean> {
|
||||
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<ImportProgress> {
|
||||
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<string, string> = {
|
||||
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<string, string> = {}
|
||||
): Promise<GmailMessageListResponse> {
|
||||
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<GmailMessageResponse | null> {
|
||||
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<EncryptedEmailStore> {
|
||||
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<EncryptedData> => {
|
||||
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<ImportProgress> {
|
||||
const importer = new GmailImporter(masterKey);
|
||||
return importer.import(options);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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<boolean> {
|
||||
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<ImportProgress> {
|
||||
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<PhotosListResponse> {
|
||||
// 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<PhotosListResponse> {
|
||||
const body: Record<string, unknown> = {
|
||||
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<EncryptedPhotoReference | null> {
|
||||
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<EncryptedData> => {
|
||||
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<string | null> {
|
||||
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<ImportProgress> {
|
||||
const importer = new PhotosImporter(masterKey);
|
||||
return importer.import(options);
|
||||
}
|
||||
|
|
@ -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<boolean> {
|
||||
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<ArrayBuffer | null> {
|
||||
if (!this.masterKey) return null;
|
||||
return await exportMasterKey(this.masterKey);
|
||||
}
|
||||
|
||||
// Check Google authentication status
|
||||
async isAuthenticated(): Promise<boolean> {
|
||||
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<void> {
|
||||
await initiateGoogleAuth(services);
|
||||
}
|
||||
|
||||
// Revoke Google access
|
||||
async signOut(): Promise<boolean> {
|
||||
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<ImportProgress> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
const tokens = await tokensStore.get();
|
||||
return tokens !== null;
|
||||
}
|
||||
|
||||
// Get granted scopes
|
||||
export async function getGrantedScopes(): Promise<string[]> {
|
||||
const tokens = await tokensStore.get();
|
||||
return tokens?.scopes || [];
|
||||
}
|
||||
|
||||
// Check if a specific service is authorized
|
||||
export async function isServiceAuthorized(service: GoogleService): Promise<boolean> {
|
||||
const scopes = await getGrantedScopes();
|
||||
return scopes.includes(GOOGLE_SCOPES[service]);
|
||||
}
|
||||
|
||||
// Revoke Google access
|
||||
export async function revokeGoogleAccess(masterKey: CryptoKey): Promise<boolean> {
|
||||
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
|
||||
};
|
||||
}
|
||||
|
|
@ -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<GoogleService, CryptoKey> = new Map();
|
||||
|
||||
constructor(private masterKey: CryptoKey) {}
|
||||
|
||||
// Initialize service keys for decryption
|
||||
private async getServiceKey(service: GoogleService): Promise<CryptoKey> {
|
||||
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<ShareableItem[]> {
|
||||
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<ShareableItem[]> {
|
||||
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<ShareableItem[]> {
|
||||
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<ShareableItem[]> {
|
||||
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<ShareableItem[]> {
|
||||
// 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<GoogleDataShape | null> {
|
||||
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<EmailCardShape | null> {
|
||||
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<DocumentCardShape | null> {
|
||||
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<PhotoCardShape | null> {
|
||||
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<EventCardShape | null> {
|
||||
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<void> {
|
||||
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<Record<string, unknown> | 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<Record<string, unknown> | 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<Record<string, unknown> | 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<Record<string, unknown> | 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
Loading…
Reference in New Issue