Public: anyone can read this; the crew can change it.

Files render in the browser, part 1: a safe media route and one registry of file kinds #7341

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/7330-files-render-in-the-browser-part-1-a-saf → main 8 commits fork 4 threads · 4 unresolved

  • Merged. mason/7330-files-render-in-the-browser-part-1-a-saf was merged into main as 73163e3.

Review

Sign in to review this change.
17 files changed +1066 −36
CLAUDE.md +2 −1
@@ -180,7 +180,8 @@ src/
180180 header, settings shell, the one 404), flash, layout (CSP+nonce,
181181 topbar, theme switcher), client (progressive enhancement, as a
182182 string), style/{base,components,content} assembled by style.ts,
183 markdown, highlight, code, worddiff, diff-view, live, and
183 markdown, highlight, code, worddiff, diff-view, live, render/ (the
184 file-kind registry, kinds.ts, and one renderer per kind), and
184185 pages/{site,repo,issues,pulls,pull,thread,commits,refs,search,
185186 org,account,settings}, and studio/ — the app-as-the-unit
186187 surface, one module per concept: words (the vocabulary every
README.md +1
@@ -359,6 +359,7 @@ This page is the front door. Everything else is a file of its own under
359359 - [`docs/FEED.md`](docs/FEED.md) — the feed: what happened, ranked, each item with why it is there. The home composer keeps your draft across a reload.
360360 - [`docs/PULLS.md`](docs/PULLS.md) — resolving a conflicted pull without a clone.
361361 - [`docs/GIT.md`](docs/GIT.md) — cloning over HTTPS and SSH, and partial clones of big repositories.
362 - [`docs/features/file-media.md`](docs/features/file-media.md) — a file in the browser: every file page has **Raw** and **Download** (any size), and `/:owner/:name/media/:ref/*path` serves images, audio, video and PDFs under their real type, sandboxed, with byte ranges.
362363 - [`docs/ROLES.md`](docs/ROLES.md) — guest, member and admin, and what each may do.
363364 - [`docs/MCP.md`](docs/MCP.md) — the forge as a connector; [`docs/STANDARDS.md`](docs/STANDARDS.md) is what it answers by default: `.well-known`, feeds, cards, the manifest.
364365
docs/features/file-media.md added +60
@@ -0,0 +1,60 @@
1 # Files in the browser: the media route and the kind registry
2
3 The foundation for rendering files on the blob page (#7329, #7330).
4
5 ## The media route
6
7 `GET /:owner/:name/media/:ref/*path` serves a file's bytes under a real
8 media type, for `<img>`, `<video>`, `<audio>`, `<object>` and `fetch()`.
9
10 - **Types** come only from the allowlist in `src/web/render/kinds.ts`
11 (png, jpeg, gif, webp, avif, bmp, ico, svg, pdf, mp3, ogg/oga, wav, flac,
12 m4a, aac, mp4, m4v, webm, mov, stl, glb). The extension proposes a type and
13 the file's first bytes confirm it; a mismatch, or any other file, is
14 `application/octet-stream`. Never HTML, never script.
15 - **Headers:** `X-Content-Type-Options: nosniff` and
16 `Content-Security-Policy: sandbox; default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'`,
17 so an SVG with a `<script>` is drawn and never runs.
18 - **Range:** one `bytes=` range answers 206 with `Content-Range`; a suffix
19 (`bytes=-N`) is the last N bytes; past the end is 416. A list of ranges is
20 answered whole, which the RFC allows. `If-Range` is honoured.
21 - **Caching:** `ETag` is the blob id, and `If-None-Match` answers 304. A
22 full commit id as `:ref` is `private, max-age=31536000, immutable`; a
23 branch or tag is `private, max-age=0` and revalidates.
24 - **Access** is `/raw`'s: a repository you cannot see is the same 404 as one
25 that is not there.
26 - **Streaming:** a file over 1 MB streams off `git cat-file` and is never
27 held whole. Streams take permits from their own gate (`mediaGate`,
28 `FORGE_MEDIA_CONCURRENCY`, default 16), so a page of videos never takes
29 the plumbing permits every page needs. The permit is taken before the
30 stream exists; a stream gives it back when it ends, when its reader hangs
31 up, or when nothing has been pulled for 30 s (`MEDIA_IDLE_MS`), so a
32 reader that never reads cannot hold one for good. With 32 requests
33 already waiting (`MEDIA_QUEUE_MAX`) the next is a 503 with
34 `Retry-After: 5`. A file of 1 MB or less never waits: it is read through
35 the pooled reader.
36 - A 304 is answered from the blob id alone, before any byte is read.
37 - `?download=1` sends the file as an attachment under its own name, at any
38 size. The blob page's Download button points here.
39
40 ## The kind registry
41
42 `fileKind(path, firstBytes)` answers `image | svg | pdf | audio | video |
43 csv | model3d | config | markdown | text | binary`. `RENDER_CAP` is the
44 largest file each kind draws inline; over it, the page offers Download.
45
46 `kinds.ts` is plain data with no web dependency: the editor and the
47 terminal ask it whether a path is Markdown, as the blob page does.
48
49 `renderBlob` asks `render/renderers.ts`. A kind in `RENDERERS` is drawn by
50 its module; a kind with none yet keeps the source or binary view it always
51 had. Where a renderer exists the header offers `Rendered | Source`; every
52 file's header has the **Raw** and **Download** buttons.
53
54 ## Adding a renderer
55
56 Write `src/web/render/<kind>.ts` exporting a `Renderer` (plain data in:
57 path, size, body, and `media`, the media route's address; HTML out), and
58 name it once in `RENDERERS` (`render/renderers.ts`). Point any element
59 that needs the bytes at `input.media`, never at an inline copy, so
60 repository content only ever runs under the media route's sandbox. `render/markdown.ts` is the example.
src/git/env.ts +7
@@ -195,6 +195,13 @@ const limitFromEnv = (name: string, fallback: number): number => {
195195 */
196196 export const gitGate = new Gate(limitFromEnv("FORGE_GIT_CONCURRENCY", 8));
197197
198 /**
199 * Blob streams for the media route: a video seeks for minutes, so a stream
200 * holds its permit until the last byte or the reader hangs up. A pool of its
201 * own, so a page of videos never takes the plumbing permits every page needs.
202 */
203 export const mediaGate = new Gate(limitFromEnv("FORGE_MEDIA_CONCURRENCY", 16));
204
198205 /**
199206 * How many pack transfers a host of `bytes` memory runs at once by default:
200207 * one per 64 MiB, at least two, at most sixteen. A push is not one process
src/git/read.ts +94 −2
@@ -7,8 +7,8 @@
77
88 import { defect } from "../kernel/error.ts";
99 import { err, ok, type Result } from "../kernel/result.ts";
10 import { gitEnv, runGit, runGitRaw, type GitFailure } from "./env.ts";
11 import { isSafeRef } from "./guard.ts";
10 import { gitEnv, mediaGate, runGit, runGitRaw, type GitFailure } from "./env.ts";
11 import { isSafeOid, isSafeRef } from "./guard.ts";
1212
1313 export type ObjectType = "blob" | "tree" | "commit" | "tag";
1414
@@ -603,3 +603,95 @@ export async function refTip(dir: string, ref: string): Promise<string | undefin
603603 }
604604 return undefined;
605605 }
606
607 /** How many requests may wait for a media permit before the next is turned away. */
608 export const MEDIA_QUEUE_MAX = 32;
609 /** A stream nobody has pulled from for this long is ended and its permit returned. */
610 export const MEDIA_IDLE_MS = 30_000;
611
612 /**
613 * Bytes `start`..`end` (inclusive) of the blob `oid`, streamed straight off
614 * `git cat-file` and never held whole: a 2 GB video costs a pipe, not 2 GB
615 * of memory. Git cannot seek inside a blob, so the bytes before `start` are
616 * read and dropped; past `end` the process is killed.
617 *
618 * The permit is taken before the stream exists, so there is no stream to
619 * cancel while it is waiting; a reader whose `signal` aborts in the queue
620 * gets `"gone"` and the permit goes back unspent. With `MEDIA_QUEUE_MAX`
621 * already waiting, the answer is `"busy"` (a 503) rather than one more
622 * waiter. Once open, the stream gives its permit back when it ends, when its
623 * reader hangs up or its signal aborts, or when nothing has pulled from it
624 * for `idleMs`, which keeps a reader that never reads from holding a permit
625 * for good.
626 */
627 export async function blobStream(
628 dir: string,
629 oid: string,
630 start: number,
631 end: number,
outdated

High: if the reader cancels while this await is pending, cancel() → finish() runs with release/proc undefined. This line then takes a permit and spawns a git cat-file that is never read or killed. Set a cancelled flag in cancel() and check it here: release and return without spawning. Also add an idle deadline per stream so a reader that never reads cannot hold the permit forever.

632 limits: { idleMs?: number; queueMax?: number; signal?: AbortSignal } = {},
633 ): Promise<ReadableStream<Uint8Array> | "busy" | "gone"> {
634 if (!isSafeOid(oid)) defect("git.unsafe_oid", `unsafe oid: ${JSON.stringify(oid)}`);
635 const free = mediaGate.stats();
636 if (free.active >= free.limit && free.waiting >= (limits.queueMax ?? MEDIA_QUEUE_MAX))
637 return "busy";
638 const release = await mediaGate.acquire();
639 // The reader left while it waited: the permit goes straight back, unspent.
640 if (limits.signal?.aborted) {
641 release();
642 return "gone";
643 }
644 const proc = Bun.spawn(["git", "cat-file", "blob", oid], {
645 cwd: dir,
646 env: gitEnv(),
647 stdin: "ignore",
648 stdout: "pipe",
649 stderr: "ignore",
650 });
651 const source = proc.stdout.getReader();
652 const idleMs = limits.idleMs ?? MEDIA_IDLE_MS;
653 let idle: ReturnType<typeof setTimeout> | undefined;
654 let offset = 0;
655 let ended = false;
656 const finish = (): void => {
657 if (ended) return;
658 ended = true;
659 clearTimeout(idle);
660 limits.signal?.removeEventListener("abort", finish);
661 proc.kill("SIGKILL");
662 release();
663 };
664 limits.signal?.addEventListener("abort", finish, { once: true });
665 return new ReadableStream<Uint8Array>({
666 start(controller) {
667 // Armed from the first moment, so a response nobody ever reads ends too.
668 idle = setTimeout(() => {
669 finish();
670 controller.error(new Error("media stream idle"));
671 }, idleMs);
672 },
673 async pull(controller) {
674 idle?.refresh();
675 while (true) {
676 const next = await source.read();
677 const chunk = next.value;
678 if (next.done || chunk === undefined) break;
679 const from = offset;
680 offset += chunk.length;
681 if (offset <= start) continue;
682 // A copy, not a view: Bun may recycle the pipe's buffer.
683 controller.enqueue(
684 chunk.slice(Math.max(0, start - from), Math.min(chunk.length, end + 1 - from)),
685 );
686 if (offset > end) break;
687 return;
688 }
689 if (ended) return;
690 finish();
691 controller.close();
692 },
693 cancel() {
694 finish();
695 },
696 });
697 }
src/http/range.ts added +43
@@ -0,0 +1,43 @@
1 /**
2 * One HTTP byte range (RFC 9110 §14), as plain data. A video seeks and a PDF
3 * streams by asking for a window of a file; this reads which window.
4 *
5 * Only a single range is honoured. A list of ranges, a unit other than
6 * bytes, or a header that does not parse is ignored, which the RFC allows:
7 * the whole file goes out with 200, and nothing is refused that a browser
8 * would ask for.
9 */
10
11 /** Inclusive at both ends, as `Content-Range` writes it. */
12 export interface ByteRange {
13 readonly start: number;
14 readonly end: number;
15 }
16
17 /**
18 * The window `header` asks for in a file of `size` bytes: a range, `null`
19 * for the whole file, or `"unsatisfiable"` (416) when it starts past the end.
20 */
21 export function parseRange(
22 header: string | null,
23 size: number,
24 ): ByteRange | "unsatisfiable" | null {
25 if (header === null) return null;
26 const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
27 if (match === null) return null;
28 const [, first = "", last = ""] = match;
29 if (first === "" && last === "") return null;
30 if (first === "") {
31 // A suffix: the last N bytes.
32 const length = Number(last);
33 if (!Number.isSafeInteger(length)) return null;
34 if (length === 0 || size === 0) return "unsatisfiable";
35 return { start: Math.max(0, size - length), end: size - 1 };
36 }
37 const start = Number(first);
38 const end = last === "" ? Number.MAX_SAFE_INTEGER : Number(last);
39 if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end)) return null;
40 if (end < start) return null;
41 if (start >= size) return "unsatisfiable";
42 return { start, end: Math.min(end, size - 1) };
43 }
src/http/server.ts +2
@@ -205,6 +205,7 @@ import {
205205 repoSyncSubmit,
206206 treePage,
207207 } from "../web/pages/repo.ts";
208 import { mediaBlob } from "../web/pages/media.ts";
208209 import {
209210 branchCreate,
210211 branchDelete,
@@ -1324,6 +1325,7 @@ export function buildRouter(): Router<Handler> {
13241325 router.add("GET", "/:owner/:name/tree/:ref/*path", treePage);
13251326 router.add("GET", "/:owner/:name/blob/:ref/*path", blobPage);
13261327 router.add("GET", "/:owner/:name/raw/:ref/*path", rawBlob);
1328 router.add("GET", "/:owner/:name/media/:ref/*path", mediaBlob);
13271329 // Every file path at a ref, for the fuzzy finder. JSON, `canRead`-gated.
13281330 router.add("GET", "/:owner/:name/paths/:ref", pathsJson);
13291331
src/tui/screens/workspace.ts +2 −1
@@ -45,6 +45,7 @@ import { stopMirroring } from "../../actions/mirror_target.ts";
4545 import { getMirrorTarget, mirrorLine } from "../../core/migrate_mirror.ts";
4646 import { canAdmin, DECISIONS_BY, getRepoById, type Repo } from "../../core/repos.ts";
4747 import type { TreeEntry } from "../../git/read.ts";
48 import { fileKind, NO_BYTES } from "../../web/render/kinds.ts";
4849 import type { RepoFacts, RepoSource } from "../../view/source.ts";
4950 import { detectLanguage, highlightLines } from "../../web/highlight.ts";
5051 import type { Ctx, Screen } from "../app/app.ts";
@@ -257,7 +258,7 @@ type TabKind = "image" | "markdown" | "code";
257258 /** `README.md`, `NOTES.markdown`, `readme` — the prose a repo opens on. */
258259 function isMarkdownPath(path: string): boolean {
259260 const name = path.slice(path.lastIndexOf("/") + 1);
260 return /\.(md|markdown)$/i.test(name) || /^readme(\.|$)/i.test(name);
261 return fileKind(name, NO_BYTES) === "markdown" || /^readme(\.|$)/i.test(name);
261262 }
262263
263264 class Tab {
src/web/pages/edit.ts +3 −1
@@ -17,6 +17,7 @@
1717 * preview are `editorJs`, and none of them is load-bearing.
1818 */
1919
20 import { fileKind, NO_BYTES } from "../render/kinds.ts";
2021 import { editFiles, MAX_FILE_BYTES } from "../../actions/contents.ts";
2122 import { contribute } from "../../actions/contribute.ts";
2223 import { NO_WRITE_ACCESS } from "../../actions/pull_words.ts";
@@ -555,8 +556,9 @@ function mirrorHtml(content: string, lang: string): string {
555556 .join("");
556557 }
557558
559 /** A file not yet named is written as prose until its name says otherwise. */
558560 const isMarkdownPath = (path: string): boolean =>
559 path === "" || /\.(md|markdown|mdx)$/i.test(path);
561 path === "" || fileKind(path, NO_BYTES) === "markdown";
560562
561563 /**
562564 * The repository and the situation an edit route needs, or the refusal. A
src/web/pages/media.ts added +126
@@ -0,0 +1,126 @@
1 /**
2 * GET /:owner/:name/media/:ref/*path — a file's bytes under a real media
3 * type, so a browser can show them in `<img>`, `<video>`, `<audio>`,
4 * `<object>` and `fetch()`. `/raw` is the text you would copy; this is the
5 * picture, the song, the PDF.
6 *
7 * The type comes only from the allowlist in `render/kinds.ts`, confirmed by
8 * the file's own first bytes; anything else is `application/octet-stream`.
9 * Never HTML, never script, and the sandbox policy below means an SVG that
10 * carries a `<script>` is drawn as a picture and never runs on this origin.
11 *
12 * Bytes stream from git, never buffered whole, with HTTP Range so a video
13 * seeks and a PDF streams. Access is `/raw`'s: a repository you cannot see
14 * answers the same 404 as one that is not there.
15 */
16
17 import { isSafeRef, isSafeTreePath } from "../../git/guard.ts";
18 import { blobStream, readerFor } from "../../git/read.ts";
19 import { repoDir } from "../../git/repos.ts";
20 import type { Ctx } from "../../http/context.ts";
21 import { holds } from "../../http/etag.ts";
22 import { parseRange } from "../../http/range.ts";
23 import { loadRepo, notFoundPage } from "../chrome.ts";
24 import { HEAD_BYTES, mediaType } from "../render/kinds.ts";
25
26 /** The policy every media answer leaves with. */
27 export const MEDIA_POLICY =
28 "sandbox; default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'";
29
30 /** At or under this, the pooled reader hands the body back without a spawn. */
31 const SMALL_BYTES = 1_000_000;
32
33 const FULL_OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
34
35 /** `/media/:ref/:path`, the address a renderer points its element at. */
36 export const mediaHref = (base: string, ref: string, path: string): string =>
37 `${base}/media/${encodeURIComponent(ref)}/${path}`;
38
39 export async function mediaBlob(ctx: Ctx): Promise<Response> {
40 const repo = loadRepo(ctx);
41 if (!repo) return notFoundPage(ctx);
42 const ref = ctx.params.ref ?? "";
43 const path = ctx.params.path ?? "";
44 if (!isSafeRef(ref) || !isSafeTreePath(path)) return notFoundPage(ctx);
45
46 const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name);
47 const reader = readerFor(dir);
48 const stat = await reader.stat(`${ref}:${path}`).catch(() => undefined);
49 if (stat === undefined || !stat.ok || stat.value.type !== "blob")
50 return notFoundPage(ctx);
51 const { oid, size } = stat.value;
52
53 const download = ctx.url.searchParams.get("download") === "1";
54 const etag = `"${oid}"`;
55 const headers: Record<string, string> = {
56 "x-content-type-options": "nosniff",
57 "content-security-policy": MEDIA_POLICY,
58 "referrer-policy": "same-origin",
59 "accept-ranges": "bytes",
60 etag,
61 // A full commit id names bytes that can never change; a branch does not.
62 "cache-control": FULL_OID.test(ref)
63 ? "private, max-age=31536000, immutable"
outdated

The sniff for a large file takes a mediaGate permit and waits with no timeout. Once 16 stalled streams hold the gate, this await hangs every later request for a large file. Cap the queue and answer 503 + Retry-After instead of waiting without end.

64 : "private, max-age=0",
65 };
66 if (download) headers["content-disposition"] = attachment(path);
67 // The tag is the blob id `stat` already gave, so a revalidation reads no byte.
68 if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers });
69
70 // A small file is read whole through the pool; a large one is sniffed from
71 // its first bytes and streamed, so its size never becomes memory.
72 let body: Uint8Array | undefined;
73 let head: Uint8Array;
74 if (size <= SMALL_BYTES) {
75 const object = await reader.object(oid).catch(() => undefined);
76 if (object === undefined || !object.ok) return notFoundPage(ctx);
77 body = object.value.body;
78 head = body.subarray(0, HEAD_BYTES);
79 } else {
80 const sniff = await blobStream(dir, oid, 0, HEAD_BYTES - 1, { signal: ctx.request.signal });
81 if (sniff === "busy" || sniff === "gone") return busy();
outdated

The ETag is known from stat (line 51), so check holds() before the object read at lines 56-59 and before the sniff. A revalidation should not read up to 1 MB.

82 head = await new Response(sniff).bytes();
83 }
84 headers["content-type"] = download ? "application/octet-stream" : mediaType(path, head);
85
86 // A Range past an If-Range that no longer holds gets the whole new file.
87 const ifRange = ctx.request.headers.get("if-range");
88 const range =
89 ifRange === null || ifRange === etag
90 ? parseRange(ctx.request.headers.get("range"), size)
91 : null;
92 if (range === "unsatisfiable") {
93 headers["content-range"] = `bytes */${size}`;
94 return new Response(null, { status: 416, headers });
95 }
96 const start = range?.start ?? 0;
97 const end = range?.end ?? size - 1;
98 if (range !== null) headers["content-range"] = `bytes ${start}-${end}/${size}`;
99 const status = range === null ? 200 : 206;
100 if (size === 0) return new Response(null, { status, headers });
101 // A streamed body goes out chunked (Bun does not keep a length on a large
102 // stream); `Content-Range` still names the whole size, which is what a
103 // seeking player reads.
104 if (body === undefined) {
105 const stream = await blobStream(dir, oid, start, end, { signal: ctx.request.signal });
106 if (stream === "busy" || stream === "gone") return busy();
107 return new Response(stream, { status, headers });
108 }
109 headers["content-length"] = String(end + 1 - start);
110 return new Response(body.subarray(start, end + 1), { status, headers });
111 }
112
113 /** Every media permit is held and the queue is full: come back shortly. */
114 function busy(): Response {
115 return new Response("Busy streaming other files; try again shortly.\n", {
116 status: 503,
117 headers: { "content-type": "text/plain; charset=utf-8", "retry-after": "5", "cache-control": "no-store" },
118 });
119 }
120
121 /** Saved under its own name, quoted so no byte of it can end the header. */
122 function attachment(path: string): string {
123 const name = path.slice(path.lastIndexOf("/") + 1);
124 const plain = name.replace(/[^\x20-\x7e]|["\\]/g, "_");
125 return `attachment; filename="${plain}"; filename*=UTF-8''${encodeURIComponent(name)}`;
126 }
src/web/pages/repo.ts +43 −30
@@ -71,6 +71,9 @@ import { field, readForm } from "../form.ts";
7171 import { html, when, type Html } from "../html.ts";
7272 import { flashTo, fragment, page, redirect } from "../layout.ts";
7373 import { renderMarkdown } from "../markdown.ts";
74 import { fileKind, HEAD_BYTES, RENDER_CAP } from "../render/kinds.ts";
75 import { RENDERERS } from "../render/renderers.ts";
76 import { mediaHref } from "./media.ts";
7477 import { browseBase, localScope, type RepoScope } from "../scope.ts";
7578 import { repoForkOf } from "../../core/origins.ts";
7679 import {
@@ -602,8 +605,9 @@ export async function readmeFor(
602605 */
603606 export type BlobView = "source" | "rendered";
604607
605 export const isMarkdownPath = (path: string): boolean =>
606 /\.(md|markdown|mdx)$/i.test(path);
608 /** The file saved under its own name, streamed at any size. */
609 const downloadHref = (base: string, ref: string, path: string): string =>
610 `${mediaHref(base, ref, path)}?download=1`;
607611
608612 export function blobView(value: string | null): BlobView {
609613 return value === "source" ? "source" : "rendered";
@@ -627,7 +631,7 @@ export function blobBody(
627631 title: "Binary file",
628632 note: `${fileSize(blob.size)} — not shown, but you can download it.`,
629633 action: btn("Download", {
630 href: `${base}/raw/${encoded}/${path}`,
634 href: downloadHref(base, ref, path),
631635 variant: "primary",
632636 icon: "download",
633637 }),
@@ -640,10 +644,11 @@ export function blobBody(
640644 content: empty({
641645 icon: "alert",
642646 title: "Too large to render",
643 note: `${fileSize(blob.size)} — over the render cap. Fetch the raw bytes instead.`,
644 action: btn("View raw", {
645 href: `${base}/raw/${encoded}/${path}`,
647 note: `${fileSize(blob.size)} — over the render cap. Download it instead.`,
648 action: btn("Download", {
649 href: downloadHref(base, ref, path),
646650 variant: "primary",
651 icon: "download",
647652 }),
648653 }),
649654 };
@@ -1017,14 +1022,28 @@ export function renderBlob(
10171022 ): Html {
10181023 const base = scope.base;
10191024 const encoded = encodeURIComponent(o.ref);
1020 const markdown = o.blob.kind === "text" && isMarkdownPath(o.path);
1021 const view: BlobView = markdown ? (o.view ?? "rendered") : "source";
1025 const blob = o.blob;
1026 // The registry decides what this file is and whether anything draws it;
1027 // a kind with no renderer yet, or one over its cap, keeps the source view.
1028 const renderer =
1029 blob.kind === "too_large"
1030 ? undefined
1031 : (() => {
1032 const kind = fileKind(o.path, blob.body.subarray(0, HEAD_BYTES));
1033 return blob.size <= RENDER_CAP[kind] ? RENDERERS[kind] : undefined;
1034 })();
1035 const view: BlobView = renderer ? (o.view ?? "rendered") : "source";
10221036 const { content, lines } =
1023 view === "rendered" && o.blob.kind === "text"
1024 ? renderedMarkdown(o.blob)
1025 : blobBody(base, o.ref, o.path, o.blob, o.highlighted, o.all);
1037 view === "rendered" && renderer && blob.kind !== "too_large"
1038 ? renderer({
1039 path: o.path,
1040 size: blob.size,
1041 body: blob.body,
1042 media: mediaHref(base, o.ref, o.path),
1043 })
1044 : blobBody(base, o.ref, o.path, blob, o.highlighted, o.all);
10261045 const sizeText =
1027 o.blob.kind === "too_large" ? "" : sizeWithTokens(o.blob.size, o.path);
1046 blob.kind === "too_large" ? "" : sizeWithTokens(blob.size, o.path);
10281047 const fileHref = `${base}/blob/${encoded}/${o.path}`;
10291048 return html`<div class="code-pane" id="code-pane" data-path="${o.path}">
10301049 <div class="bar mb">
@@ -1044,20 +1063,20 @@ export function renderBlob(
10441063 >`,
10451064 )}
10461065 ${when(
1047 markdown,
1066 renderer !== undefined,
10481067 () =>
10491068 html`<span class="blob-view"
10501069 >${seg([
1051 {
1052 label: "Source",
1053 href: `${fileHref}?view=source`,
1054 current: view === "source",
1055 },
10561070 {
10571071 label: "Rendered",
10581072 href: fileHref,
10591073 current: view === "rendered",
10601074 },
outdated

Raw and Download are actions, not views of this page: they can never be current. Keep seg for Rendered/Source (shown only when a renderer exists). Keep Raw as the outline sm btn it was, and add Download as a btn with the download icon.

1075 {
1076 label: "Source",
1077 href: `${fileHref}?view=source`,
1078 current: view === "source",
1079 },
10611080 ])}</span
10621081 >`,
10631082 )}
@@ -1066,6 +1085,12 @@ export function renderBlob(
10661085 size: "sm",
10671086 variant: "outline",
10681087 })}
1088 ${btn("Download", {
1089 href: downloadHref(base, o.ref, o.path),
1090 size: "sm",
1091 variant: "outline",
1092 icon: "download",
1093 })}
10691094 ${btn("History", {
10701095 href: `${base}/commits?ref=${encoded}&path=${encodeURIComponent(o.path)}`,
10711096 size: "sm",
@@ -1078,18 +1103,6 @@ export function renderBlob(
10781103 </div>`;
10791104 }
10801105
1081 /** A Markdown file as prose, framed exactly the way a README is. */
1082 function renderedMarkdown(blob: Blob & { kind: "text" }): {
1083 content: Html;
1084 lines: number;
1085 } {
1086 const text = new TextDecoder().decode(blob.body);
1087 return {
1088 lines: text === "" ? 0 : text.replace(/\n$/, "").split("\n").length,
1089 content: html`<div class="card prose">${renderMarkdown(text)}</div>`,
1090 };
1091 }
1092
10931106 /** The bytes, with the headers that stop them ever executing as a page. */
10941107 export function rawResponse(
10951108 blob: Blob,
src/web/render/kinds.ts added +182
@@ -0,0 +1,182 @@
1 /**
2 * One registry of file kinds: what a file in a repository is, the one media
3 * type the forge will ever serve it as, and the module that draws it.
4 *
5 * It is plain data with no web dependency, so the terminal asks it too. The
6 * media route (`pages/media.ts`) asks for a type, and
7 * gets one only from the allowlist below, decided by extension and confirmed
8 * by the file's own first bytes where its format has a signature; anything
9 * else, or a file whose bytes say it is not what its name says, goes out as
10 * `application/octet-stream`. Never HTML, never script. `renderBlob` asks
11 * `renderers.ts` what draws a kind, and a kind with nothing there keeps the
12 * source view it always had.
13 */
14
15 export type FileKind =
16 | "image"
17 | "svg"
18 | "pdf"
19 | "audio"
20 | "video"
21 | "csv"
22 | "model3d"
23 | "config"
24 | "markdown"
25 | "text"
26 | "binary";
27
28 const MB = 1_000_000;
29
30 /**
31 * The largest file each kind draws inline. Over it the page offers Download,
32 * which streams at any size. A text kind is bounded by the page's own
33 * render ceiling too, since its body is read to draw it.
34 */
35 export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
36 image: 50 * MB,
37 svg: 5 * MB,
38 pdf: 100 * MB,
39 audio: 500 * MB,
40 video: 4_000 * MB,
41 csv: 10 * MB,
42 model3d: 100 * MB,
43 config: 1 * MB,
44 markdown: 1 * MB,
45 text: 1 * MB,
46 binary: 0,
47 };
48
49 type Magic = (head: Uint8Array) => boolean;
50
51 const at = (head: Uint8Array, offset: number, text: string): boolean => {
52 if (head.length < offset + text.length) return false;
53 for (let i = 0; i < text.length; i++)
54 if (head[offset + i] !== text.charCodeAt(i)) return false;
55 return true;
56 };
57 const bytes =
58 (...sig: number[]): Magic =>
59 (head) =>
60 head.length >= sig.length && sig.every((b, i) => head[i] === b);
61 const ascii =
62 (text: string, offset = 0): Magic =>
63 (head) =>
64 at(head, offset, text);
65 const any =
66 (...checks: Magic[]): Magic =>
67 (head) =>
68 checks.some((c) => c(head));
69
70 /** ISO base media (mp4, m4a, mov, avif): a box size, then `ftyp`. */
71 const ftyp = ascii("ftyp", 4);
72 /** An older QuickTime file may open on another top-level atom. */
73 const quicktime = any(ftyp, ...["moov", "mdat", "wide", "free", "skip", "pnot"].map((a) => ascii(a, 4)));
74 const riff = (form: string): Magic => (head) => at(head, 0, "RIFF") && at(head, 8, form);
75 /** MPEG audio: an ID3 tag, or a frame sync (eleven set bits). */
76 const mpegAudio: Magic = (head) =>
77 at(head, 0, "ID3") || (head[0] === 0xff && ((head[1] ?? 0) & 0xe0) === 0xe0);
78 /** Raw AAC in ADTS frames: a twelve-bit sync, layer zero. */
79 const adts: Magic = (head) => head[0] === 0xff && ((head[1] ?? 0) & 0xf6) === 0xf0;
80 /** SVG is text, and has no signature but its root element. */
81 const svgRoot: Magic = (head) =>
82 !head.includes(0) && /<svg[\s>]/i.test(new TextDecoder().decode(head));
83
84 interface Media {
85 readonly kind: FileKind;
86 readonly type: string;
87 /** Absent for a format with no signature (STL); the extension decides. */
88 readonly magic?: Magic;
89 }
90
91 /** The allowlist. A media type that is not here is never served. */
92 const MEDIA: Readonly<Record<string, Media>> = {
93 png: { kind: "image", type: "image/png", magic: bytes(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a) },
94 jpg: { kind: "image", type: "image/jpeg", magic: bytes(0xff, 0xd8, 0xff) },
95 jpeg: { kind: "image", type: "image/jpeg", magic: bytes(0xff, 0xd8, 0xff) },
96 gif: { kind: "image", type: "image/gif", magic: any(ascii("GIF87a"), ascii("GIF89a")) },
97 webp: { kind: "image", type: "image/webp", magic: riff("WEBP") },
98 avif: { kind: "image", type: "image/avif", magic: (h) => ftyp(h) && (at(h, 8, "avif") || at(h, 8, "avis")) },
99 bmp: { kind: "image", type: "image/bmp", magic: ascii("BM") },
100 ico: { kind: "image", type: "image/x-icon", magic: bytes(0x00, 0x00, 0x01, 0x00) },
101 svg: { kind: "svg", type: "image/svg+xml", magic: svgRoot },
102 pdf: { kind: "pdf", type: "application/pdf", magic: ascii("%PDF-") },
103 mp3: { kind: "audio", type: "audio/mpeg", magic: mpegAudio },
104 ogg: { kind: "audio", type: "audio/ogg", magic: ascii("OggS") },
105 oga: { kind: "audio", type: "audio/ogg", magic: ascii("OggS") },
106 wav: { kind: "audio", type: "audio/wav", magic: riff("WAVE") },
107 flac: { kind: "audio", type: "audio/flac", magic: ascii("fLaC") },
108 m4a: { kind: "audio", type: "audio/mp4", magic: ftyp },
109 aac: { kind: "audio", type: "audio/aac", magic: any(adts, ftyp) },
110 mp4: { kind: "video", type: "video/mp4", magic: ftyp },
111 m4v: { kind: "video", type: "video/mp4", magic: ftyp },
112 webm: { kind: "video", type: "video/webm", magic: bytes(0x1a, 0x45, 0xdf, 0xa3) },
113 mov: { kind: "video", type: "video/quicktime", magic: quicktime },
114 stl: { kind: "model3d", type: "model/stl" },
115 glb: { kind: "model3d", type: "model/gltf-binary", magic: ascii("glTF") },
116 };
117
118 /** Kinds read as text: the extension names the kind, the bytes must be text. */
119 const TEXTUAL: Readonly<Record<string, FileKind>> = {
120 csv: "csv",
121 tsv: "csv",
122 gltf: "model3d",
123 json: "config",
124 jsonc: "config",
125 yaml: "config",
126 yml: "config",
127 toml: "config",
128 ini: "config",
129 conf: "config",
130 md: "markdown",
131 markdown: "markdown",
132 mdx: "markdown",
133 };
134
135 /** For a caller that has only a name: the kind its extension gives. */
136 export const NO_BYTES: Uint8Array = new Uint8Array(0);
137
138 /** How many leading bytes the registry needs to decide. */
139 export const HEAD_BYTES = 4096;
140
141 const extension = (path: string): string => {
142 const name = path.slice(path.lastIndexOf("/") + 1);
143 const dot = name.lastIndexOf(".");
144 return dot <= 0 ? "" : name.slice(dot + 1).toLowerCase();
145 };
146
147 /** Git's own heuristic: a NUL in the first 8 KB means binary. */
148 const looksBinary = (head: Uint8Array): boolean => head.subarray(0, 8000).includes(0);
149
150 /** The allowlisted media entry for a file, if its bytes agree with its name. */
151 function confirmed(path: string, head: Uint8Array): Media | undefined {
152 const media = MEDIA[extension(path)];
153 if (media === undefined) return undefined;
154 if (media.magic !== undefined && !media.magic(head)) return undefined;
155 return media;
156 }
157
158 /**
159 * What a file is, from its name and its first bytes (`HEAD_BYTES` is enough).
160 * A name the bytes contradict — a `.png` that is not a PNG — is read as the
161 * text or binary its bytes say it is.
162 */
163 export function fileKind(path: string, head: Uint8Array): FileKind {
164 const media = confirmed(path, head);
165 if (media !== undefined) return media.kind;
166 if (looksBinary(head)) return "binary";
167 return TEXTUAL[extension(path)] ?? "text";
168 }
169
170 /**
171 * The one content type the media route sends a file under: an allowlisted
172 * media type its bytes confirm, or `application/octet-stream`.
173 */
174 export function mediaType(path: string, head: Uint8Array): string {
175 return confirmed(path, head)?.type ?? "application/octet-stream";
176 }
177
178 /** Every extension the registry names, for the test that walks them all. */
179 export const KNOWN_EXTENSIONS: readonly string[] = [
180 ...Object.keys(MEDIA),
181 ...Object.keys(TEXTUAL),
182 ];
src/web/render/markdown.ts added +13
@@ -0,0 +1,13 @@
1 /** A Markdown file as prose, framed exactly the way a README is. */
2
3 import { html } from "../html.ts";
4 import { renderMarkdown } from "../markdown.ts";
5 import type { Rendered, RenderInput } from "./renderers.ts";
6
7 export function markdown(input: RenderInput): Rendered {
8 const text = new TextDecoder().decode(input.body);
9 return {
10 lines: text === "" ? 0 : text.replace(/\n$/, "").split("\n").length,
11 content: html`<div class="card prose">${renderMarkdown(text)}</div>`,
12 };
13 }
src/web/render/renderers.ts added +31
@@ -0,0 +1,31 @@
1 /**
2 * What draws each kind of file on the blob page. A renderer lives in its own
3 * file beside this one (`render/<kind>.ts`) and is named once, in
4 * `RENDERERS`; a kind missing here keeps the view it always had.
5 */
6
7 import type { Html } from "../html.ts";
8 import type { FileKind } from "./kinds.ts";
9 import { markdown } from "./markdown.ts";
10
11 /** What a renderer is handed: the file, and where its bytes can be fetched. */
12 export interface RenderInput {
13 readonly path: string;
14 readonly size: number;
15 /** The whole body, when the page read it; a heavy viewer fetches `media`. */
16 readonly body: Uint8Array;
17 /** `/:owner/:name/media/:ref/*path`, for `<img>`, `<video>` and `fetch()`. */
18 readonly media: string;
19 }
20
21 export interface Rendered {
22 readonly content: Html;
23 /** Lines the file has, for the header's count; 0 when lines mean nothing. */
24 readonly lines: number;
25 }
26
27 export type Renderer = (input: RenderInput) => Rendered;
28
29 export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
30 markdown,
31 };
test/media.test.ts added +331
@@ -0,0 +1,331 @@
1 /**
2 * The media route, `/:owner/:name/media/:ref/*path`, against a real forge:
3 * an allowlisted type confirmed by the bytes, the sandbox policy that keeps
4 * an SVG's script from running, byte ranges on a buffered and a streamed
5 * file, the ETag and its 304, and `/raw`'s access — a private repository is
6 * a 404 to a stranger. And the blob page's switch points at it.
7 */
8
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createApp, type App } from "../src/app.ts";
14 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { createSession } from "../src/core/sessions.ts";
16 import { mediaGate } from "../src/git/env.ts";
17 import { blobStream, MEDIA_QUEUE_MAX, refTip, readerFor } from "../src/git/read.ts";
18 import { repoDir } from "../src/git/repos.ts";
19 import { serve, type Servers } from "../src/http/server.ts";
20 import { MEDIA_POLICY } from "../src/web/pages/media.ts";
21 import { seedAdmin } from "./helpers/admin.ts";
22 import { quietLogs } from "./helpers/globals.ts";
23
24 quietLogs();
25
26 const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 1, 2, 3]);
27 const EVIL_SVG =
28 '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(document.cookie)</script><rect width="1" height="1"/></svg>\n';
29 /** Over the pooled read, so it is sniffed and streamed off git. */
30 const CLIP = (() => {
31 const bytes = new Uint8Array(1_500_000);
32 for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 7) & 0xff;
33 bytes.set(new TextEncoder().encode("\0\0\0\x20ftypisom"), 0);
34 return bytes;
35 })();
36
37 let dataDir = "";
38 let app: App;
39 let servers: Servers;
40 let base = "";
41 let alice = "";
42 let sha = "";
43
44 const get = (path: string, headers: Record<string, string> = {}): Promise<Response> =>
45 fetch(`${base}${path}`, { redirect: "manual", headers });
46
47 beforeAll(async () => {
48 dataDir = await mkdtemp(join(tmpdir(), "forge-media-"));
49 app = createApp({ dataDir, port: 0 });
50 servers = await serve(app);
51 base = servers.url;
52 const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-media-passphrase" });
53 if (!a.ok) throw new Error("signup failed");
54 alice = `forge_session=${createSession(app.db, a.value.id).token}`;
55
56 const shown = await createRepo(app, a.value, { name: "gallery", visibility: "public" });
57 if (!shown.ok) throw new Error("repo failed");
58 const made = await commitToBranch(app, a.value, shown.value, {
59 message: "pictures",
60 files: [
61 { path: "art/logo.png", content: PNG },
62 { path: "art/fake.png", content: "not a png at all\n" },
63 { path: "art/evil.svg", content: EVIL_SVG },
64 { path: "art/page.html", content: "<script>alert(1)</script>" },
65 { path: "clip.mp4", content: CLIP },
66 { path: "README.md", content: "# gallery\n" },
67 { path: "src/a.ts", content: "export const a = 1\n" },
68 { path: "empty.png", content: "" },
69 ],
70 });
71 if (!made.ok) throw new Error("commit failed");
72 sha = (await refTip(repoDir(app.reposRoot, "alice", "gallery"), "refs/heads/main")) ?? "";
73
74 const hidden = await createRepo(app, a.value, { name: "vault", visibility: "private" });
75 if (!hidden.ok) throw new Error("private repo failed");
76 await commitToBranch(app, a.value, hidden.value, {
77 message: "hidden",
78 files: [{ path: "secret.png", content: PNG }],
79 });
80 });
81
82 afterAll(async () => {
83 await servers.stop();
84 app.close();
85 await rm(dataDir, { recursive: true, force: true });
86 });
87
88 describe("the media route", () => {
89 test("a confirmed PNG is served as image/png with its bytes", async () => {
90 const res = await get("/alice/gallery/media/main/art/logo.png");
91 expect(res.status).toBe(200);
92 expect(res.headers.get("content-type")).toBe("image/png");
93 expect(res.headers.get("accept-ranges")).toBe("bytes");
94 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
95 expect(new Uint8Array(await res.arrayBuffer())).toEqual(PNG);
96 });
97
98 test("a name the bytes contradict, or a type off the allowlist, is octet-stream", async () => {
99 const fake = await get("/alice/gallery/media/main/art/fake.png");
100 expect(fake.status).toBe(200);
101 expect(fake.headers.get("content-type")).toBe("application/octet-stream");
102 const page = await get("/alice/gallery/media/main/art/page.html");
103 expect(page.headers.get("content-type")).toBe("application/octet-stream");
104 });
105
106 test("an SVG with a script is an image under the sandbox policy", async () => {
107 const res = await get("/alice/gallery/media/main/art/evil.svg");
108 expect(res.status).toBe(200);
109 expect(res.headers.get("content-type")).toBe("image/svg+xml");
110 expect(res.headers.get("content-security-policy")).toBe(MEDIA_POLICY);
111 expect(MEDIA_POLICY).toStartWith("sandbox;");
112 expect(MEDIA_POLICY).toContain("default-src 'none'");
113 expect(MEDIA_POLICY).not.toContain("script-src");
114 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
115 });
116
117 test("a single range is 206 with its window", async () => {
118 const res = await get("/alice/gallery/media/main/art/logo.png", { range: "bytes=1-3" });
119 expect(res.status).toBe(206);
120 expect(res.headers.get("content-range")).toBe(`bytes 1-3/${PNG.length}`);
121 expect(res.headers.get("content-length")).toBe("3");
122 expect(new Uint8Array(await res.arrayBuffer())).toEqual(PNG.subarray(1, 4));
123 });
124
125 test("a suffix range is the last bytes", async () => {
126 const res = await get("/alice/gallery/media/main/art/logo.png", { range: "bytes=-4" });
127 expect(res.status).toBe(206);
128 expect(res.headers.get("content-range")).toBe(`bytes ${PNG.length - 4}-${PNG.length - 1}/${PNG.length}`);
129 expect(new Uint8Array(await res.arrayBuffer())).toEqual(PNG.subarray(PNG.length - 4));
130 });
131
132 test("a range past the end is 416", async () => {
133 const res = await get("/alice/gallery/media/main/art/logo.png", { range: `bytes=${PNG.length}-` });
134 expect(res.status).toBe(416);
135 expect(res.headers.get("content-range")).toBe(`bytes */${PNG.length}`);
136 const empty = await get("/alice/gallery/media/main/empty.png", { range: "bytes=0-" });
137 expect(empty.status).toBe(416);
138 });
139
140 test("a large file streams off git, whole and in windows", async () => {
141 const whole = await get("/alice/gallery/media/main/clip.mp4");
142 expect(whole.status).toBe(200);
143 expect(whole.headers.get("content-type")).toBe("video/mp4");
144 expect(new Uint8Array(await whole.arrayBuffer())).toEqual(CLIP);
145
146 // A window across git's pipe chunks, the kind a seeking video asks for.
147 const mid = await get("/alice/gallery/media/main/clip.mp4", { range: "bytes=700000-900122" });
148 expect(mid.status).toBe(206);
149 expect(mid.headers.get("content-range")).toBe(`bytes 700000-900122/${CLIP.length}`);
150 expect(new Uint8Array(await mid.arrayBuffer())).toEqual(CLIP.subarray(700000, 900123));
151
152 const tail = await get("/alice/gallery/media/main/clip.mp4", { range: "bytes=-10" });
153 expect(new Uint8Array(await tail.arrayBuffer())).toEqual(CLIP.subarray(CLIP.length - 10));
154 });
155
156 test("a reader that hangs up mid-stream hands its permit back", async () => {
157 const res = await get("/alice/gallery/media/main/clip.mp4");
158 const reader = res.body!.getReader();
159 expect((await reader.read()).done).toBe(false);
160 await reader.cancel();
161 for (let i = 0; i < 200 && mediaGate.stats().active > 0; i++) await Bun.sleep(10);
162 expect(mediaGate.stats().active).toBe(0);
163 });
164
165 test("If-Range that still holds gets the range; a stale one gets the whole file", async () => {
166 const etag = (await get("/alice/gallery/media/main/art/logo.png")).headers.get("etag") ?? "";
167 const held = await get("/alice/gallery/media/main/art/logo.png", { range: "bytes=1-3", "if-range": etag });
168 expect(held.status).toBe(206);
169 expect(new Uint8Array(await held.arrayBuffer())).toEqual(PNG.subarray(1, 4));
170 const stale = await get("/alice/gallery/media/main/art/logo.png", {
171 range: "bytes=1-3",
172 "if-range": `"${"0".repeat(40)}"`,
173 });
174 expect(stale.status).toBe(200);
175 expect(stale.headers.get("content-range")).toBeNull();
176 expect(new Uint8Array(await stale.arrayBuffer())).toEqual(PNG);
177 });
178
179 test("the ETag is the blob id, and holding it is a 304", async () => {
180 const res = await get("/alice/gallery/media/main/art/logo.png");
181 const etag = res.headers.get("etag") ?? "";
182 expect(etag).toMatch(/^"[0-9a-f]{40}"$/);
183 const again = await get("/alice/gallery/media/main/art/logo.png", { "if-none-match": etag });
184 expect(again.status).toBe(304);
185 expect(await again.text()).toBe("");
186 });
187
188 test("a 304 is answered from the stat alone: no sniff, no permit", async () => {
189 const etag = (await get("/alice/gallery/media/main/clip.mp4", { range: "bytes=0-0" })).headers.get("etag") ?? "";
190 const held = await holdAll();
191 try {
192 // Every permit is taken, so a 304 that sniffed would wait here.
193 const again = await get("/alice/gallery/media/main/clip.mp4", { "if-none-match": etag });
194 expect(again.status).toBe(304);
195 expect(again.headers.get("content-type")).toBeNull();
196 expect(again.headers.get("etag")).toBe(etag);
197 } finally {
198 held.forEach((release) => release());
199 }
200 });
201
202 test("a full commit id is cached for a year; a branch revalidates", async () => {
203 const pinned = await get(`/alice/gallery/media/${sha}/art/logo.png`);
204 expect(pinned.status).toBe(200);
205 expect(pinned.headers.get("cache-control")).toBe("private, max-age=31536000, immutable");
206 const moving = await get("/alice/gallery/media/main/art/logo.png");
207 expect(moving.headers.get("cache-control")).toBe("private, max-age=0");
208 });
209
210 test("?download=1 is an attachment under its own name, never rendered", async () => {
211 const res = await get("/alice/gallery/media/main/art/evil.svg?download=1");
212 expect(res.headers.get("content-type")).toBe("application/octet-stream");
213 expect(res.headers.get("content-disposition")).toStartWith('attachment; filename="evil.svg"');
214 });
215
216 test("a private repository is a 404 signed out, and its owner's to see", async () => {
217 const out = await get("/alice/vault/media/main/secret.png");
218 expect(out.status).toBe(404);
219 expect(out.headers.get("content-type") ?? "").not.toStartWith("image/");
220 const owner = await get("/alice/vault/media/main/secret.png", { cookie: alice });
221 expect(owner.status).toBe(200);
222 expect(owner.headers.get("content-type")).toBe("image/png");
223 });
224
225 test("a missing file, a tree, and an unsafe ref are the same 404", async () => {
226 expect((await get("/alice/gallery/media/main/nope.png")).status).toBe(404);
227 expect((await get("/alice/gallery/media/main/art")).status).toBe(404);
228 expect((await get("/alice/gallery/media/--output=x/art/logo.png")).status).toBe(404);
229 });
230 });
231
232 /** Take every media permit, as sixteen stalled streams would. */
233 async function holdAll(): Promise<(() => void)[]> {
234 const held: (() => void)[] = [];
235 while (mediaGate.stats().active < mediaGate.limit) held.push(await mediaGate.acquire());
236 return held;
237 }
238
239 const settled = async (): Promise<number> => {
240 for (let i = 0; i < 300 && mediaGate.stats().active > 0; i++) await Bun.sleep(10);
241 return mediaGate.stats().active;
242 };
243
244 describe("the media permits", () => {
245 const clip = async (): Promise<{ dir: string; oid: string }> => {
246 const dir = repoDir(app.reposRoot, "alice", "gallery");
247 const stat = await readerFor(dir).stat("main:clip.mp4");
248 if (!stat.ok) throw new Error("no clip");
249 return { dir, oid: stat.value.oid };
250 };
251
252 test("a reader that stalls gets its permit taken back", async () => {
253 const { dir, oid } = await clip();
254 const stream = await blobStream(dir, oid, 0, CLIP.length - 1, { idleMs: 50 });
255 if (typeof stream === "string") throw new Error(stream);
256 const reader = stream.getReader();
257 expect((await reader.read()).done).toBe(false);
258 // Then it never pulls again.
259 expect(await settled()).toBe(0);
260 await expect(reader.read()).rejects.toThrow("idle");
261 });
262
263 test("a stream nobody ever reads ends too", async () => {
264 const { dir, oid } = await clip();
265 const stream = await blobStream(dir, oid, 0, 9, { idleMs: 50 });
266 expect(typeof stream).not.toBe("string");
267 expect(await settled()).toBe(0);
268 });
269
270 test("a reader that leaves while it waits for a permit spends none", async () => {
271 const { dir, oid } = await clip();
272 const held = await holdAll();
273 const gone = new AbortController();
274 const waiting = blobStream(dir, oid, 0, 9, { signal: gone.signal });
275 expect(mediaGate.stats().waiting).toBe(1);
276 gone.abort();
277 held.forEach((release) => release());
278 expect(await waiting).toBe("gone");
279 expect(await settled()).toBe(0);
280 });
281
282 test("a full queue is a 503 with Retry-After, not one more waiter", async () => {
283 const { dir, oid } = await clip();
284 const held = await holdAll();
285 const queued = Array.from({ length: MEDIA_QUEUE_MAX }, () => blobStream(dir, oid, 0, 9));
286 try {
287 expect(mediaGate.stats().waiting).toBe(MEDIA_QUEUE_MAX);
288 const res = await get("/alice/gallery/media/main/clip.mp4");
289 expect(res.status).toBe(503);
290 expect(res.headers.get("retry-after")).toBe("5");
291 expect(mediaGate.stats().waiting).toBe(MEDIA_QUEUE_MAX);
292 // A small file never queues: it is read through the pool.
293 expect((await get("/alice/gallery/media/main/art/logo.png")).status).toBe(200);
294 } finally {
295 held.forEach((release) => release());
296 for (const stream of await Promise.all(queued))
297 if (typeof stream !== "string") await stream.cancel();
298 }
299 expect(await settled()).toBe(0);
300 });
301 });
302
303 describe("the blob page's switch", () => {
304 const flat = (s: string): string => s.replace(/\s+/g, " ");
305
306 test("Markdown chooses Rendered or Source, beside Raw and Download", async () => {
307 const html = flat(await (await get("/alice/gallery/blob/main/README.md")).text());
308 expect(html).toContain('aria-current="page" >Rendered');
309 expect(html).toContain('href="/alice/gallery/blob/main/README.md?view=source"');
310 expect(html).toContain('href="/alice/gallery/raw/main/README.md"');
311 expect(html).toContain('href="/alice/gallery/media/main/README.md?download=1"');
312 // Raw and Download are actions, not views: never inside the switch.
313 const views = /<div class="seg">(.*?)<\/div>/.exec(html)?.[1] ?? "";
314 expect(views).toContain("Rendered");
315 expect(views).not.toContain("Raw");
316 expect(views).not.toContain("Download");
317 });
318
319 test("a kind with no renderer keeps its view, no switch, with Raw and Download", async () => {
320 const code = flat(await (await get("/alice/gallery/blob/main/src/a.ts")).text());
321 expect(code).toContain('class="card code"');
322 expect(code).not.toContain('class="blob-view"');
323 expect(code).not.toContain(">Rendered");
324 expect(code).toContain('href="/alice/gallery/raw/main/src/a.ts"');
325 expect(code).toContain('href="/alice/gallery/media/main/src/a.ts?download=1"');
326
327 const png = flat(await (await get("/alice/gallery/blob/main/art/logo.png")).text());
328 expect(png).toContain("Binary file");
329 expect(png).toContain('href="/alice/gallery/media/main/art/logo.png?download=1"');
330 });
331 });
test/render-kinds.test.ts added +125
@@ -0,0 +1,125 @@
1 /**
2 * The registry of file kinds and the byte-range reader, as plain data: every
3 * extension the registry names lands on its kind, a name its bytes
4 * contradict falls back, and a Range header reads as the window it asks for.
5 */
6
7 import { describe, expect, test } from "bun:test";
8 import { parseRange } from "../src/http/range.ts";
9 import { fileKind, KNOWN_EXTENSIONS, mediaType, type FileKind } from "../src/web/render/kinds.ts";
10
11 const enc = (s: string): Uint8Array => new TextEncoder().encode(s);
12 const of = (...b: number[]): Uint8Array => new Uint8Array(b);
13 const box = (brand: string): Uint8Array => enc(`\0\0\0\x20ftyp${brand}`);
14
15 /** One true head per extension, and the kind and type it must come back as. */
16 const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = {
17 png: [of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0), "image", "image/png"],
18 jpg: [of(0xff, 0xd8, 0xff, 0xe0), "image", "image/jpeg"],
19 jpeg: [of(0xff, 0xd8, 0xff, 0xe1), "image", "image/jpeg"],
20 gif: [enc("GIF89a\0"), "image", "image/gif"],
21 webp: [enc("RIFF\0\0\0\0WEBPVP8 "), "image", "image/webp"],
22 avif: [box("avif"), "image", "image/avif"],
23 bmp: [enc("BM\0\0"), "image", "image/bmp"],
24 ico: [of(0, 0, 1, 0, 1), "image", "image/x-icon"],
25 svg: [enc('<?xml version="1.0"?>\n<svg xmlns="http://www.w3.org/2000/svg"/>'), "svg", "image/svg+xml"],
26 pdf: [enc("%PDF-1.7\n"), "pdf", "application/pdf"],
27 mp3: [enc("ID3\x04\0"), "audio", "audio/mpeg"],
28 ogg: [enc("OggS\0"), "audio", "audio/ogg"],
29 oga: [enc("OggS\0"), "audio", "audio/ogg"],
30 wav: [enc("RIFF\0\0\0\0WAVEfmt "), "audio", "audio/wav"],
31 flac: [enc("fLaC\0"), "audio", "audio/flac"],
32 m4a: [box("M4A "), "audio", "audio/mp4"],
33 aac: [of(0xff, 0xf1, 0x50, 0x80), "audio", "audio/aac"],
34 mp4: [box("isom"), "video", "video/mp4"],
35 m4v: [box("M4V "), "video", "video/mp4"],
36 webm: [of(0x1a, 0x45, 0xdf, 0xa3, 0), "video", "video/webm"],
37 mov: [box("qt "), "video", "video/quicktime"],
38 stl: [enc("solid cube\n"), "model3d", "model/stl"],
39 glb: [enc("glTF\x02\0\0\0"), "model3d", "model/gltf-binary"],
40 csv: [enc("a,b\n1,2\n"), "csv", "application/octet-stream"],
41 tsv: [enc("a\tb\n"), "csv", "application/octet-stream"],
42 gltf: [enc('{"asset":{}}'), "model3d", "application/octet-stream"],
43 json: [enc("{}"), "config", "application/octet-stream"],
44 jsonc: [enc("{}"), "config", "application/octet-stream"],
45 yaml: [enc("a: 1\n"), "config", "application/octet-stream"],
46 yml: [enc("a: 1\n"), "config", "application/octet-stream"],
47 toml: [enc("a = 1\n"), "config", "application/octet-stream"],
48 ini: [enc("[a]\n"), "config", "application/octet-stream"],
49 conf: [enc("a 1\n"), "config", "application/octet-stream"],
50 md: [enc("# a\n"), "markdown", "application/octet-stream"],
51 markdown: [enc("# a\n"), "markdown", "application/octet-stream"],
52 mdx: [enc("# a\n"), "markdown", "application/octet-stream"],
53 };
54
55 describe("the registry of file kinds", () => {
56 test("every extension it names maps to its kind and its one media type", () => {
57 expect(KNOWN_EXTENSIONS.length).toBeGreaterThan(0);
58 for (const ext of KNOWN_EXTENSIONS) {
59 const sample = SAMPLES[ext];
60 expect(sample, `a sample for .${ext}`).toBeDefined();
61 const [head, kind, type] = sample!;
62 expect([ext, fileKind(`dir/file.${ext}`, head)]).toEqual([ext, kind]);
63 expect([ext, mediaType(`dir/FILE.${ext.toUpperCase()}`, head)]).toEqual([ext, type]);
64 }
65 });
66
67 test("a name the bytes contradict falls back to octet-stream and to what the bytes are", () => {
68 expect(mediaType("logo.png", enc("not a png"))).toBe("application/octet-stream");
69 expect(fileKind("logo.png", enc("not a png"))).toBe("text");
70 expect(mediaType("clip.mp4", of(0, 1, 2, 0))).toBe("application/octet-stream");
71 expect(fileKind("clip.mp4", of(0, 1, 2, 0))).toBe("binary");
72 // A PNG wearing another format's name is still not served as that format.
73 const png = SAMPLES.png![0];
74 expect(mediaType("photo.jpg", png)).toBe("application/octet-stream");
75 // An SVG with no root element is not an SVG.
76 expect(mediaType("x.svg", enc("<html><script>1</script></html>"))).toBe(
77 "application/octet-stream",
78 );
79 });
80
81 test("nothing it can answer is HTML or script", () => {
82 for (const ext of [...KNOWN_EXTENSIONS, "html", "htm", "js", "mjs", "xhtml", "xml"]) {
83 const head = SAMPLES[ext]?.[0] ?? enc("<script>alert(1)</script>");
84 const type = mediaType(`a.${ext}`, head);
85 expect(type).not.toMatch(/html|javascript|ecmascript|xml(?!$)/);
86 expect(type).not.toBe("text/html");
87 }
88 expect(mediaType("index.html", enc("<html>"))).toBe("application/octet-stream");
89 });
90
91 test("a file with no known extension is text or binary by its bytes", () => {
92 expect(fileKind("Makefile", enc("all:\n"))).toBe("text");
93 expect(fileKind("src/a.ts", enc("export {}\n"))).toBe("text");
94 expect(fileKind("blob.dat", of(1, 0, 2))).toBe("binary");
95 expect(fileKind(".png", SAMPLES.png![0])).toBe("binary");
96 });
97 });
98
99 describe("a byte range", () => {
100 test("a single range, clamped to the file", () => {
101 expect(parseRange("bytes=0-9", 100)).toEqual({ start: 0, end: 9 });
102 expect(parseRange("bytes=90-200", 100)).toEqual({ start: 90, end: 99 });
103 expect(parseRange("bytes=10-", 100)).toEqual({ start: 10, end: 99 });
104 });
105
106 test("a suffix range is the last N bytes", () => {
107 expect(parseRange("bytes=-10", 100)).toEqual({ start: 90, end: 99 });
108 expect(parseRange("bytes=-500", 100)).toEqual({ start: 0, end: 99 });
109 });
110
111 test("past the end is unsatisfiable", () => {
112 expect(parseRange("bytes=100-", 100)).toBe("unsatisfiable");
113 expect(parseRange("bytes=-0", 100)).toBe("unsatisfiable");
114 expect(parseRange("bytes=0-", 0)).toBe("unsatisfiable");
115 });
116
117 test("anything else is the whole file", () => {
118 expect(parseRange(null, 100)).toBeNull();
119 expect(parseRange("bytes=0-1,5-6", 100)).toBeNull();
120 expect(parseRange("items=0-1", 100)).toBeNull();
121 expect(parseRange("bytes=9-1", 100)).toBeNull();
122 expect(parseRange("bytes=-", 100)).toBeNull();
123 expect(parseRange("bytes=99999999999999999999-", 100)).toBeNull();
124 });
125 });
test/spawn-pipes.test.ts +1 −1
@@ -25,7 +25,7 @@ const PIPING: Record<string, { sites: string[]; reason: string }> = {
2525 "src/crew/workdir.ts": { sites: ["stderr"], reason: "stderr read to its end alongside the exit; stdout ignored" },
2626 "src/git/env.ts": { sites: ["stdout+stderr", "stdout+stderr"], reason: "stdout and stderr both read to the end, finally clears the timer" },
2727 "src/git/local.ts": { sites: ["stdout+stderr", "stdout+stderr"], reason: "stdout and stderr both read to the end" },
28 "src/git/read.ts": { sites: ["stdout"], reason: "cat-file stdout is read by the long-lived reader; stderr ignored" },
28 "src/git/read.ts": { sites: ["stdout", "stdout"], reason: "cat-file stdout is read by the long-lived reader, and by blobStream until its window ends or the reader hangs up (then killed); stderr ignored" },
2929 "src/git/smart-http.ts": { sites: ["stdout"], reason: "stdout is a pull-pipe FIFO (the response body, read on demand); stderr ignored" },
3030 "src/git/smart-ssh.ts": { sites: ["stdout+stderr"], reason: "pump() drains stdout (a pull-pipe FIFO) and stderr into the channel" },
3131 "src/kernel/data_home.ts": { sites: ["stdout"], reason: "spawnSync buffers stdout; stderr ignored" },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs