import { Router, type Request, type Response } from "express"; import { Readable } from "stream"; import { extractToken } from "../auth.js"; import { getDatasetId, resolveToken, setUserToken, } from "../hf-storage.js"; /** * Authenticated reverse proxy for the backing HF dataset. * * Why this exists * --------------- * The dataset that backs the editor is **private by default** (see * `hf-storage.ts::ensureDatasetExists`), but the published article's * `` tags and the og:image / pdf links still need to be * resolvable from a normal browser - including by anonymous viewers * of a public Space. We can't just hand out * `huggingface.co/datasets/.../resolve/...` URLs because the dataset * itself is gated. Instead, the editor server proxies those reads on * the viewer's behalf, attaching a token it has on hand. * * Token resolution cascade (most specific to most ambient): * 1. Cookie from the current request (if the viewer is signed in) * 2. Last-known cached user token (warmed by any prior signed-in * request - see `hf-storage::setUserToken`) * 3. The server-side `HF_TOKEN` env (when an operator set one) * 4. No token at all (works only for public datasets) * * Path whitelist * -------------- * Only `images/` and `published/` may flow through the proxy. We * explicitly never expose `articles/` (raw Y.js `.yjs` snapshots * including unpublished drafts) and never expose the dataset root * either. Anything outside the whitelist gets a 404 - leaking the * proxy's existence is fine, leaking the path layout is not. * * Streaming * --------- * Responses are piped straight from `fetch`'s WHATWG body to the * Express `res`, so we never buffer a full PDF or large image in * Node memory. The cost is a tiny shim through `Readable.fromWeb`. * * Cache-Control * ------------- * - `images/*` filenames are UUID-based and content-addressed in * practice (the editor never overwrites an existing upload), so * they get `immutable, max-age=1y` - browsers will skip * revalidation entirely. * - `published/*` is mutable (every republish overwrites it) so we * cap at a short max-age and tell intermediaries to revalidate. */ export function createDatasetProxyRouter(): Router { const router = Router(); router.get(/^\/d\/(.+)$/, async (req: Request, res: Response) => { const datasetId = getDatasetId(); if (!datasetId) { res.status(503).json({ error: "Dataset persistence not configured" }); return; } const relPath = (req.params as { 0?: string })[0] || ""; if (!isPathAllowed(relPath)) { // 404, not 403: don't confirm whether the caller "almost" had // the right path. An attacker probing for /d/articles/foo.yjs // gets the same response as one probing for /d/garbage. res.status(404).json({ error: "Not found" }); return; } // Promote a cookie token to the cache opportunistically: even if // this very request was authenticated, future anonymous requests // from the same Space session benefit from a warm cache. const cookieToken = extractToken(req.headers.cookie); if (cookieToken) setUserToken(cookieToken); const token = resolveToken(cookieToken); const upstreamUrl = `https://huggingface.co/datasets/${datasetId}/resolve/main/${encodePath(relPath)}`; let upstream: Response | globalThis.Response; try { upstream = await fetch(upstreamUrl, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); } catch (err) { console.error(`[dataset-proxy] fetch failed for ${relPath}:`, (err as Error).message); res.status(502).json({ error: "Upstream fetch failed" }); return; } if (!upstream.ok || !upstream.body) { // Don't reflect upstream 401 directly: the viewer can't do // anything about a missing server-side token, and surfacing // 401 would trigger browser auth prompts in some setups. // Map any non-2xx to a generic 404 + log the real status for // operators reading the Space logs. console.warn( `[dataset-proxy] ${relPath}: upstream status ${upstream.status} (token=${token ? "yes" : "no"})`, ); res.status(upstream.status === 404 ? 404 : 502).json({ error: upstream.status === 404 ? "Not found" : "Upstream error", }); return; } // Forward content-type + content-length when present so the // browser renders the right viewer and shows progress for PDFs. const ct = upstream.headers.get("content-type"); if (ct) res.setHeader("Content-Type", ct); const cl = upstream.headers.get("content-length"); if (cl) res.setHeader("Content-Length", cl); res.setHeader("Cache-Control", cacheControlFor(relPath)); // Stream the WHATWG body straight to the Express response. The // `as any` is required because @types/node's `Readable.fromWeb` // doesn't accept the DOM `ReadableStream` type without a cast. const nodeStream = Readable.fromWeb(upstream.body as any); nodeStream.on("error", (err) => { console.error(`[dataset-proxy] stream error for ${relPath}:`, err.message); if (!res.headersSent) res.status(502).end(); else res.end(); }); nodeStream.pipe(res); }); return router; } const ALLOWED_PREFIXES = ["images/", "published/"]; function isPathAllowed(path: string): boolean { // Reject path traversal up-front. `decodeURIComponent` would let // an attacker hide `..` segments behind URL encoding, but Express // already decodes the wildcard param for us, so a plain string // check is sufficient. if (path.includes("..") || path.includes("\0") || path.startsWith("/")) { return false; } return ALLOWED_PREFIXES.some((prefix) => path.startsWith(prefix)); } /** * Re-encode the path before sending it upstream so spaces / unicode * filenames don't trip HF's URL parser. We split on `/` so the * slashes stay literal. */ function encodePath(path: string): string { return path.split("/").map(encodeURIComponent).join("/"); } function cacheControlFor(path: string): string { if (path.startsWith("images/")) { // UUID names + no overwrite => safe to mark immutable. return "public, max-age=31536000, immutable"; } // Published assets get republished in place, so cap aggressively // but allow shared caches to serve while revalidating. return "public, max-age=300, stale-while-revalidate=60"; }