Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mumtool/app/api/report/segment/route.ts | 36++++--------------------------------
Mumtool/lib/report/serve.mjs | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Aumtool/lib/report/serve.test.mjs | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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); +}