211 lines
5.1 KiB
TypeScript
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 };
|
|
}
|