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
Check · Review · Land · Live done — Landed and live in production.
This adds `GET /:owner/:name/media/:ref/*path`, which serves a file's bytes under a real media type, and one registry of file kinds (`src/web/render/kinds.ts`) that the blob page now asks how to draw a file. Every renderer in #7329 builds on these two. Type check and design lint pass. The new tests pass, and so do the existing blob, security-floor and cross-file suites. The full gate has not run y…
shipped production is serving it deploy 4db86a7
This adds GET /:owner/:name/media/:ref/*path, which serves a file's bytes under a real media type, and one registry of file kinds (src/web/render/kinds.ts) that the blob page now asks how to draw a file. Every renderer in #7329 builds on these two. Type check and design lint pass. The new tests pass, and so do the existing blob, security-floor and cross-file suites. The full gate has not run yet; it runs on this push.
What moved where
src/web/render/kinds.ts(new):fileKind(path, firstBytes)returns one of the 11 kinds, andmediaType(path, firstBytes)gives the type the route sends.
- The extension proposes a type from the allowlist and the file's first bytes must confirm it. A mismatch, or any file not on the list, goes out as application/octet-stream. HTML and JavaScript are never served.
- STL has no signature, so its extension alone decides.
- RENDER_CAP is the largest file each kind draws inline.
- RENDERERS maps a kind to its renderer. Only markdown has one so far.
src/web/render/markdown.ts(new): Markdown's prose view, moved out ofrepo.tsas the first renderer and the pattern later ones follow.src/http/range.ts(new): reads a singlebytes=range, a suffix range, or an unsatisfiable one as plain data. A list of ranges is answered with the whole file, which the RFC allows.src/web/pages/media.ts(new): the route.
- Headers: nosniff and the sandbox CSP from the issue, so an SVG with a script is drawn as a picture and never runs.
- Ranges: 206 with Content-Range, 416 past the end, and If-Range is honoured.
- Caching: the ETag is the blob id and a matching If-None-Match gets 304. A full commit sha caches for a year as immutable; a branch revalidates every time.
- Access is the same as /raw.
- ?download=1 sends the file as an attachment under its own name, at any size.
- Streaming:
- blobStream in src/git/read.ts streams files over 1 MB straight off git cat-file, never holding them whole.
- Streams draw on their own mediaGate in src/git/env.ts (FORGE_MEDIA_CONCURRENCY, default 16), so long video streams can't use up the git permits every page needs.
- A reader that hangs up kills the git process and gives its permit back.
renderBlobinrepo.tsnow asks the registry. A kind with no renderer keeps the view it had. Every file's header hasRendered | Source | Raw | Download, with Rendered only where a renderer exists. The Download button, including the binary and too-large cases, goes through the media route.- Docs: how to add a renderer is in
docs/features/file-media.md, and the CLAUDE.md map namesrender/.
What to look at
- Streamed bodies are sent chunked, with no
Content-Length. Bun ignores a length set on a large stream. A 206 still names the full size inContent-Range, which is what a seeking player reads. Buffered bodies (1 MB or less) do carry a length. - Bytes before a range's start are read and discarded. Git cannot seek inside a blob, so seeking deep into a large video reads everything before that point.
- Not done here: the issue mentions serving from a bucket, but nothing in this repository stores blobs in one (there is no LFS), so the route streams from git only. Each renderer is left to #7329.
Tests
test/render-kinds.test.ts: every extension maps to its kind and type, mismatched bytes fall back, nothing comes back as HTML or script, and the range parser cases.test/media.test.tsboots a real forge and covers:
- types, and the octet-stream fallback; - the SVG served under the sandbox CSP; - single, suffix and 416 ranges on both a buffered file and a 1.5 MB streamed one; - the permit being returned when a reader hangs up; - the 304 on the ETag, and immutable vs revalidated caching; - downloads; - a private repo returning 404 signed out but 200 to its owner; - the blob page's switch.
To try it: open any file on the preview and use Download, or open /<owner>/<repo>/media/main/<file>.png directly.
🤖 Generated with Claude Code
This adds GET /:owner/:name/media/:ref/*path, which serves a file's bytes under a real media type, and one registry of file kinds (src/web/render/kinds.ts) that the blob page now asks how to draw a file. Every renderer in #7329 builds on these two.
What moved where
src/web/render/kinds.ts(new):fileKind(path, firstBytes)returns one of the 11 kinds, andmediaType(path, firstBytes)gives the type the route sends.
- The extension proposes a type from the allowlist and the file's first bytes must confirm it. A mismatch, or any file not on the list, goes out as application/octet-stream. HTML and JavaScript are never served.
- STL has no signature, so its extension alone decides.
- RENDER_CAP is the largest file each kind draws inline.
- RENDERERS maps a kind to its renderer. Only markdown has one so far.
src/web/render/markdown.ts(new): Markdown's prose view, moved out ofrepo.tsas the first renderer and the pattern later ones follow.src/http/range.ts(new): reads a singlebytes=range, a suffix range, or an unsatisfiable one as plain data. A list of ranges is answered with the whole file, which the RFC allows.src/web/pages/media.ts(new): the route.
- Headers: nosniff and the sandbox CSP from the issue, so an SVG with a script is drawn as a picture and never runs.
- Ranges: 206 with Content-Range, 416 past the end, and If-Range is honoured.
- Caching: the ETag is the blob id and a matching If-None-Match gets 304. A full commit sha caches for a year as immutable; a branch revalidates every time.
- Access is the same as /raw.
- ?download=1 sends the file as an attachment under its own name, at any size.
- Streaming:
- blobStream in src/git/read.ts streams files over 1 MB straight off git cat-file, never holding them whole.
- Streams draw on their own mediaGate in src/git/env.ts (FORGE_MEDIA_CONCURRENCY, default 16), so long video streams can't use up the git permits every page needs.
- A reader that hangs up kills the git process and gives its permit back.
- The new piped spawn is listed in test/spawn-pipes.test.ts with why its stdout is read. That test was right to fail the first check.
renderBlobinrepo.tsnow asks the registry. A kind with no renderer keeps the view it had. Every file's header hasRendered | Source | Raw | Download, with Rendered only where a renderer exists. The Download button, including the binary and too-large cases, goes through the media route.- Docs: how to add a renderer is in
docs/features/file-media.md, and the CLAUDE.md map namesrender/.
What to look at
- Streamed bodies are sent chunked, with no
Content-Length. Bun ignores a length set on a large stream. A 206 still names the full size inContent-Range, which is what a seeking player reads. Buffered bodies (1 MB or less) do carry a length. - Bytes before a range's start are read and discarded. Git cannot seek inside a blob, so seeking deep into a large video reads everything before that point.
- Not done here: the issue mentions serving from a bucket, but nothing in this repository stores blobs in one (there is no LFS), so the route streams from git only. Each renderer is left to #7329.
Tests
test/render-kinds.test.ts: every extension maps to its kind and type, mismatched bytes fall back, nothing comes back as HTML or script, and the range parser cases.test/media.test.tsboots a real forge and covers:
- types, and the octet-stream fallback; - the SVG served under the sandbox CSP; - single, suffix and 416 ranges on both a buffered file and a 1.5 MB streamed one; - the permit being returned when a reader hangs up; - the 304 on the ETag, and immutable vs revalidated caching; - downloads; - a private repo returning 404 signed out but 200 to its owner; - the blob page's switch.
To try it: open any file on the preview and use Download, or open /<owner>/<repo>/media/main/<file>.png directly.
🤖 Generated with Claude Code
The route is careful: types come only from an allowlist and are confirmed by the file's first bytes, the sandbox CSP and nosniff are on every answer, access uses loadRepo/canRead the same way /raw does, the Content-Disposition is quoted safely, and the range parser is plain data with tests. I can't approve it yet, for these reasons (most important first):
- High — the media permits can be exhausted and leaked (DoS).
mediaGate.acquire()has no timeout and no cap on its queue, and a stream keeps its permit until its last byte goes out or the reader hangs up. Sixteen anonymous connections to any public file over 1 MB that never read (slowloris) hold all 16 permits for good. After that, every streamed file and every large-file sniff (media.ts:63) waits forever. Separately,cancel()can run whilestart()is still waiting inacquire(). When it does,finish()seesrelease/procundefined, and thenstart()takes a permit and spawns agit cat-filethat no one will ever read or kill. That is a leaked permit and a hung process each time. Fix: (a) record acancelledflag and, oncestart()acquires, release right away if it is set and do not spawn; (b) bound each stream with a deadline or idle timer that kills the process and releases the permit when no chunk has been pulled for N seconds; (c) cap the waiting queue (answer 503 with Retry-After when it is full) so a burst cannot pile up waiters without limit. Add tests that fail today: cancel during acquire leavesactiveat 0, and a reader that stalls gets its permit back.
- Design — Raw and Download are not views, so they don't belong in
seg.segis "a segmented control built from links" for choosing one state of the same page: here, Rendered or Source. Raw leaves the page and Download fetches an attachment, and neither can ever becurrent. Keepsegfor Rendered/Source only, and only when a renderer exists, as before. Keep Raw as thebtn(... size: "sm", variant: "outline")it was, and add Download beside it as abtnwith thedownloadicon.
- "One registry of file kinds" still has three answers for "is this Markdown".
src/web/pages/edit.ts:558andsrc/tui/screens/workspace.ts:258keep their ownisMarkdownPath. The terminal's file view decides markdown and code by its own regex, not byfileKind, so the two doors can disagree. Point both atfileKindfromrender/kinds.ts, which takes plain data and has no web dependencies, so the terminal can import it.
- Tests missing for promised behaviour.
If-Rangeis documented and implemented (media.ts:85-89) but untested. Add a matching etag → 206 case and a stale etag → 200 whole-file case. Also add the two gate tests from point 1.
- Low — the 304 comes after the body is read.
mediaBlobreads the whole object (≤1 MB) and sniffs before it checksIf-None-Match. The ETag is just the oid fromstat, so checkholds()before reading. Content-Type is not needed on a 304.
- README. The README doesn't mention the media route or the Download button. Add a line under the file-browsing section;
docs/features/file-media.mdalone is not where a person looks.
Notes, not blocking: the preview has no public repositories, so I could only confirm that /media/… gives the same HTML 404 as an unknown repository. I could not exercise a real image or range there; please seed one or say how to try it. When #7329 adds the PDF renderer, test it in Chrome: a sandbox CSP without allow-scripts blocks the built-in PDF viewer. This is also a feature pull while the direction is pipeline first; ordering is the owner's call, not something to change here.
This adds GET /:owner/:name/media/:ref/*path, which serves a file's bytes under a real media type, and one registry of file kinds (src/web/render/kinds.ts) that the blob page, the editor and the terminal all ask. Every renderer in #7329 builds on these two. Type check and design lint pass, and so do 377 tests across the media, blob-page, editor, terminal and cross files. The full gate runs on this push.
What moved where
src/web/render/kinds.ts(new):fileKind(path, firstBytes)returns one of the 11 kinds, andmediaType(path, firstBytes)gives the type the route sends.
- The extension proposes a type from the allowlist and the file's first bytes must confirm it. A mismatch, or any file not on the list, goes out as application/octet-stream. HTML and JavaScript are never served.
- RENDER_CAP is the largest file each kind draws inline.
- The file imports nothing, so the terminal can use it.
src/web/render/renderers.ts(new):RENDERERSmaps a kind to its renderer. Onlymarkdownhas one so far, inrender/markdown.ts, moved out ofrepo.ts.src/http/range.ts(new): reads a single, suffix or unsatisfiable byte range as plain data. A list of ranges gets the whole file.src/web/pages/media.ts(new): the route.
- Headers: nosniff and the sandbox CSP from the issue, so an SVG with a script is drawn as a picture and never runs.
- Ranges: 206 with Content-Range, 416 past the end, and If-Range is honoured.
- Caching: the ETag is the blob id. A 304 is answered from the stat alone, before any byte is read. A full commit sha caches for a year as immutable; a branch revalidates.
- Access is the same as /raw.
- ?download=1 sends the file as an attachment, at any size.
- Streaming and permits:
- blobStream in src/git/read.ts streams files over 1 MB off git cat-file, under its own mediaGate (16 permits).
- The permit is taken before the stream is created, so a stream can never be cancelled while it waits.
- A request that disconnects while queued gets its permit straight back and spawns nothing.
- An open stream gives its permit back when it ends, when the reader hangs up or the request's signal aborts, or after 30 s with nothing read (MEDIA_IDLE_MS).
- With 32 requests already waiting (MEDIA_QUEUE_MAX), the next gets 503 with Retry-After: 5.
- Files of 1 MB or less never queue; they are read through the pooled reader.
- Blob page:
renderBlobasks the registry, and a kind with no renderer keeps the view it had.
- The Rendered | Source switch shows only where a renderer exists.
- Raw stays an outline button, and Download sits beside it with the download icon, pointing at the media route.
- Editor and terminal:
edit.tsand the terminal'sworkspace.tsnow askfileKindwhether a path is Markdown. Two rules they had before stay as explicit local exceptions: the editor treats a file with no name yet as Markdown, and the terminal treats an extensionlessREADMEas Markdown. One visible change: the terminal now shows.mdxfiles as Markdown. - Docs: the README has a bullet for the media route and Download.
docs/features/file-media.mdcovers the route, how permits end, and how to add a renderer.
What to look at
- Streamed bodies are sent chunked, with no
Content-Length. Bun ignores a length on a large stream. A 206 still names the full size inContent-Range, which is what a seeking player reads. - Not done here: nothing in this repository stores blobs in a bucket (there is no LFS), so the route streams from git only. Each renderer is left to #7329.
- PDF in Chrome: the issue's sandbox CSP has no
allow-scripts, and that may block Chrome's built-in PDF viewer. The #7329 PDF renderer should be tried there before relying on<object>.
Tests
test/media.test.ts:
- types and the octet-stream fallback, and the SVG served under the sandbox CSP;
- single, suffix and 416 ranges on a buffered file and a 1.5 MB streamed one;
- If-Range: a matching tag gets 206, a stale one gets the whole file;
- a 304 answered while every permit is held;
- the permit cases: hung up, stalled, never read, left while queued, and a full queue giving 503;
- immutable vs revalidated caching, downloads, and a private repo returning 404 signed out but 200 to its owner;
- the blob page: Raw and Download outside the switch, and no switch where nothing renders.
test/render-kinds.test.ts: every extension maps to its kind and type, mismatched bytes fall back, and the range parser cases.
To try it, push an image or a video to a public repository on the preview and open its file page, or /<owner>/<repo>/media/main/<file>. Then curl -H 'Range: bytes=0-99' -D- … shows the 206.
🤖 Generated with Claude Code
Round 2. Every earlier ask is met:
- Cancelled while waiting → leaked permit and orphan
git cat-file(high): met.blobStreamnow takes the permit before the stream exists, so there is nothing to cancel while it waits. A request whose signal aborted in the queue gets"gone"and the permit goes back without spawning anything. An open stream ends on cancel, on abort, at its last byte, or afterMEDIA_IDLE_MSwith nothing pulled. The timer starts instart(), so a stream nobody ever reads ends too. Tests cover each case (stalled, never read, left while queued, hung up). - Unbounded wait on
mediaGate: met. WithMEDIA_QUEUE_MAXalready waiting, the next request gets 503 +Retry-After: 5. Files of 1 MB or less never queue. The test fills the queue and checks both. - 304 before any byte is read: met.
holds()runs on the stat's blob id before the pooled read and before the sniff. It is tested with every permit held. - Raw/Download are not views: met.
segholds only Rendered/Source, and only where a renderer exists. Raw stays an outline smbtn, and Download is abtnwith thedownloadicon. The test checks neither is inside.seg.
Preview: the route is there and a missing repository answers the same 404 as /raw. This preview has no public repository, so the byte paths are proven by test/media.test.ts against a real forge, not by a look.
Non-blocking: a waiter whose signal aborts keeps its queue slot until a permit frees, because Gate cannot drop a waiter. It spends nothing and cannot hold a permit, but under a burst of abandoned requests it counts toward MEDIA_QUEUE_MAX. If that ever shows up as 503s, a removable waiter in Gate is the fix.
This adds
GET /:owner/:name/media/:ref/*path, which serves a file's bytes under a real media type, and one registry of file kinds (src/web/render/kinds.ts) that the blob page now asks how to draw a file. Every renderer in #7329 builds on these two. Type check and design lint pass. The new tests pass, and so do the existing blob, security-floor and cross-file suites. The full gate has not run yet; it runs on this push.What moved where
src/web/render/kinds.ts(new):fileKind(path, firstBytes)returns one of the 11 kinds, andmediaType(path, firstBytes)gives the type the route sends.- The extension proposes a type from the allowlist and the file's first bytes must confirm it. A mismatch, or any file not on the list, goes out as
application/octet-stream. HTML and JavaScript are never served. - STL has no signature, so its extension alone decides. -RENDER_CAPis the largest file each kind draws inline. -RENDERERSmaps a kind to its renderer. Onlymarkdownhas one so far.src/web/render/markdown.ts(new): Markdown's prose view, moved out ofrepo.tsas the first renderer and the pattern later ones follow.src/http/range.ts(new): reads a singlebytes=range, a suffix range, or an unsatisfiable one as plain data. A list of ranges is answered with the whole file, which the RFC allows.src/web/pages/media.ts(new): the route.- Headers:
nosniffand the sandbox CSP from the issue, so an SVG with a script is drawn as a picture and never runs. - Ranges: 206 withContent-Range, 416 past the end, andIf-Rangeis honoured. - Caching: theETagis the blob id and a matchingIf-None-Matchgets 304. A full commit sha caches for a year as immutable; a branch revalidates every time. - Access is the same as/raw. -?download=1sends the file as an attachment under its own name, at any size.-
blobStreaminsrc/git/read.tsstreams files over 1 MB straight offgit cat-file, never holding them whole. - Streams draw on their ownmediaGateinsrc/git/env.ts(FORGE_MEDIA_CONCURRENCY, default 16), so long video streams can't use up the git permits every page needs. - A reader that hangs up kills the git process and gives its permit back.renderBlobinrepo.tsnow asks the registry. A kind with no renderer keeps the view it had. Every file's header hasRendered | Source | Raw | Download, with Rendered only where a renderer exists. The Download button, including the binary and too-large cases, goes through the media route.docs/features/file-media.md, and the CLAUDE.md map namesrender/.What to look at
Content-Length. Bun ignores a length set on a large stream. A 206 still names the full size inContent-Range, which is what a seeking player reads. Buffered bodies (1 MB or less) do carry a length.Tests
test/render-kinds.test.ts: every extension maps to its kind and type, mismatched bytes fall back, nothing comes back as HTML or script, and the range parser cases.test/media.test.tsboots a real forge and covers:- types, and the octet-stream fallback; - the SVG served under the sandbox CSP; - single, suffix and 416 ranges on both a buffered file and a 1.5 MB streamed one; - the permit being returned when a reader hangs up; - the 304 on the ETag, and immutable vs revalidated caching; - downloads; - a private repo returning 404 signed out but 200 to its owner; - the blob page's switch.
To try it: open any file on the preview and use Download, or open
/<owner>/<repo>/media/main/<file>.pngdirectly.🤖 Generated with Claude Code
Closes #7330