rspace-online/shared/local-first/backup.ts

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('');
}
}