commit 9c59d06c0e3aa7942f00ce6610bcc4f1483c8b69
parent 3c1d76db02c062ecb42e6d17a90bb4ac0459325d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 30 Sep 2026 21:17:24 -0400
deck S4: rangeResponse() shared by the segment route; the deck's serve helpers
lib/report/serve.mjs:
- rangeResponse(request, { abs, size, headers }) is the segment route's range
code, moved; the segment route now calls it and behaves as before (an
unparseable Range sends the whole file, an unsatisfiable one is a bare 416).
- resolveReport(project, variant) — the membership rule for a request naming a
project and a cut but no clip; the variant is checked against VARIANTS.
- videoFor(project, manifest, variant, final|preview) — the cut's deliverable
through variantPaths(), or out/<variant>/<slug>.preview.mp4.
- deckPreviewDir / deckPreviewSrc / encodeProjectSegment — the preview
composition is served under /api/report/chrome/files/<b64url project>/
<variant>/, so its relative asset urls resolve with no query string.
- deckPreviewFile(dir, segments) — the traversal guard: refuses empty, `.` and
`..` segments, slashes, backslashes and NULs inside a segment, absolute
paths, and any file whose real path is not under the directory's real path.
Tested: ranges (whole, explicit, open-ended, suffix, clamped, 416, ignored)
and the guard (traversal, absolute, symlinks out, a directory, out/ itself a
symlink).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 357 insertions(+), 35 deletions(-)
diff --git a/umtool/app/api/report/segment/route.ts b/umtool/app/api/report/segment/route.ts
@@ -1,6 +1,4 @@
-import { createReadStream } from "node:fs";
-import { resolveClip, segmentFor } from "@/lib/report/serve.mjs";
-import { Readable } from "node:stream";
+import { rangeResponse, resolveClip, segmentFor } from "@/lib/report/serve.mjs";
export const dynamic = "force-dynamic";
@@ -21,7 +19,8 @@ export const dynamic = "force-dynamic";
// what makes "re-render, then watch it" show the new cut rather than the old.
//
// Range support is not optional: without a 206 the <video> element will not seek
-// in a stream it did not fully download.
+// in a stream it did not fully download. rangeResponse() is the one
+// implementation, shared with /api/report/video.
export async function GET(request: Request) {
const url = new URL(request.url);
@@ -47,32 +46,5 @@ export async function GET(request: Request) {
"x-segment": seg.rel,
};
- const range = request.headers.get("range");
- const m = range ? /^bytes=(\d*)-(\d*)$/.exec(range.trim()) : null;
- if (m) {
- const size = seg.size;
- let start = m[1] ? Number(m[1]) : 0;
- let end = m[2] ? Number(m[2]) : size - 1;
- if (!m[1] && m[2]) {
- // A suffix range: the LAST n bytes.
- start = Math.max(0, size - Number(m[2]));
- end = size - 1;
- }
- if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= size) {
- return new Response(null, { status: 416, headers: { "content-range": `bytes */${size}` } });
- }
- end = Math.min(end, size - 1);
- return new Response(Readable.toWeb(createReadStream(seg.abs, { start, end })) as ReadableStream, {
- status: 206,
- headers: {
- ...headers,
- "content-range": `bytes ${start}-${end}/${size}`,
- "content-length": String(end - start + 1),
- },
- });
- }
-
- return new Response(Readable.toWeb(createReadStream(seg.abs)) as ReadableStream, {
- headers: { ...headers, "content-length": String(seg.size) },
- });
+ return rangeResponse(request, { abs: seg.abs, size: seg.size, headers });
}
diff --git a/umtool/lib/report/serve.mjs b/umtool/lib/report/serve.mjs
@@ -6,10 +6,12 @@
// name that is simply not there fails, and a traversal fails twice: once on the
// membership check and once on resolveInRoots.
import path from "node:path";
-import { stat } from "node:fs/promises";
-import { REPORTS_ROOT, resolveInRoots } from "../paths.mjs";
+import { createReadStream } from "node:fs";
+import { realpath, stat } from "node:fs/promises";
+import { Readable } from "node:stream";
+import { REPORTS_ROOT, inside, resolveInRoots } from "../paths.mjs";
import { walkProjects } from "../projects/walk.mjs";
-import { DEFAULT_VARIANT, WIN_EPS } from "umtool-report-to-video/build-video";
+import { DEFAULT_VARIANT, VARIANTS, WIN_EPS, variantPaths } from "umtool-report-to-video/build-video";
import { rawCacheOf } from "./raw-cache.mjs";
import {
channelsDirFor,
@@ -30,6 +32,27 @@ export async function resolveClip(projectId, clipId) {
return { project, manifest, clip };
}
+/**
+ * The same membership rule for a request that names a PROJECT and a cut, and
+ * no clip: the deck's routes. `variant` is checked against the pipeline's own
+ * list; absent or empty it is the default cut.
+ *
+ * @param {string} projectId
+ * @param {string | null} [variant]
+ */
+export async function resolveReport(projectId, variant = null) {
+ const v = variant || DEFAULT_VARIANT;
+ if (!VARIANTS.includes(v)) {
+ return { error: `variant must be one of ${VARIANTS.join(", ")}`, status: 400 };
+ }
+ const projects = await walkProjects(REPORTS_ROOT);
+ const project = projects.find((p) => p.id === projectId);
+ if (!project) return { error: "no such project", status: 404 };
+ const manifest = await readManifest(project.dir);
+ if (!manifest) return { error: "no manifest", status: 404 };
+ return { project, manifest, variant: v };
+}
+
/** The clips-raw cache, re-exported so `serve.mjs` stays the bench's one door. */
export { rawCacheOf } from "./raw-cache.mjs";
@@ -147,3 +170,145 @@ export async function resolveClaim(projectId, claimId) {
if (!claim) return { error: "no such claim", status: 404 };
return { project, manifest, claim };
}
+
+
+// ---------------------------------------------------------------------------
+// Byte ranges.
+//
+// One implementation for every route here that hands an mp4 to a <video>:
+// the built segment, the cut and its preview. Without a 206 the element will
+// not seek in a stream it did not fully download.
+// ---------------------------------------------------------------------------
+
+/**
+ * The response for one file the caller has ALREADY authorised and stat'ed.
+ *
+ * A `Range` this does not parse is ignored and the whole file is sent; one it
+ * parses but cannot satisfy is a 416 carrying only `content-range`. A suffix
+ * range (`bytes=-500`) is the LAST n bytes -- Chrome asks for one to find an
+ * mp4's moov atom when it is not at the front.
+ *
+ * @param {Request} request
+ * @param {{ abs: string, size: number, headers?: Record<string, string> }} file
+ * @returns {Response}
+ */
+export function rangeResponse(request, { abs, size, headers = {} }) {
+ const range = request.headers.get("range");
+ const m = range ? /^bytes=(\d*)-(\d*)$/.exec(range.trim()) : null;
+ if (m) {
+ let start = m[1] ? Number(m[1]) : 0;
+ let end = m[2] ? Number(m[2]) : size - 1;
+ if (!m[1] && m[2]) {
+ // A suffix range: the LAST n bytes.
+ start = Math.max(0, size - Number(m[2]));
+ end = size - 1;
+ }
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= size) {
+ return new Response(null, { status: 416, headers: { "content-range": `bytes */${size}` } });
+ }
+ end = Math.min(end, size - 1);
+ return new Response(/** @type {ReadableStream} */ (Readable.toWeb(createReadStream(abs, { start, end }))), {
+ status: 206,
+ headers: {
+ ...headers,
+ "content-range": `bytes ${start}-${end}/${size}`,
+ "content-length": String(end - start + 1),
+ },
+ });
+ }
+ return new Response(/** @type {ReadableStream} */ (Readable.toWeb(createReadStream(abs))), {
+ headers: { ...headers, "content-length": String(size) },
+ });
+}
+
+/**
+ * The deliverable of one cut, or the short window `--chrome-preview` writes.
+ *
+ * Built from the manifest's slug and the checked variant through the
+ * pipeline's own variantPaths(), never from anything the client typed: `final`
+ * is `out/<slug>.mp4` (`out/<slug>-full.mp4` for `full`), `preview` is
+ * `out/<variant>/<slug>.preview.mp4`. The mtime rides along for the same
+ * reason segmentFor's does -- a re-render writes the same path.
+ *
+ * @param {{ dir: string }} project
+ * @param {{ slug?: string }} manifest
+ * @param {string} variant
+ * @param {"final" | "preview"} kind
+ */
+export async function videoFor(project, manifest, variant, kind) {
+ const slug = manifest.slug ?? path.basename(project.dir);
+ const dirs = variantPaths(path.join(project.dir, "out"), slug, variant);
+ const file = kind === "preview" ? path.join(dirs.dir, `${slug}.preview.mp4`) : dirs.final;
+ const abs = resolveInRoots(file);
+ if (!abs) return null;
+ const st = await stat(abs).catch(() => null);
+ if (!st?.isFile()) return null;
+ return {
+ rel: path.relative(project.dir, abs).split(path.sep).join("/"),
+ abs,
+ size: st.size,
+ mtimeMs: Math.round(st.mtimeMs),
+ };
+}
+
+// ---------------------------------------------------------------------------
+// The deck's preview composition.
+//
+// compose-chrome writes it under out/<variant>/chrome/deck-preview/, and the
+// page loads it in an iframe -- so its index.html, and the assets it names by
+// RELATIVE url, are served from one prefix by GET /api/report/chrome/files/.
+// That route takes a path from the client, which nothing else here does, so
+// the whole rule is in deckPreviewFile and it is tested.
+// ---------------------------------------------------------------------------
+
+/** The preview project directory of one cut. Never a build's `chrome/deck/`. */
+export const deckPreviewDir = (projectDir, variant) =>
+ path.join(projectDir, "out", variant, "chrome", "deck-preview");
+
+/**
+ * The project id as ONE url segment. Ids are relative paths (`folder/name`),
+ * and the composition's relative asset urls only resolve under a prefix with
+ * no query string, so the id cannot ride as a parameter or as raw segments.
+ */
+export const encodeProjectSegment = (id) => Buffer.from(String(id), "utf8").toString("base64url");
+export const decodeProjectSegment = (seg) => {
+ if (!/^[A-Za-z0-9_-]+$/.test(String(seg ?? ""))) return null;
+ return Buffer.from(seg, "base64url").toString("utf8");
+};
+
+/** The iframe src for a cut's preview composition. */
+export const deckPreviewSrc = (projectId, variant) =>
+ `/api/report/chrome/files/${encodeProjectSegment(projectId)}/${variant}/index.html`;
+
+/**
+ * Resolve the url segments after `<project>/<variant>/` to a file INSIDE the
+ * preview directory, or null.
+ *
+ * Refused: an empty, `.` or `..` segment; one carrying a slash, a backslash or
+ * a NUL (a segment the router decoded from `%2F` is still one segment); an
+ * absolute path; and anything whose REAL path -- symlinks followed -- is not
+ * under the directory's real path. A symlink inside the directory pointing
+ * out of it is the case the realpath is for. A directory is not a file.
+ *
+ * @param {string} dir the preview directory (deckPreviewDir)
+ * @param {string[]} segments
+ * @returns {Promise<{ abs: string, size: number } | null>}
+ */
+export async function deckPreviewFile(dir, segments) {
+ if (!Array.isArray(segments) || !segments.length) return null;
+ for (const seg of segments) {
+ if (typeof seg !== "string" || !seg || seg === "." || seg === "..") return null;
+ if (/[\/\\\0]/.test(seg) || path.isAbsolute(seg)) return null;
+ }
+ const base = path.resolve(dir);
+ const abs = path.resolve(base, ...segments);
+ if (!inside(base, abs) || abs === base) return null;
+ const [realBase, realAbs] = await Promise.all([
+ realpath(base).catch(() => null),
+ realpath(abs).catch(() => null),
+ ]);
+ if (!realBase || !realAbs || !inside(realBase, realAbs) || realAbs === realBase) return null;
+ const st = await stat(realAbs).catch(() => null);
+ if (!st?.isFile()) return null;
+ return { abs: realAbs, size: st.size };
+}
diff --git a/umtool/lib/report/serve.test.mjs b/umtool/lib/report/serve.test.mjs
@@ -0,0 +1,185 @@
+// What the report routes serve: rangeResponse (the segment and video routes'
+// byte ranges) and deckPreviewFile (the one route that takes a path from the
+// client, confined to the deck's preview directory).
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import {
+ decodeProjectSegment,
+ deckPreviewDir,
+ deckPreviewFile,
+ deckPreviewSrc,
+ encodeProjectSegment,
+ rangeResponse,
+} from "./serve.mjs";
+
+const req = (range) => new Request("http://x/", range ? { headers: { range } } : {});
+const bytes = async (res) => Buffer.from(await res.arrayBuffer());
+
+test("rangeResponse: whole file, explicit, open-ended, suffix and clamped ranges", async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), "umtool-range-"));
+ try {
+ const data = Buffer.from(Array.from({ length: 1000 }, (_, i) => i % 251));
+ const abs = path.join(dir, "a.mp4");
+ await writeFile(abs, data);
+ const file = { abs, size: data.length, headers: { "content-type": "video/mp4", "x-k": "v" } };
+
+ let res = rangeResponse(req(null), file);
+ assert.equal(res.status, 200);
+ assert.equal(res.headers.get("content-length"), "1000");
+ assert.equal(res.headers.get("content-type"), "video/mp4");
+ assert.equal(res.headers.get("x-k"), "v");
+ assert.deepEqual(await bytes(res), data);
+
+ res = rangeResponse(req("bytes=0-99"), file);
+ assert.equal(res.status, 206);
+ assert.equal(res.headers.get("content-range"), "bytes 0-99/1000");
+ assert.equal(res.headers.get("content-length"), "100");
+ assert.equal(res.headers.get("x-k"), "v");
+ assert.deepEqual(await bytes(res), data.subarray(0, 100));
+
+ res = rangeResponse(req("bytes=900-"), file);
+ assert.equal(res.status, 206);
+ assert.equal(res.headers.get("content-range"), "bytes 900-999/1000");
+ assert.deepEqual(await bytes(res), data.subarray(900));
+
+ // A suffix range is the LAST n bytes, not an offset.
+ res = rangeResponse(req("bytes=-100"), file);
+ assert.equal(res.status, 206);
+ assert.equal(res.headers.get("content-range"), "bytes 900-999/1000");
+ assert.deepEqual(await bytes(res), data.subarray(900));
+
+ res = rangeResponse(req("bytes=-5000"), file);
+ assert.equal(res.headers.get("content-range"), "bytes 0-999/1000");
+
+ // An end past the file is clamped.
+ res = rangeResponse(req("bytes=990-5000"), file);
+ assert.equal(res.headers.get("content-range"), "bytes 990-999/1000");
+ assert.equal((await bytes(res)).length, 10);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("rangeResponse: unsatisfiable is a bare 416; unparseable is the whole file", async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), "umtool-range-"));
+ try {
+ const abs = path.join(dir, "a.mp4");
+ await writeFile(abs, Buffer.alloc(1000, 1));
+ const file = { abs, size: 1000, headers: { "content-type": "video/mp4" } };
+ for (const r of ["bytes=1000-", "bytes=5-2", "bytes=2000-3000"]) {
+ const res = rangeResponse(req(r), file);
+ assert.equal(res.status, 416, r);
+ assert.equal(res.headers.get("content-range"), "bytes */1000");
+ assert.equal(res.headers.get("content-type"), null, "the 416 carries content-range only");
+ }
+ for (const r of ["items=0-1", "bytes=0-1,5-6", "nonsense"]) {
+ const res = rangeResponse(req(r), file);
+ assert.equal(res.status, 200, r);
+ assert.equal(res.headers.get("content-length"), "1000");
+ await res.arrayBuffer();
+ }
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("project ids ride as one url segment and come back exactly", () => {
+ for (const id of ["ferret-rescue", "folder/name", "a b/ç"]) {
+ const seg = encodeProjectSegment(id);
+ assert.match(seg, /^[A-Za-z0-9_-]+$/);
+ assert.equal(decodeProjectSegment(seg), id);
+ }
+ for (const bad of ["", "..", "a/b", "a.b", null, undefined]) assert.equal(decodeProjectSegment(bad), null);
+ assert.equal(
+ deckPreviewSrc("folder/name", "sourced"),
+ `/api/report/chrome/files/${encodeProjectSegment("folder/name")}/sourced/index.html`,
+ );
+ assert.equal(deckPreviewDir("/p", "full"), path.join("/p", "out", "full", "chrome", "deck-preview"));
+});
+
+test("deckPreviewFile: files inside the preview directory, and nothing else", async () => {
+ const root = await mkdtemp(path.join(tmpdir(), "umtool-deckfiles-"));
+ try {
+ const project = path.join(root, "proj");
+ const dir = deckPreviewDir(project, "sourced");
+ await mkdir(path.join(dir, "assets"), { recursive: true });
+ await writeFile(path.join(dir, "index.html"), "<html></html>");
+ await writeFile(path.join(dir, "assets", "gsap.min.js"), "x");
+ // Beside the preview: a build's own project, the manifest, and a secret
+ // outside the project altogether.
+ await mkdir(path.join(project, "out", "sourced", "chrome", "deck"), { recursive: true });
+ await writeFile(path.join(project, "out", "sourced", "chrome", "deck", "index.html"), "build");
+ await writeFile(path.join(project, "video.manifest.json"), "{}");
+ await writeFile(path.join(root, "secret.txt"), "secret");
+ // Links inside the directory: one out, one to a directory out, one in.
+ await symlink(path.join(root, "secret.txt"), path.join(dir, "out-link.txt"));
+ await symlink(root, path.join(dir, "out-dir"));
+ await symlink(path.join(dir, "index.html"), path.join(dir, "assets", "in-link.html"));
+
+ const ok = async (segs) => {
+ const f = await deckPreviewFile(dir, segs);
+ assert.ok(f, JSON.stringify(segs));
+ return f;
+ };
+ const refused = async (segs) => assert.equal(await deckPreviewFile(dir, segs), null, JSON.stringify(segs));
+
+ assert.equal((await ok(["index.html"])).size, "<html></html>".length);
+ await ok(["assets", "gsap.min.js"]);
+ // A link that stays inside is fine; it resolves to the real file.
+ assert.equal((await ok(["assets", "in-link.html"])).abs, path.join(await realDir(dir), "index.html"));
+
+ await refused([]);
+ await refused([".."]);
+ await refused(["..", "deck", "index.html"]);
+ await refused(["assets", "..", "..", "deck", "index.html"]);
+ await refused(["..", "..", "..", "video.manifest.json"]);
+ await refused(["..", "..", "..", "..", "secret.txt"]);
+ await refused(["."]);
+ await refused(["", "index.html"]);
+ // A segment the router decoded from %2F or %5C is still one segment.
+ await refused(["assets/gsap.min.js"]);
+ await refused(["../../../../secret.txt"]);
+ await refused(["assets\\gsap.min.js"]);
+ await refused(["index.html\0.png"]);
+ // Absolute paths, however they arrive.
+ await refused([path.join(root, "secret.txt")]);
+ await refused(["/etc/passwd"]);
+ // Symlinks out of the directory.
+ await refused(["out-link.txt"]);
+ await refused(["out-dir", "secret.txt"]);
+ // A directory is not a file; a missing file is not one either.
+ await refused(["assets"]);
+ await refused(["nope.html"]);
+ await refused("index.html");
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+test("deckPreviewFile: an out/ that is itself a symlink (media on another drive) still serves", async () => {
+ const root = await mkdtemp(path.join(tmpdir(), "umtool-deckfiles-"));
+ try {
+ const real = path.join(root, "elsewhere", "out");
+ await mkdir(path.join(real, "sourced", "chrome", "deck-preview"), { recursive: true });
+ await writeFile(path.join(real, "sourced", "chrome", "deck-preview", "index.html"), "x");
+ const project = path.join(root, "proj");
+ await mkdir(project);
+ await symlink(real, path.join(project, "out"));
+ const f = await deckPreviewFile(deckPreviewDir(project, "sourced"), ["index.html"]);
+ assert.ok(f);
+ assert.equal(await deckPreviewFile(deckPreviewDir(project, "sourced"), ["..", "..", "..", "..", "proj"]), null);
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+async function realDir(p) {
+ const { realpath } = await import("node:fs/promises");
+ return realpath(p);
+}