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