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

An .html file in a repository previews as a page in a sandboxed frame, with Source one tap away #10927

Merged opened this

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

Merged

scout/forge:scout/10897-an-html-file-in-a-repository-previews-as → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/10897-an-html-file-in-a-repository-previews-as was merged into main as a4b767b.

Review

Sign in to review this change.
15 files changed +287 −8
docs/design/areas/code.md +2
@@ -12,3 +12,5 @@
1212 - `.moved-from` (#10109) — the one muted line "Moved from <old path> on <date>" under a repository head for 30 days after a move; the path wraps anywhere, so no sideways scroll at 390px.
1313
1414 A font specimen (`render/font.ts`): `.font-view` sits in a bare card (`card({ extra: "bare" })`, no box of its own); `.font-name` its family heading, `.font-line` one line of sample text, and `.font-s`, `.font-m`, `.font-l` its three sizes. The small size is 14px; the larger two are display sizes, off the UI scale on purpose.
15
16 An HTML file's preview (`render/html_preview.ts`): `.html-frame` is a bare sandboxed frame with its own hairline (no card around it) 80vh tall (480px at least, 60vh on a phone). The frame is `color-scheme: light`, so the page inside gets the white canvas it was written for in either theme.
docs/features/html-preview.md added +9
@@ -0,0 +1,9 @@
1 # HTML preview
2
3 Open an `.html` or `.htm` file (up to 2 MB, no NUL bytes) in a repository and the blob page shows it as the page it is, in a frame (`src/web/render/html_preview.ts`). **Rendered** is the default; **Source** (`?view=source`) shows the markup, escaped, as for any text file; Raw and Download hand back the bytes untouched.
4
5 How it is held: the frame points at the media route, which serves the file as `text/html` only under `htmlPolicy` (`src/web/pages/media.ts`): `sandbox` with no `allow-*` token (opaque origin, no script, form or popup), `default-src 'none'`, and images and stylesheets only from this repository's files at the same ref. A relative `../../` reference that climbs out of the repository resolves to an address outside that scope and the browser does not fetch it. The frame element carries `sandbox=""` too, and only the blob page gets `frame-src 'self'`. The file's bytes never enter the blob page's markup. A download is always `application/octet-stream`.
6
7 Layout: the frame is 80vh tall (60vh and 360px at least on a phone, 390px wide included), with one hairline and no card around it. It is `color-scheme: light`, so its canvas is white and the page inside looks the same in either theme. Fixed height, so nothing shifts.
8
9 Terminal (SSH): the file opens as its source, like any text file; the rendered page is a web-only door, because a terminal cannot draw a page. `test/tui-workspace.test.ts` holds that.
src/tui/screens/workspace.ts +1 −1
@@ -911,7 +911,7 @@ export function workspaceScreen(
911911 note =
912912 archiveNote(t.path, read.value.body, read.value.size, formatSize) ??
913913 `Binary file, ${formatSize(read.value.size)}.`;
914 } else raw = read.value.body;
914 } else raw = read.value.body; // .html included: source here, the page preview is web-only
915915 }
916916 entry = { note, noteTitle, noteDetail, raw, picture };
917917 remember(key, entry);
src/web/layout.ts +7 −1
@@ -169,6 +169,11 @@ export interface PageOptions {
169169 * blob page of a PDF asks; no other page's policy changes.
170170 */
171171 objectSrc?: boolean;
172 /**
173 * The page frames an HTML file from the media route in a sandboxed
174 * `<iframe>` (`frame-src 'self'`). Only the blob page of an HTML file asks.
175 */
176 frameSelf?: boolean;
172177 /**
173178 * The page sets a font file in the font itself (`render/font.ts`): the
174179 * browser may load a font from this origin (`font-src 'self'`). Only the
@@ -939,8 +944,9 @@ const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com";
939944 const BARE_ORIGIN = /^https?:\/\/[a-z0-9.-]+(?::\d{1,5})?$/i;
940945
941946 /** The `frame-src` directive (with its trailing space), or nothing when the page frames nothing. */
942 export function frameSources(options: Pick<PageOptions, "turnstile" | "frameSrc">): string {
947 export function frameSources(options: Pick<PageOptions, "turnstile" | "frameSrc" | "frameSelf">): string {
943948 const origins = [
949 ...(options.frameSelf ? ["'self'"] : []),
944950 ...(options.turnstile ? [TURNSTILE_ORIGIN] : []),
945951 ...(options.frameSrc !== undefined && BARE_ORIGIN.test(options.frameSrc) ? [options.frameSrc] : []),
946952 ];
src/web/pages/media.ts +20
@@ -23,6 +23,7 @@ import type { Ctx } from "../../http/context.ts";
2323 import { holds } from "../../http/etag.ts";
2424 import { parseRange } from "../../http/range.ts";
2525 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
26 import { publicOrigin } from "../../core/public_origin.ts";
2627 import { loadRepo, notFoundPage } from "../chrome.ts";
2728 import { cachedMedia, keepMedia } from "./tree_cache.ts";
2829 import { HEAD_BYTES, mediaType } from "../render/kinds.ts";
@@ -41,6 +42,19 @@ export const MEDIA_POLICY =
4142 export const PDF_POLICY =
4243 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; frame-ancestors 'self'";
4344
45 /**
46 * An HTML file is served as a page only under this: `sandbox` with no
47 * `allow-*` token gives it an opaque origin and no script, form or popup;
48 * `default-src 'none'` blocks everything not listed. Images and stylesheets
49 * load only from `scope`, this repository's files at this ref, so a relative
50 * `../../` reference that climbs out of the repository (to another repository
51 * or any page of the forge) is refused by the browser, not fetched. Inline
52 * styles load. `frame-ancestors 'self'` lets only the forge's own blob page
53 * frame it.
54 */
55 export const htmlPolicy = (scope: string): string =>
56 `sandbox; default-src 'none'; img-src ${scope} data:; style-src 'unsafe-inline' ${scope}; frame-ancestors 'self'`;
57
4458 /** At or under this, the pooled reader hands the body back without a spawn. */
4559 const SMALL_BYTES = 1_000_000;
4660
@@ -148,6 +162,12 @@ async function mediaRead(ctx: Ctx, clock: Clock): Promise<Response> {
148162 const type = download ? "application/octet-stream" : mediaType(path, head);
149163 headers["content-type"] = type;
150164 if (type === "application/pdf") headers["content-security-policy"] = PDF_POLICY;
165 if (type.startsWith("text/html")) {
166 const origin = publicOrigin(ctx.app.config.url, ctx.url.origin);
167 headers["content-security-policy"] = htmlPolicy(
168 `${origin}${mediaHref(`/${repo.ownerName}/${repo.name}`, ref, "")}`,
169 );
170 }
151171
152172 // A Range past an If-Range that no longer holds gets the whole new file.
153173 const ifRange = ctx.request.headers.get("if-range");
src/web/pages/repo.ts +16
@@ -1113,6 +1113,15 @@ export function drawsPdf(scope: RepoScope, path: string, blob: Blob, view: BlobV
11131113 );
11141114 }
11151115
1116 /** Whether the blob page of this file frames an HTML preview, and so needs `frame-src 'self'`. */
1117 export function drawsHtml(scope: RepoScope, path: string, blob: Blob, view: BlobView): boolean {
1118 return (
1119 view !== "source" &&
1120 RENDERERS.html !== undefined &&
1121 blobRenderer(scope, path, blob)?.render === RENDERERS.html
1122 );
1123 }
1124
11161125 /** Whether the blob page of this file sets a font in itself, and so needs `font-src`. */
11171126 export function drawsFont(scope: RepoScope, path: string, blob: Blob, view: BlobView): boolean {
11181127 return (
@@ -1514,6 +1523,8 @@ export function codePage(
15141523 before?: Html;
15151524 /** The page draws a PDF in an `<object>` (`PageOptions.objectSrc`). */
15161525 objectSrc?: boolean;
1526 /** The page frames an HTML preview (`PageOptions.frameSelf`). */
1527 frameSelf?: boolean;
15171528 /** The page sets a font in itself (`PageOptions.fontSrc`). */
15181529 fontSrc?: boolean;
15191530 /** Script only some files need (the CSV table's sort and filter). */
@@ -1524,6 +1535,7 @@ export function codePage(
15241535 ctx,
15251536 title: o.title,
15261537 ...(o.objectSrc ? { objectSrc: true } : {}),
1538 ...(o.frameSelf ? { frameSelf: true } : {}),
15271539 ...(o.fontSrc ? { fontSrc: true } : {}),
15281540 crumbs: scope.crumbs,
15291541 palette: scope.palette,
@@ -1687,6 +1699,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
16871699 explorer: hit.explorer,
16881700 content: hit.content,
16891701 objectSrc: hit.objectSrc,
1702 frameSelf: hit.frame,
16901703 ...(hit.font ? { fontSrc: true, extraJs: fontJs } : hit.csv ? { extraJs: csvJs } : {}),
16911704 })),
16921705 );
@@ -1745,6 +1758,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
17451758 ),
17461759 });
17471760 const objectSrc = drawsPdf(scope, path, shown, view);
1761 const frame = drawsHtml(scope, path, shown, view);
17481762 const font = drawsFont(scope, path, shown, view);
17491763 if (kept && keepable)
17501764 keepBlobPage(repo.id, ref, path, `${view}\0${table ?? ""}`, all, {
@@ -1752,6 +1766,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
17521766 explorer: explorerHtml,
17531767 content: pane,
17541768 objectSrc,
1769 frame,
17551770 font,
17561771 csv: isCsvPath(path),
17571772 });
@@ -1761,6 +1776,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
17611776 explorer: explorerHtml,
17621777 content: pane,
17631778 objectSrc,
1779 frameSelf: frame,
17641780 ...(font ? { fontSrc: true, extraJs: fontJs } : isCsvPath(path) ? { extraJs: csvJs } : {}),
17651781 }));
17661782 notePhase(ctx.request, "render", paneMs + performance.now() - pageStart);
src/web/pages/tree_cache.ts +1
@@ -113,6 +113,7 @@ export const keptPartsStats = () => parts.stats();
113113 export interface RenderedBlob extends RenderedTree {
114114 readonly title: string;
115115 readonly objectSrc: boolean;
116 readonly frame: boolean;
116117 readonly csv: boolean;
117118 readonly font: boolean;
118119 }
src/web/render/html_preview.ts added +23
@@ -0,0 +1,23 @@
1 /**
2 * An HTML file drawn as the page it is, in a sandboxed frame. The frame's
3 * `src` is the media route, which serves the bytes as `text/html` only under
4 * a `sandbox` policy (`pages/media.ts` `htmlPolicy`), so the file's own
5 * script never runs, on the forge's origin or in the frame. The `sandbox`
6 * attribute carries no `allow-*` token either: two locks, not one. Relative
7 * images and stylesheets resolve against the media address, beside the file;
8 * the route's policy lets only files under the same ref load (`htmlPolicy`).
9 * The file's bytes never enter this page's markup. The page that holds the
10 * frame sends `frame-src 'self'` (`layout.ts` `frameSelf`).
11 */
12
13 import { html } from "../html.ts";
14 import { fileSize } from "../ui.ts";
15 import type { Rendered, RenderInput } from "./renderers.ts";
16
17 export function htmlPreview(input: RenderInput): Rendered {
18 return {
19 lines: 0,
20 summary: `${fileSize(input.size)} · HTML`,
outdated

Principle 6 (few borders, never a box around a thing): the frame is wrapped in a card just to clip overflow. Use the frame's own hairline, or a bare card as font.ts does (card({ extra: "bare" })), and drop the .html-view wrapper if the frame alone suffices.

21 content: html`<iframe class="html-frame" sandbox="" src="${input.media}" title="Preview of ${input.path}" referrerpolicy="no-referrer" loading="lazy"></iframe>`,
22 };
23 }
src/web/render/kinds.ts +9 −1
@@ -7,7 +7,8 @@
77 * gets one only from the allowlist below, decided by extension and confirmed
88 * by the file's own first bytes where its format has a signature; anything
99 * 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
10 * `application/octet-stream`. HTML is the one page type, and only under the
11 * sandbox policy in `pages/media.ts`; never script on this origin. `renderBlob` asks
1112 * `renderers.ts` what draws a kind, and a kind with nothing there keeps the
1213 * source view it always had.
1314 */
@@ -16,6 +17,7 @@ export type FileKind =
1617 | "image"
1718 | "svg"
1819 | "pdf"
20 | "html"
1921 | "audio"
2022 | "video"
2123 | "csv"
@@ -47,6 +49,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
4749 image: 50 * MB,
4850 svg: 5 * MB,
4951 pdf: 100 * MB,
52 html: 2 * MB,
5053 audio: 500 * MB,
5154 video: 4_000 * MB,
5255 csv: 10 * MB,
@@ -104,6 +107,9 @@ const adts: Magic = (head) => head[0] === 0xff && ((head[1] ?? 0) & 0xf6) === 0x
104107 const svgRoot: Magic = (head) =>
105108 !head.includes(0) && /<svg[\s>]/i.test(new TextDecoder().decode(head));
106109
110 /** HTML is text: a NUL in the head means it is something else. */
111 const textHead: Magic = (head) => !head.includes(0);
112
107113 interface Media {
108114 readonly kind: FileKind;
109115 /** The format as a person says it, for a header: `WebM`, `QuickTime`. */
@@ -124,6 +130,8 @@ const MEDIA: Readonly<Record<string, Media>> = {
124130 bmp: { kind: "image", name: "BMP", type: "image/bmp", magic: ascii("BM") },
125131 ico: { kind: "image", name: "ICO", type: "image/x-icon", magic: bytes(0x00, 0x00, 0x01, 0x00) },
126132 svg: { kind: "svg", name: "SVG", type: "image/svg+xml", magic: svgRoot },
133 html: { kind: "html", name: "HTML", type: "text/html; charset=utf-8", magic: textHead },
134 htm: { kind: "html", name: "HTML", type: "text/html; charset=utf-8", magic: textHead },
127135 pdf: { kind: "pdf", name: "PDF", type: "application/pdf", magic: ascii("%PDF-") },
128136 mp3: { kind: "audio", name: "MP3", type: "audio/mpeg", magic: mpegAudio },
129137 ogg: { kind: "audio", name: "Ogg", type: "audio/ogg", magic: ascii("OggS") },
src/web/render/renderers.ts +3
@@ -16,6 +16,7 @@ import { gpx } from "./gpx.ts";
1616 import { font } from "./font.ts";
1717 import { ics } from "./ics.ts";
1818 import { vcf } from "./vcf.ts";
19 import { htmlPreview } from "./html_preview.ts";
1920 import { image } from "./image.ts";
2021 import { fileKind, HEAD_BYTES, kindByName, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts";
2122 import { log, hasEscape } from "./log.ts";
@@ -67,6 +68,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
6768 markdown,
6869 notebook,
6970 pdf,
71 html: htmlPreview,
7072 csv,
7173 sqlite,
7274 audio,
@@ -95,6 +97,7 @@ const MEDIA_KINDS: ReadonlySet<FileKind> = new Set<FileKind>([
9597 "image",
9698 "svg",
9799 "pdf",
100 "html",
98101 "audio",
99102 "video",
100103 "model3d",
src/web/style/content.ts +3
@@ -474,6 +474,9 @@ form.inline { display: inline; margin-left: auto; }
474474 .media-pane .media-player { display: block; width: 100%; max-width: 100%; }
475475 .media-pane video.media-player { height: auto; max-height: 80vh; background: var(--muted); border-radius: var(--radius-sm); }
476476
477 /* An HTML file's page in a sandboxed frame: one hairline, a fixed height, so nothing shifts. */
478 .html-frame { display: block; width: 100%; height: 80vh; min-height: 480px; border: 1px solid var(--border); border-radius: var(--radius-sm); color-scheme: light; }
outdated

The existing comment "A PDF in the browser's own viewer" now sits above the HTML rules and the PDF rules follow it. Move the new block above that comment and give it its own one-line comment, so each block is described once (principle 1).

479 @media (max-width: 600px) { .html-frame { height: 60vh; min-height: 360px; } }
477480 /* A PDF in the browser's own viewer: a fixed frame, so nothing shifts. */
478481 .pdf-view { overflow: hidden; }
479482 .pdf-frame { display: block; width: 100%; height: 80vh; min-height: 480px; border: 0; }
test/html_preview.test.ts added +166
@@ -0,0 +1,166 @@
1 /**
2 * An .html file previews as a page in a sandboxed frame: the blob page holds
3 * the frame and the switch and none of the file's markup; the media route
4 * serves it as text/html only under the sandbox policy.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { htmlPolicy } from "../src/web/pages/media.ts";
15 import { fileKind, kindByName, mediaType, RENDER_CAP } from "../src/web/render/kinds.ts";
16 import { seedAdmin } from "./helpers/admin.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18
19 quietLogs();
20
21 const PAGE = '<!doctype html><title>t</title><link rel="stylesheet" href="site.css"><img src="pic.png"><script>alert("xss-marker")</script>\n';
22 const BIG = `<p>${"x".repeat(2_040_000)}</p>`;
23
24 let dataDir = "";
25 let app: App;
26 let servers: Servers;
27 let base = "";
28
29 const get = (path: string): Promise<Response> => fetch(`${base}${path}`, { redirect: "manual" });
30 const flat = (s: string): string => s.replace(/\s+/g, " ");
31
32 beforeAll(async () => {
33 dataDir = await mkdtemp(join(tmpdir(), "forge-html-"));
34 app = createApp({ dataDir, port: 0 });
35 servers = await serve(app);
36 base = servers.url;
37 const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-html-passphrase" });
38 if (!a.ok) throw new Error("signup failed");
39 const repo = await createRepo(app, a.value, { name: "site", visibility: "public" });
40 if (!repo.ok) throw new Error("repo failed");
41 const made = await commitToBranch(app, a.value, repo.value, {
42 message: "site",
43 files: [
44 { path: "web/index.html", content: PAGE },
45 { path: "old.HTM", content: "<p>hi</p>" },
46 { path: "big.html", content: BIG },
47 { path: "bin.html", content: new Uint8Array([60, 0, 1, 2]) },
48 ],
49 });
50 if (!made.ok) throw new Error("commit failed");
51 });
52
53 afterAll(async () => {
54 await servers.stop();
55 await rm(dataDir, { recursive: true, force: true });
56 });
57
58 describe("the registry", () => {
59 test("html and htm are a kind with a 2 MB cap; NUL bytes are not html", () => {
60 const head = new TextEncoder().encode("<p>");
61 expect(fileKind("a.html", head)).toBe("html");
62 expect(fileKind("a.HTM", head)).toBe("html");
63 expect(kindByName("x/y.html")).toBe("html");
64 expect(RENDER_CAP.html).toBe(2_000_000);
65 expect(fileKind("a.html", new Uint8Array([60, 0]))).toBe("binary");
66 expect(mediaType("a.html", new Uint8Array([60, 0]))).toBe("application/octet-stream");
67 });
68 });
69
70 describe("the media route for an html file", () => {
71 test("text/html under the sandbox policy, nosniff, same-origin framing", async () => {
72 const res = await get("/alice/site/media/main/web/index.html");
73 expect(res.status).toBe(200);
74 expect(res.headers.get("content-type")).toStartWith("text/html");
75 const scope = `${base}/alice/site/media/main/`;
76 const policy = res.headers.get("content-security-policy") ?? "";
77 expect(policy).toBe(htmlPolicy(scope));
78 expect(policy).toStartWith("sandbox; default-src 'none';");
79 expect(policy).toContain(`img-src ${scope} data:`);
80 expect(policy).toContain(`style-src 'unsafe-inline' ${scope};`);
81 expect(policy).not.toContain("script-src");
82 expect(policy).not.toContain("allow-");
83 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
84 expect(res.headers.get("x-frame-options")).toBe("SAMEORIGIN");
85 expect((await get("/alice/site/media/main/old.HTM")).headers.get("content-security-policy")).toBe(htmlPolicy(scope));
86 });
87
88 test("a relative path that climbs out of the repository is outside what the policy loads", async () => {
89 const scope = `${base}/alice/site/media/main/`;
90 const policy = (await get("/alice/site/media/main/web/index.html")).headers.get("content-security-policy") ?? "";
91 const frame = new URL("/alice/site/media/main/web/index.html", base);
92 const loads = (ref: string): boolean => new URL(ref, frame).href.startsWith(scope);
93 expect(policy).toContain(scope);
94 // Beside the file and above it, inside the repository: loads.
95 expect(loads("pic.png")).toBe(true);
96 expect(loads("../site.css")).toBe(true);
97 // Out of the repository, to another one or to the forge's own pages: refused.
98 expect(loads("../../../bob/site/media/main/pic.png")).toBe(false);
99 expect(loads("../../../../account/style.css")).toBe(false);
100 expect(loads("../../../../../x.png")).toBe(false);
101 // And the policy never falls back to the whole origin.
102 expect(policy).not.toContain("'self' data:");
103 expect(policy).not.toMatch(/(?:img|style)-src[^;]*'self'/);
104 });
105
106 test("a download, or bytes that are not text, is never html", async () => {
107 const dl = await get("/alice/site/media/main/web/index.html?download=1");
108 expect(dl.headers.get("content-type")).toBe("application/octet-stream");
109 const bin = await get("/alice/site/media/main/bin.html");
110 expect(bin.headers.get("content-type")).toBe("application/octet-stream");
111 });
112
113 test("a path that walks out of the repository is refused", async () => {
114 expect((await get("/alice/site/media/main/web/%2e%2e/%2e%2e/x.html")).status).toBe(404);
115 expect((await get("/alice/site/media/main/..%2f..%2fx.html")).status).toBe(404);
116 });
117 });
118
119 describe("the blob page of an html file", () => {
120 test("a sandboxed frame onto the media route, the switch, and none of the file's markup", async () => {
121 const res = await get("/alice/site/blob/main/web/index.html");
122 const text = await res.text();
123 const page = flat(text);
124 expect(page).toContain('<iframe class="html-frame" sandbox="" src="/alice/site/media/main/web/index.html"');
125 expect(page).not.toMatch(/sandbox="[^"]*allow-/);
126 expect(page).toContain('aria-current="page" >Rendered');
127 expect(page).toContain('href="/alice/site/blob/main/web/index.html?view=source"');
128 expect(page).toContain('href="/alice/site/raw/main/web/index.html"');
129 expect(page).toContain("/alice/site/media/main/web/index.html?download=1");
130 expect(res.headers.get("content-security-policy")).toContain("frame-src 'self'");
131 // The script is in neither view as live markup.
132 expect(text).not.toContain("<script>alert");
133 const source = await (await get("/alice/site/blob/main/web/index.html?view=source")).text();
134 expect(source).not.toContain("<iframe");
135 expect(source).not.toContain("<script>alert");
136 expect(source).toContain("&lt;");
137 });
138
139 test("only that page gets frame-src", async () => {
140 const readme = await get("/alice/site");
141 expect(readme.headers.get("content-security-policy")).not.toContain("frame-src");
142 const src = await get("/alice/site/blob/main/web/index.html?view=source");
143 expect(src.headers.get("content-security-policy")).not.toContain("frame-src");
144 });
145
146 test("over the cap there is no frame and no Rendered", async () => {
147 const res = await get("/alice/site/blob/main/big.html");
148 const page = flat(await res.text());
149 expect(page).not.toContain("<iframe");
150 expect(page).not.toContain(">Rendered");
151 expect(page).toContain("download=1");
152 });
153 });
154
155 describe("the frame's layout", () => {
156 test("a bare frame with its own hairline, a fixed height, a phone rule; no card around it", async () => {
157 const page = flat(await (await get("/alice/site/blob/main/web/index.html")).text());
158 expect(page).not.toContain("html-view");
159 const css = await (await get("/alice/site")).text();
160 const sheet = /href="([^"]*\.css[^"]*)"/.exec(css)?.[1];
161 const style = sheet ? await (await get(sheet)).text() : css;
162 const flatStyle = flat(style);
163 expect(flatStyle).toMatch(/\.html-frame \{[^}]*height: 80vh[^}]*border: 1px solid var\(--border\)[^}]*color-scheme: light/);
164 expect(flatStyle).toMatch(/max-width: 600px\) \{ \.html-frame \{ height: 60vh/);
165 });
166 });
test/media.test.ts +2 −2
@@ -65,7 +65,7 @@ beforeAll(async () => {
6565 { path: "art/evil.svg", content: EVIL_SVG },
6666 { path: "doc.pdf", content: PDF },
6767 { path: "fake.pdf", content: "<script>alert(1)</script>" },
68 { path: "art/page.html", content: "<script>alert(1)</script>" },
68 { path: "art/page.txt", content: "<script>alert(1)</script>" },
6969 { path: "clip.mp4", content: CLIP },
7070 { path: "README.md", content: "# gallery\n" },
7171 { path: "src/a.ts", content: "export const a = 1\n" },
@@ -119,7 +119,7 @@ describe("the media route", () => {
119119 const fake = await get("/alice/gallery/media/main/art/fake.png");
120120 expect(fake.status).toBe(200);
121121 expect(fake.headers.get("content-type")).toBe("application/octet-stream");
122 const page = await get("/alice/gallery/media/main/art/page.html");
122 const page = await get("/alice/gallery/media/main/art/page.txt");
123123 expect(page.headers.get("content-type")).toBe("application/octet-stream");
124124 });
125125
test/render-kinds.test.ts +7 −3
@@ -24,6 +24,8 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = {
2424 ico: [of(0, 0, 1, 0, 1), "image", "image/x-icon"],
2525 svg: [enc('<?xml version="1.0"?>\n<svg xmlns="http://www.w3.org/2000/svg"/>'), "svg", "image/svg+xml"],
2626 pdf: [enc("%PDF-1.7\n"), "pdf", "application/pdf"],
27 html: [enc("<!doctype html><p>"), "html", "text/html; charset=utf-8"],
28 htm: [enc("<p>"), "html", "text/html; charset=utf-8"],
2729 mp3: [enc("ID3\x04\0"), "audio", "audio/mpeg"],
2830 ogg: [enc("OggS\0"), "audio", "audio/ogg"],
2931 oga: [enc("OggS\0"), "audio", "audio/ogg"],
@@ -104,14 +106,16 @@ describe("the registry of file kinds", () => {
104106 );
105107 });
106108
107 test("nothing it can answer is HTML or script", () => {
108 for (const ext of [...KNOWN_EXTENSIONS, "html", "htm", "js", "mjs", "xhtml", "xml"]) {
109 test("nothing it can answer is script, and HTML only as .html or .htm (sandboxed by the media route)", () => {
110 for (const ext of [...KNOWN_EXTENSIONS, "js", "mjs", "xhtml", "xml"]) {
111 if (ext === "html" || ext === "htm") continue;
109112 const head = SAMPLES[ext]?.[0] ?? enc("<script>alert(1)</script>");
110113 const type = mediaType(`a.${ext}`, head);
111114 expect(type).not.toMatch(/html|javascript|ecmascript|xml(?!$)/);
112115 expect(type).not.toBe("text/html");
113116 }
114 expect(mediaType("index.html", enc("<html>"))).toBe("application/octet-stream");
117 expect(mediaType("index.html", enc("<html>"))).toBe("text/html; charset=utf-8");
118 expect(mediaType("index.html", new Uint8Array([60, 0]))).toBe("application/octet-stream");
115119 });
116120
117121 test("a file with no known extension is text or binary by its bytes", () => {
test/tui-workspace.test.ts +18
@@ -309,6 +309,24 @@ describe("the terminal workspace", () => {
309309 }
310310 });
311311
312 test("a .html file opens as its source: the page preview is a web-only door", async () => {
313 const made = await createRepo(forge, alice, { name: "pages", visibility: "public", description: "pages" });
314 if (!made.ok) throw new Error("repo failed");
315 await commitToBranch(forge, alice, made.value, {
316 message: "seed",
317 files: [{ path: "index.html", content: "<h1>hello page</h1>\n" }],
318 });
319 const h = boot();
320 try {
321 await h.settle();
322 await open(h, "pages");
323 await openFile(h, "index.html");
324 expect(h.text()).toContain("<h1>hello page</h1>");
325 } finally {
326 h.stop();
327 }
328 });
329
312330 test("a .patch opens as the diff a pull shows, v flips to the source, a non-diff stays text", async () => {
313331 const made = await createRepo(forge, alice, {
314332 name: "patches",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs