rspace-online/server/local-first/backup-store.ts

211 lines
5.1 KiB
TypeScript

/**
* 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<BackupManifest> {
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<void> {
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<void> {
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<Uint8Array | null> {
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<BackupManifest> {
return loadManifest(userId, spaceSlug);
}
/**
* Delete a specific backup blob.
*/
export async function deleteBackup(
userId: string,
spaceSlug: string,
docId: string,
): Promise<boolean> {
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<boolean> {
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 };
}