import { Router, type Request, type Response } from "express"; import { createReadStream, existsSync } from "fs"; import { extractToken, resolveUser } from "../auth.js"; import { getStorageStatus } from "../hf-storage.js"; import { docPath, sanitizeName } from "../utils.js"; interface StorageContext { oauthEnabled: boolean; } /** * Routes that surface the state of the persistence pipeline and * provide manual escape hatches for disaster recovery. * * GET /api/storage/status * Returns the current pipeline state (datasetReady, * lastLocalSaveAt, lastCloudPushAt, pendingPush, lastError). * Polled by the SyncIndicator every few seconds. Auth-gated to * canEdit so we don't leak error details (which may include * dataset ids) to anonymous viewers. * * GET /api/admin/export-doc?name= * Streams the raw `.yjs` file off disk so an editor can * download a snapshot manually. The cardinal use case is * "the cloud push has been failing for hours, the container * is about to rebuild, I need to get my data out NOW". Same * canEdit gate. */ export function createStorageRouter(ctx: StorageContext): Router { const router = Router(); router.get("/api/storage/status", async (req, res) => { if (ctx.oauthEnabled) { const token = extractToken(req.headers.cookie); const user = await resolveUser(token); if (!user || !user.canEdit) { res.status(403).json({ error: "Unauthorized" }); return; } } res.json(getStorageStatus()); }); router.get("/api/admin/export-doc", async (req: Request, res: Response) => { if (ctx.oauthEnabled) { const token = extractToken(req.headers.cookie); const user = await resolveUser(token); if (!user || !user.canEdit) { res.status(403).json({ error: "Unauthorized" }); return; } } // `name` is the doc id; default to the only doc the editor // currently supports ("default"). Sanitised before touching FS. const rawName = typeof req.query.name === "string" ? req.query.name : "default"; const safeName = sanitizeName(rawName); const path = docPath(rawName); if (!existsSync(path)) { res.status(404).json({ error: "No on-disk snapshot for that doc" }); return; } // Force a download with a date-stamped filename so multiple // exports don't overwrite each other in the user's downloads // folder. ISO date without colons (Windows / macOS Downloads // are happier without them). const stamp = new Date().toISOString().replace(/[:.]/g, "-"); res.setHeader("Content-Type", "application/octet-stream"); res.setHeader( "Content-Disposition", `attachment; filename="${safeName}-${stamp}.yjs"`, ); res.setHeader("Cache-Control", "no-store"); const stream = createReadStream(path); stream.on("error", (err) => { console.error(`[admin-export] stream error for ${safeName}:`, err.message); if (!res.headersSent) res.status(500).end(); else res.end(); }); stream.pipe(res); }); return router; }