/** * Backup Store — Server-side opaque blob storage for encrypted backups. * * Layout: /data/backups/{userId}/{spaceSlug}/{docId-hash}.enc * Manifest: /data/backups/{userId}/{spaceSlug}/manifest.json * * The server stores ciphertext blobs it cannot decrypt (zero-knowledge). * Clients encrypt before upload and decrypt after download. */ import { resolve, dirname } from "node:path"; import { mkdir, readdir, readFile, writeFile, unlink, stat, rm } from "node:fs/promises"; import { createHash } from "node:crypto"; const BACKUPS_DIR = process.env.BACKUPS_DIR || "/data/backups"; export interface BackupManifestEntry { docId: string; hash: string; size: number; updatedAt: string; } export interface BackupManifest { spaceSlug: string; entries: BackupManifestEntry[]; updatedAt: string; } /** Hash a docId into a safe filename. */ function docIdHash(docId: string): string { return createHash("sha256").update(docId).digest("hex").slice(0, 32); } /** Resolve the directory for a user+space backup. */ function backupDir(userId: string, spaceSlug: string): string { return resolve(BACKUPS_DIR, userId, spaceSlug); } /** Resolve the path for a specific blob. */ function blobPath(userId: string, spaceSlug: string, docId: string): string { return resolve(backupDir(userId, spaceSlug), `${docIdHash(docId)}.enc`); } /** Resolve the manifest path. */ function manifestPath(userId: string, spaceSlug: string): string { return resolve(backupDir(userId, spaceSlug), "manifest.json"); } /** Load a manifest (returns empty manifest if none exists). */ async function loadManifest( userId: string, spaceSlug: string, ): Promise { try { const path = manifestPath(userId, spaceSlug); const file = Bun.file(path); if (await file.exists()) { return (await file.json()) as BackupManifest; } } catch { // Corrupt or missing manifest } return { spaceSlug, entries: [], updatedAt: new Date().toISOString() }; } /** Save a manifest. */ async function saveManifest( userId: string, spaceSlug: string, manifest: BackupManifest, ): Promise { const path = manifestPath(userId, spaceSlug); await mkdir(dirname(path), { recursive: true }); await writeFile(path, JSON.stringify(manifest, null, 2)); } /** * Store an encrypted backup blob. */ export async function putBackup( userId: string, spaceSlug: string, docId: string, blob: Uint8Array, ): Promise { const path = blobPath(userId, spaceSlug, docId); await mkdir(dirname(path), { recursive: true }); await writeFile(path, blob); // Update manifest const manifest = await loadManifest(userId, spaceSlug); const hash = createHash("sha256").update(blob).digest("hex"); const existing = manifest.entries.findIndex((e) => e.docId === docId); const entry: BackupManifestEntry = { docId, hash, size: blob.byteLength, updatedAt: new Date().toISOString(), }; if (existing >= 0) { manifest.entries[existing] = entry; } else { manifest.entries.push(entry); } manifest.updatedAt = new Date().toISOString(); await saveManifest(userId, spaceSlug, manifest); } /** * Retrieve an encrypted backup blob. */ export async function getBackup( userId: string, spaceSlug: string, docId: string, ): Promise { try { const path = blobPath(userId, spaceSlug, docId); const file = Bun.file(path); if (await file.exists()) { const buffer = await file.arrayBuffer(); return new Uint8Array(buffer); } } catch { // File doesn't exist } return null; } /** * List all backup entries for a space. */ export async function listBackups( userId: string, spaceSlug: string, ): Promise { return loadManifest(userId, spaceSlug); } /** * Delete a specific backup blob. */ export async function deleteBackup( userId: string, spaceSlug: string, docId: string, ): Promise { try { const path = blobPath(userId, spaceSlug, docId); await unlink(path); // Update manifest const manifest = await loadManifest(userId, spaceSlug); manifest.entries = manifest.entries.filter((e) => e.docId !== docId); manifest.updatedAt = new Date().toISOString(); await saveManifest(userId, spaceSlug, manifest); return true; } catch { return false; } } /** * Delete all backups for a space. */ export async function deleteAllBackups( userId: string, spaceSlug: string, ): Promise { try { const dir = backupDir(userId, spaceSlug); await rm(dir, { recursive: true, force: true }); return true; } catch { return false; } } /** * Get total backup storage usage for a user. */ export async function getUsage(userId: string): Promise<{ totalBytes: number; spaceCount: number; docCount: number; }> { let totalBytes = 0; let spaceCount = 0; let docCount = 0; try { const userDir = resolve(BACKUPS_DIR, userId); const spaces = await readdir(userDir, { withFileTypes: true }); for (const entry of spaces) { if (!entry.isDirectory()) continue; spaceCount++; const manifest = await loadManifest(userId, entry.name); for (const e of manifest.entries) { totalBytes += e.size; docCount++; } } } catch { // User has no backups } return { totalBytes, spaceCount, docCount }; }