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.
mason/forge:mason/7330-files-render-in-the-browser-part-1-a-saf → main 8 commits fork 4 threads · 4 unresolved
CLAUDE.md +2 −1
| @@ -180,7 +180,8 @@ src/ | ||
| 180 | 180 | header, settings shell, the one 404), flash, layout (CSP+nonce, |
| 181 | 181 | topbar, theme switcher), client (progressive enhancement, as a |
| 182 | 182 | 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 | |
| 184 | 185 | pages/{site,repo,issues,pulls,pull,thread,commits,refs,search, |
| 185 | 186 | org,account,settings}, and studio/ — the app-as-the-unit |
| 186 | 187 | 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 | ||
| 359 | 359 | - [](docs/FEED.md) — the feed: what happened, ranked, each item with why it is there. The home composer keeps your draft across a reload. |
| 360 | 360 | - [](docs/PULLS.md) — resolving a conflicted pull without a clone. |
| 361 | 361 | - [](docs/GIT.md) — cloning over HTTPS and SSH, and partial clones of big repositories. |
| 362 | - [](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. | |
| 362 | 363 | - [](docs/ROLES.md) — guest, member and admin, and what each may do. |
| 363 | 364 | - [](docs/MCP.md) — the forge as a connector; [](docs/STANDARDS.md) is what it answers by default: `.well-known`, feeds, cards, the manifest. |
| 364 | 365 |
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 => { | ||
| 195 | 195 | */ |
| 196 | 196 | export const gitGate = new Gate(limitFromEnv("FORGE_GIT_CONCURRENCY", 8)); |
| 197 | 197 | |
| 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 | ||
| 198 | 205 | /** |
| 199 | 206 | * How many pack transfers a host of `bytes` memory runs at once by default: |
| 200 | 207 | * 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 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { defect } from "../kernel/error.ts"; |
| 9 | 9 | 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"; | |
| 12 | 12 | |
| 13 | 13 | export type ObjectType = "blob" | "tree" | "commit" | "tag"; |
| 14 | 14 | |
| @@ -603,3 +603,95 @@ export async function refTip(dir: string, ref: string): Promise<string | undefin | ||
| 603 | 603 | } |
| 604 | 604 | return undefined; |
| 605 | 605 | } |
| 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, | |
| 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 { | ||
| 205 | 205 | repoSyncSubmit, |
| 206 | 206 | treePage, |
| 207 | 207 | } from "../web/pages/repo.ts"; |
| 208 | import { mediaBlob } from "../web/pages/media.ts"; | |
| 208 | 209 | import { |
| 209 | 210 | branchCreate, |
| 210 | 211 | branchDelete, |
| @@ -1324,6 +1325,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1324 | 1325 | router.add("GET", "/:owner/:name/tree/:ref/*path", treePage); |
| 1325 | 1326 | router.add("GET", "/:owner/:name/blob/:ref/*path", blobPage); |
| 1326 | 1327 | router.add("GET", "/:owner/:name/raw/:ref/*path", rawBlob); |
| 1328 | router.add("GET", "/:owner/:name/media/:ref/*path", mediaBlob); | |
| 1327 | 1329 | // Every file path at a ref, for the fuzzy finder. JSON, `canRead`-gated. |
| 1328 | 1330 | router.add("GET", "/:owner/:name/paths/:ref", pathsJson); |
| 1329 | 1331 |
src/tui/screens/workspace.ts +2 −1
| @@ -45,6 +45,7 @@ import { stopMirroring } from "../../actions/mirror_target.ts"; | ||
| 45 | 45 | import { getMirrorTarget, mirrorLine } from "../../core/migrate_mirror.ts"; |
| 46 | 46 | import { canAdmin, DECISIONS_BY, getRepoById, type Repo } from "../../core/repos.ts"; |
| 47 | 47 | import type { TreeEntry } from "../../git/read.ts"; |
| 48 | import { fileKind, NO_BYTES } from "../../web/render/kinds.ts"; | |
| 48 | 49 | import type { RepoFacts, RepoSource } from "../../view/source.ts"; |
| 49 | 50 | import { detectLanguage, highlightLines } from "../../web/highlight.ts"; |
| 50 | 51 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -257,7 +258,7 @@ type TabKind = "image" | "markdown" | "code"; | ||
| 257 | 258 | /** `README.md`, `NOTES.markdown`, `readme` — the prose a repo opens on. */ |
| 258 | 259 | function isMarkdownPath(path: string): boolean { |
| 259 | 260 | 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); | |
| 261 | 262 | } |
| 262 | 263 | |
| 263 | 264 | class Tab { |
src/web/pages/edit.ts +3 −1
| @@ -17,6 +17,7 @@ | ||
| 17 | 17 | * preview are `editorJs`, and none of them is load-bearing. |
| 18 | 18 | */ |
| 19 | 19 | |
| 20 | import { fileKind, NO_BYTES } from "../render/kinds.ts"; | |
| 20 | 21 | import { editFiles, MAX_FILE_BYTES } from "../../actions/contents.ts"; |
| 21 | 22 | import { contribute } from "../../actions/contribute.ts"; |
| 22 | 23 | import { NO_WRITE_ACCESS } from "../../actions/pull_words.ts"; |
| @@ -555,8 +556,9 @@ function mirrorHtml(content: string, lang: string): string { | ||
| 555 | 556 | .join(""); |
| 556 | 557 | } |
| 557 | 558 | |
| 559 | /** A file not yet named is written as prose until its name says otherwise. */ | |
| 558 | 560 | const isMarkdownPath = (path: string): boolean => |
| 559 | path === "" || /\.(md|markdown|mdx)$/i.test(path); | |
| 561 | path === "" || fileKind(path, NO_BYTES) === "markdown"; | |
| 560 | 562 | |
| 561 | 563 | /** |
| 562 | 564 | * 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 |
||
| 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 |
||
| 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"; | ||
| 71 | 71 | import { html, when, type Html } from "../html.ts"; |
| 72 | 72 | import { flashTo, fragment, page, redirect } from "../layout.ts"; |
| 73 | 73 | 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"; | |
| 74 | 77 | import { browseBase, localScope, type RepoScope } from "../scope.ts"; |
| 75 | 78 | import { repoForkOf } from "../../core/origins.ts"; |
| 76 | 79 | import { |
| @@ -602,8 +605,9 @@ export async function readmeFor( | ||
| 602 | 605 | */ |
| 603 | 606 | export type BlobView = "source" | "rendered"; |
| 604 | 607 | |
| 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`; | |
| 607 | 611 | |
| 608 | 612 | export function blobView(value: string | null): BlobView { |
| 609 | 613 | return value === "source" ? "source" : "rendered"; |
| @@ -627,7 +631,7 @@ export function blobBody( | ||
| 627 | 631 | title: "Binary file", |
| 628 | 632 | note: `${fileSize(blob.size)} — not shown, but you can download it.`, |
| 629 | 633 | action: btn("Download", { |
| 630 | href: `${base}/raw/${encoded}/${path}`, | |
| 634 | href: downloadHref(base, ref, path), | |
| 631 | 635 | variant: "primary", |
| 632 | 636 | icon: "download", |
| 633 | 637 | }), |
| @@ -640,10 +644,11 @@ export function blobBody( | ||
| 640 | 644 | content: empty({ |
| 641 | 645 | icon: "alert", |
| 642 | 646 | 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), | |
| 646 | 650 | variant: "primary", |
| 651 | icon: "download", | |
| 647 | 652 | }), |
| 648 | 653 | }), |
| 649 | 654 | }; |
| @@ -1017,14 +1022,28 @@ export function renderBlob( | ||
| 1017 | 1022 | ): Html { |
| 1018 | 1023 | const base = scope.base; |
| 1019 | 1024 | 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"; | |
| 1022 | 1036 | 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); | |
| 1026 | 1045 | const sizeText = |
| 1027 | o.blob.kind === "too_large" ? "" : sizeWithTokens(o.blob.size, o.path); | |
| 1046 | blob.kind === "too_large" ? "" : sizeWithTokens(blob.size, o.path); | |
| 1028 | 1047 | const fileHref = `${base}/blob/${encoded}/${o.path}`; |
| 1029 | 1048 | return html`<div class="code-pane" id="code-pane" data-path="${o.path}"> |
| 1030 | 1049 | <div class="bar mb"> |
| @@ -1044,20 +1063,20 @@ export function renderBlob( | ||
| 1044 | 1063 | >`, |
| 1045 | 1064 | )} |
| 1046 | 1065 | ${when( |
| 1047 | markdown, | |
| 1066 | renderer !== undefined, | |
| 1048 | 1067 | () => |
| 1049 | 1068 | html`<span class="blob-view" |
| 1050 | 1069 | >${seg([ |
| 1051 | { | |
| 1052 | label: "Source", | |
| 1053 | href: `${fileHref}?view=source`, | |
| 1054 | current: view === "source", | |
| 1055 | }, | |
| 1056 | 1070 | { |
| 1057 | 1071 | label: "Rendered", |
| 1058 | 1072 | href: fileHref, |
| 1059 | 1073 | current: view === "rendered", |
| 1060 | 1074 | }, |
outdated
Raw and Download are actions, not views of this page: they can never be |
||
| 1075 | { | |
| 1076 | label: "Source", | |
| 1077 | href: `${fileHref}?view=source`, | |
| 1078 | current: view === "source", | |
| 1079 | }, | |
| 1061 | 1080 | ])}</span |
| 1062 | 1081 | >`, |
| 1063 | 1082 | )} |
| @@ -1066,6 +1085,12 @@ export function renderBlob( | ||
| 1066 | 1085 | size: "sm", |
| 1067 | 1086 | variant: "outline", |
| 1068 | 1087 | })} |
| 1088 | ${btn("Download", { | |
| 1089 | href: downloadHref(base, o.ref, o.path), | |
| 1090 | size: "sm", | |
| 1091 | variant: "outline", | |
| 1092 | icon: "download", | |
| 1093 | })} | |
| 1069 | 1094 | ${btn("History", { |
| 1070 | 1095 | href: `${base}/commits?ref=${encoded}&path=${encodeURIComponent(o.path)}`, |
| 1071 | 1096 | size: "sm", |
| @@ -1078,18 +1103,6 @@ export function renderBlob( | ||
| 1078 | 1103 | </div>`; |
| 1079 | 1104 | } |
| 1080 | 1105 | |
| 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 | ||
| 1093 | 1106 | /** The bytes, with the headers that stop them ever executing as a page. */ |
| 1094 | 1107 | export function rawResponse( |
| 1095 | 1108 | 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 }> = { | ||
| 25 | 25 | "src/crew/workdir.ts": { sites: ["stderr"], reason: "stderr read to its end alongside the exit; stdout ignored" }, |
| 26 | 26 | "src/git/env.ts": { sites: ["stdout+stderr", "stdout+stderr"], reason: "stdout and stderr both read to the end, finally clears the timer" }, |
| 27 | 27 | "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" }, | |
| 29 | 29 | "src/git/smart-http.ts": { sites: ["stdout"], reason: "stdout is a pull-pipe FIFO (the response body, read on demand); stderr ignored" }, |
| 30 | 30 | "src/git/smart-ssh.ts": { sites: ["stdout+stderr"], reason: "pump() drains stdout (a pull-pipe FIFO) and stderr into the channel" }, |
| 31 | 31 | "src/kernel/data_home.ts": { sites: ["stdout"], reason: "spawnSync buffers stdout; stderr ignored" }, |
High: if the reader cancels while this
awaitis pending,cancel()→finish()runs withrelease/procundefined. This line then takes a permit and spawns agit cat-filethat is never read or killed. Set acancelledflag incancel()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.