Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
Not found is a page, not a fragment: the 404 and the 500 are served through the shell, and a favicon is not a miss #184
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/115-not-found-is-a-page-not-a-fragment-the-4 → main 1 commit fork
8 files
changed +366
−26
README.md +17 −2
| @@ -612,8 +612,8 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and | ||
| 612 | 612 | about any one game: `page({ title, description, heading, nav, env, pull, |
| 613 | 613 | commit, script, ssh, terminalLead, stageHtml, sectionsHtml, footer, |
| 614 | 614 | extraStyle })` |
| 615 | gives you the doctype, the head, the one `<style>` block, the header with its | |
| 616 | nav of the games, its environment pill and short commit, `<main>`, the | |
| 615 | gives you the doctype, the head, the one `<style>` block, the icon, the header | |
| 616 | with its nav of the games, its environment pill and short commit, `<main>`, the | |
| 617 | 617 | terminal line and the footer, |
| 618 | 618 | and `stage({ overlays, touch })` gives you `#stage` — `#scene`, `#hud`, `#fps` |
| 619 | 619 | and the `#touch` pads built from `{ action, label }` buttons. Text parameters |
| @@ -641,6 +641,21 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and | ||
| 641 | 641 | `src/client/stage.test-helper.ts`, the stub page a test boots on is built from |
| 642 | 642 | that list and `src/server.test.ts` pins the served page against the same one, |
| 643 | 643 | so the page the game is tested on and the page it is served on cannot drift. |
| 644 | - `src/pages/miss.ts` — **the two pages no game owns**, through that same | |
| 645 | shell: the 404 a wrong path lands on and the 500 a bug does. Both are whole | |
| 646 | documents, dark and phone-sized like every game page, with the header's nav, | |
| 647 | the environment pill, the commit and the footer; the 404 lists every game | |
| 648 | from the registry as a way out, each link 44px on a side, and the 500 says | |
| 649 | something went wrong and nothing else — no path, no stack, no runtime, no | |
| 650 | version, because the page is never handed the error. The error goes to the | |
| 651 | log, which is the only place it is. `script` is optional on the shell for | |
| 652 | exactly these two: a page that runs nothing gets no `<script>` element at | |
| 653 | all, and the per-game tests still pin exactly one on every game's page. | |
| 654 | `src/server.ts` renders both once at start-up, so a miss costs a lookup and | |
| 655 | a write. The icon is in the document too — one `<link rel="icon">` holding an | |
| 656 | SVG data URI of the crosshair — so **`/favicon.ico` is a request no browser | |
| 657 | makes here**: no file under `static/`, no second round trip, and the CSP | |
| 658 | already allows `img-src 'self' data:`. | |
| 644 | 659 | - `src/games.ts` is **the registry: a game is one entry there.** It holds the |
| 645 | 660 | name its bundle is filed under, the path it answers on, what the header calls |
| 646 | 661 | it, the source it is bundled from, the size that bundle may not pass and the |
src/pages/games.ts +7 −2
| @@ -41,5 +41,10 @@ export const othersHtml = (here: string): string => | ||
| 41 | 41 | .map(entryHtml) |
| 42 | 42 | .join(" "); |
| 43 | 43 | |
| 44 | /** Every game, one to a line: what a wrong path says instead of being a dead end. */ | |
| 45 | export const listHtml = (): string => `<ul>${REGISTRY.map((game) => `<li>${entryHtml(game)}</li>`).join("")}</ul>`; | |
| 44 | /** | |
| 45 | * Every game, one to a line: what a wrong path says instead of being a dead | |
| 46 | * end. The class is the handle `./miss.ts` sizes these links by — a link out of | |
| 47 | * a dead end is a target a thumb has to hit, so it is 44px there like the nav's. | |
| 48 | */ | |
| 49 | export const listHtml = (): string => | |
| 50 | `<ul class="games">${REGISTRY.map((game) => `<li>${entryHtml(game)}</li>`).join("")}</ul>`; |
src/pages/miss.test.ts added +86
| @@ -0,0 +1,86 @@ | ||
| 1 | /** | |
| 2 | * The two pages no game owns, read as a visitor gets them: a whole document | |
| 3 | * both times, the games in the header and in the list, and — on the 500 — not | |
| 4 | * one word about what broke. | |
| 5 | */ | |
| 6 | ||
| 7 | import { expect, test } from "bun:test"; | |
| 8 | import { GAMES } from "../games.ts"; | |
| 9 | import { errorPage, notFoundPage } from "./miss.ts"; | |
| 10 | import { escape } from "./shell.ts"; | |
| 11 | ||
| 12 | const options = { env: "local", pull: undefined, commit: "abc1234", ssh: undefined }; | |
| 13 | ||
| 14 | /** Every check that is true of both of them, because both go through the shell. */ | |
| 15 | const isTheDocument = (html: string): void => { | |
| 16 | expect(html.startsWith("<!doctype html>\n<html lang=\"en\">\n")).toBe(true); | |
| 17 | expect(html).toContain(`<meta charset="utf-8">`); | |
| 18 | expect(html).toContain(`content="width=device-width, initial-scale=1, viewport-fit=cover"`); | |
| 19 | expect(html).toContain(`<link rel="icon" href="data:image/svg+xml,`); | |
| 20 | expect(html).toContain("<main>"); | |
| 21 | expect(html).toContain("<footer>"); | |
| 22 | // One h1, and it is the shell's heading: the sections under it say nothing | |
| 23 | // that would be a second. | |
| 24 | expect(html.match(/<h1/g)?.length).toBe(1); | |
| 25 | // Nothing runs on either page, so neither ships a script. | |
| 26 | expect(html).not.toContain("<script"); | |
| 27 | expect(html.match(/<style/g)?.length).toBe(1); | |
| 28 | // The header: every game linked, none of them current, and the pill. | |
| 29 | for (const game of GAMES) expect(html).toContain(`<a href="${escape(game.href)}">${escape(game.label)}</a>`); | |
| 30 | expect(html).not.toContain(`aria-current="page"`); | |
| 31 | expect(html).toContain(`<span class="env ">local</span>`); | |
| 32 | expect(html).toContain(`<small class="commit">@ abc1234</small>`); | |
| 33 | expect(html.indexOf("<nav")).toBeLessThan(html.indexOf("<main>")); | |
| 34 | }; | |
| 35 | ||
| 36 | test("the 404 is the same document every game is served inside", () => { | |
| 37 | const html = notFoundPage(options); | |
| 38 | isTheDocument(html); | |
| 39 | expect(html).toContain("<h1>not found</h1>"); | |
| 40 | expect(html).toContain("<title>not found — cs</title>"); | |
| 41 | expect(html).toContain("No page answers on that path."); | |
| 42 | }); | |
| 43 | ||
| 44 | test("the 404 names every game in the registry, and what each one is", () => { | |
| 45 | const html = notFoundPage(options); | |
| 46 | expect(html).toContain(`<ul class="games">`); | |
| 47 | for (const game of GAMES) { | |
| 48 | expect(html).toContain(`<li><a href="${escape(game.href)}">${escape(game.label)}</a> — ${escape(game.blurb)}</li>`); | |
| 49 | } | |
| 50 | expect(html.match(/<li>/g)?.length).toBe(GAMES.length); | |
| 51 | // Every link out of a dead end is a target a thumb can hit, like the nav's. | |
| 52 | expect(html).toContain("ul.games a"); | |
| 53 | expect(html).toContain("min-height:44px; min-width:44px"); | |
| 54 | }); | |
| 55 | ||
| 56 | test("the 500 is a document too, and says nothing about what broke", () => { | |
| 57 | const html = errorPage(options); | |
| 58 | isTheDocument(html); | |
| 59 | expect(html).toContain("<h1>something went wrong</h1>"); | |
| 60 | expect(html).toContain("This request hit a bug on the server"); | |
| 61 | // No path, no stack, no runtime, no version: the page is not handed the | |
| 62 | // error, so the only way one of these could appear is if someone wrote it in. | |
| 63 | for (const leak of ["/src/", "/home/", "/data/", ".ts:", "Error:", "node_modules", "stack", "v1."]) | |
| 64 | expect(html).not.toContain(leak); | |
| 65 | }); | |
| 66 | ||
| 67 | test("neither page can be made to say something the environment did not", () => { | |
| 68 | // env, pull, commit and ssh are the only text these pages take, and all four | |
| 69 | // come from the process rather than the request — but they are escaped like | |
| 70 | // anything else, so a deployment named with markup is still inert. | |
| 71 | const XSS = `</style><script>alert(1)</script>`; | |
| 72 | for (const html of [ | |
| 73 | notFoundPage({ env: XSS, pull: XSS, commit: XSS, ssh: XSS }), | |
| 74 | errorPage({ env: XSS, pull: XSS, commit: XSS, ssh: XSS }), | |
| 75 | ]) { | |
| 76 | expect(html).not.toContain("<script"); | |
| 77 | expect(html).not.toContain("</style><script>"); | |
| 78 | expect(html).toContain("</style><script>alert(1)</script>"); | |
| 79 | } | |
| 80 | }); | |
| 81 | ||
| 82 | test("the terminal line is the forge's own command, or says the terminal is off", () => { | |
| 83 | expect(notFoundPage(options)).toContain("The app's terminal is off."); | |
| 84 | expect(errorPage({ ...options, ssh: "ssh -p 2222 cs@localhost" })) | |
| 85 | .toContain("<code>ssh -p 2222 cs@localhost</code>"); | |
| 86 | }); |
src/pages/miss.ts added +91
| @@ -0,0 +1,91 @@ | ||
| 1 | /** | |
| 2 | * The pages that are not a game: the 404 a wrong path lands on, and the 500 a | |
| 3 | * bug does. They are the only two documents this server sends that no game | |
| 4 | * owns, and they are here together because they are one concept — what a | |
| 5 | * visitor sees when there is nothing to play on this path. | |
| 6 | * | |
| 7 | * Both go through `./shell.ts` like every other page, so they carry the | |
| 8 | * doctype, the charset, the viewport, the style, the header with its nav of | |
| 9 | * every game, the environment pill, the commit and the footer. Neither hands | |
| 10 | * the shell a script, because neither runs anything: the shell emits no | |
| 11 | * `<script>` element for a page that leaves it out. | |
| 12 | * | |
| 13 | * The 404 is not a dead end — it lists every game from the registry through | |
| 14 | * `listHtml()`, and those links are a thumb's target, so they are the nav's | |
| 15 | * 44px. The 500 lists nothing about itself: what went wrong is in the log, and | |
| 16 | * a path, a stack, a runtime or a version on the page is a gift to whoever is | |
| 17 | * probing. Neither page is handed the error, so neither can leak it. | |
| 18 | * | |
| 19 | * Both are pure: environment in, markup out. `src/server.ts` renders each once | |
| 20 | * at start-up, because neither ever changes. | |
| 21 | */ | |
| 22 | ||
| 23 | import { listHtml, navFor } from "./games.ts"; | |
| 24 | import { page, type PageOptions as ShellOptions } from "./shell.ts"; | |
| 25 | ||
| 26 | /** What the shell needs that these pages cannot know: the environment they run in. */ | |
| 27 | export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "ssh">; | |
| 28 | ||
| 29 | /** | |
| 30 | * No path here is a game's, so the nav marks nothing current: every entry is a | |
| 31 | * link out, which is the whole use of a nav on a page you did not mean to be on. | |
| 32 | */ | |
| 33 | const NAV = navFor(""); | |
| 34 | ||
| 35 | /** | |
| 36 | * These pages' own CSS. The list of games is the way out of a miss, so each | |
| 37 | * link is the same target the header's nav is — 44px on a side, with the | |
| 38 | * padding pulled back so the text still lines up with the paragraph above it. | |
| 39 | */ | |
| 40 | const STYLE = ` | |
| 41 | section.miss p { max-width: 42rem; } | |
| 42 | ul.games { list-style:none; padding:0; margin:.5rem 0 0; } | |
| 43 | ul.games li { padding:.15rem 0; border-bottom:1px solid var(--line); color:var(--dim); font-size:.92rem; } | |
| 44 | ul.games a { display:inline-flex; align-items:center; min-height:44px; min-width:44px; padding:0 .55rem; | |
| 45 | margin-left:-.55rem; border-radius:.45rem; color:var(--ct); text-decoration:none; font-size:1rem; } | |
| 46 | ul.games a:hover, ul.games a:focus-visible { background:#1b2230; text-decoration:underline; } | |
| 47 | `; | |
| 48 | ||
| 49 | /** What both pages hand the shell; only the words differ. */ | |
| 50 | const missPage = (options: PageOptions, title: string, heading: string, description: string, bodyHtml: string): string => | |
| 51 | page({ | |
| 52 | ...options, | |
| 53 | title, | |
| 54 | description, | |
| 55 | heading, | |
| 56 | nav: NAV, | |
| 57 | terminalLead: "The shooter's leaderboard in a terminal:", | |
| 58 | footer: "nothing was stored for this request", | |
| 59 | extraStyle: STYLE, | |
| 60 | stageHtml: "", | |
| 61 | sectionsHtml: ` <section class="miss"> | |
| 62 | ${bodyHtml} | |
| 63 | </section>`, | |
| 64 | }); | |
| 65 | ||
| 66 | /** The 404: the sentence, and every game on this server as a way out of it. */ | |
| 67 | export const notFoundPage = (options: PageOptions): string => | |
| 68 | missPage( | |
| 69 | options, | |
| 70 | "not found — cs", | |
| 71 | "not found", | |
| 72 | "No page answers on that path. The games on this server are listed here.", | |
| 73 | ` <p>No page answers on that path. The games on this server:</p> | |
| 74 | ${listHtml()}`, | |
| 75 | ); | |
| 76 | ||
| 77 | /** | |
| 78 | * The 500: it says what happened and nothing about how. No path, no stack, no | |
| 79 | * runtime, no version — the detail is in this deployment's log, where a person | |
| 80 | * who is allowed to read it can. | |
| 81 | */ | |
| 82 | export const errorPage = (options: PageOptions): string => | |
| 83 | missPage( | |
| 84 | options, | |
| 85 | "something went wrong — cs", | |
| 86 | "something went wrong", | |
| 87 | "This request hit a bug on the server. The detail is in the log, not on this page.", | |
| 88 | ` <p>This request hit a bug on the server, and nothing about it is on this page: what went | |
| 89 | wrong is in this deployment's log, where it belongs. Nothing was stored. Try it again, or | |
| 90 | take one of the games in the header.</p>`, | |
| 91 | ); |
src/pages/shell.test.ts +40
| @@ -43,6 +43,46 @@ test("the document is a document, and the only script is the bundle", () => { | ||
| 43 | 43 | expect(html.match(/<style/g)?.length).toBe(1); |
| 44 | 44 | }); |
| 45 | 45 | |
| 46 | test("a page with no script has no script element anywhere", () => { | |
| 47 | // The 404 and the 500 run nothing, so they hand in no bundle — and what comes | |
| 48 | // back is still a whole document, with the element left out rather than | |
| 49 | // emitted empty. The test above is the other half: a game that forgets its | |
| 50 | // bundle has no script either, and its page's own test catches that. | |
| 51 | const { script, ...noScript } = options; | |
| 52 | const html = page(noScript); | |
| 53 | expect(html.startsWith("<!doctype html>\n<html lang=\"en\">\n")).toBe(true); | |
| 54 | expect(html).toContain(`<meta charset="utf-8">`); | |
| 55 | expect(html).toContain("<main>"); | |
| 56 | expect(html).toContain("<footer>"); | |
| 57 | expect(html.endsWith("</footer>\n</html>\n")).toBe(true); | |
| 58 | expect(html).not.toContain("<script"); | |
| 59 | expect(html).not.toContain(script); | |
| 60 | expect(html.match(/<style/g)?.length).toBe(1); | |
| 61 | }); | |
| 62 | ||
| 63 | test("the icon is in the document, so no browser goes looking for one", () => { | |
| 64 | const html = page(options); | |
| 65 | expect(html).toContain(`<link rel="icon" href="data:image/svg+xml,%3Csvg `); | |
| 66 | expect(html).toContain("%3C/svg%3E"); | |
| 67 | // In the head, before the title, and it is the only link element. | |
| 68 | expect(html.indexOf("<link rel=\"icon\"")).toBeLessThan(html.indexOf("<title>")); | |
| 69 | expect(html.match(/<link/g)?.length).toBe(1); | |
| 70 | // A data URI with a bare # would be a fragment, and the colours are hex. | |
| 71 | expect(html).not.toContain("favicon.ico"); | |
| 72 | const href = /<link rel="icon" href="([^"]*)"/.exec(html)?.[1] as string; | |
| 73 | expect(href).not.toContain("#"); | |
| 74 | // It is a URL, and what it carries is one whole SVG: decoded, not guessed at. | |
| 75 | const url = new URL(href); | |
| 76 | expect(url.protocol).toBe("data:"); | |
| 77 | const svg = decodeURIComponent(href.slice("data:image/svg+xml,".length)); | |
| 78 | expect(svg.startsWith("<svg xmlns=")).toBe(true); | |
| 79 | expect(svg.endsWith("</svg>")).toBe(true); | |
| 80 | expect(svg).toContain("viewBox='0 0 16 16'"); | |
| 81 | expect(svg).not.toContain('"'); | |
| 82 | // Small enough to be cheaper than the request it replaces. | |
| 83 | expect(href.length).toBeLessThan(600); | |
| 84 | }); | |
| 85 | ||
| 46 | 86 | test("the environment pill says which deployment this is", () => { |
| 47 | 87 | expect(where("production", undefined)).toBe("production"); |
| 48 | 88 | expect(where("preview", "17")).toBe("preview of pull #17"); |
src/pages/shell.ts +32 −5
| @@ -10,6 +10,11 @@ | ||
| 10 | 10 | * and where it sits. The list itself lives in `./games.ts`, which both pages |
| 11 | 11 | * read, so no page imports another page. |
| 12 | 12 | * |
| 13 | * The script is the one thing a page may not have: a game hands in its bundle | |
| 14 | * and gets a `<script>`, and a page that runs nothing — the 404, the 500 — hands | |
| 15 | * in none and gets no script element at all. Everything else in the document is | |
| 16 | * the same for both, which is the point of the shell. | |
| 17 | * | |
| 13 | 18 | * Escaping is the contract at this boundary. A parameter that carries text is |
| 14 | 19 | * escaped here (`title`, `heading`, `env`, `commit`, `ssh`, a touch label, an |
| 15 | 20 | * id, a nav entry's href and label); a parameter that carries markup the |
| @@ -89,6 +94,23 @@ code { background:#141821; padding:.1rem .35rem; border-radius:.25rem; } | ||
| 89 | 94 | @media (max-width: 640px) { #stage { aspect-ratio:4/3; } h1 { font-size:1.2rem; } } |
| 90 | 95 | `; |
| 91 | 96 | |
| 97 | /** | |
| 98 | * The icon, in the document rather than beside it: a crosshair on the page's | |
| 99 | * own background, written as an SVG data URI. A browser with no `<link | |
| 100 | * rel=icon>` asks for `/favicon.ico` on its own and is answered with a 404 by | |
| 101 | * design, which is a wasted request and a wasted miss; this is the same picture | |
| 102 | * in about three hundred bytes of the first paint, with no second request, no | |
| 103 | * file under `static/` and no route. The CSP already allows `img-src 'self' | |
| 104 | * data:`. `#` is `%23` because a bare one would start the URL's fragment; | |
| 105 | * single quotes are safe inside the double-quoted attribute, and there is | |
| 106 | * nothing here a caller can reach. | |
| 107 | */ | |
| 108 | const FAVICON = | |
| 109 | "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E" + | |
| 110 | "%3Crect width='16' height='16' rx='3.5' fill='%230b0d11'/%3E" + | |
| 111 | "%3Cpath d='M8 2.2v3.4M8 10.4v3.4M2.2 8h3.4M10.4 8h3.4' stroke='%23e2b04a' stroke-width='1.4' stroke-linecap='round'/%3E" + | |
| 112 | "%3Ccircle cx='8' cy='8' r='1.3' fill='%236fa8e8'/%3E%3C/svg%3E"; | |
| 113 | ||
| 92 | 114 | /** One touch button: the action `src/engine/controls.ts` holds, and its label. */ |
| 93 | 115 | export type TouchButton = { action: string; label: string }; |
| 94 | 116 | |
| @@ -177,8 +199,11 @@ export type PageOptions = { | ||
| 177 | 199 | env: string; |
| 178 | 200 | pull: string | undefined; |
| 179 | 201 | commit: string; |
| 180 | /** The URL of the one module script. Nothing else runs. */ | |
| 181 | script: string; | |
| 202 | /** | |
| 203 | * The URL of the one module script. Nothing else runs — and a page that runs | |
| 204 | * nothing leaves this out, which emits no `<script>` element at all. | |
| 205 | */ | |
| 206 | script?: string; | |
| 182 | 207 | ssh: string | undefined; |
| 183 | 208 | /** The sentence in front of the ssh command, when there is one. */ |
| 184 | 209 | terminalLead: string; |
| @@ -188,17 +213,20 @@ export type PageOptions = { | ||
| 188 | 213 | sectionsHtml: string; |
| 189 | 214 | /** Text. The footer reads `<heading> · <where> · <footer>`. */ |
| 190 | 215 | footer: string; |
| 191 | /** One game's own CSS, appended to the shared block so a rule for one game never reaches the other. */ | |
| 216 | /** One page's own CSS, appended to the shared block so a rule for one page never reaches another. */ | |
| 192 | 217 | extraStyle?: string; |
| 193 | 218 | }; |
| 194 | 219 | |
| 195 | 220 | /** The whole document. */ |
| 196 | 221 | export function page(options: PageOptions): string { |
| 197 | 222 | const here = where(options.env, options.pull); |
| 223 | const script = | |
| 224 | options.script === undefined ? "" : `<script type="module" src="${escape(options.script)}"></script>\n`; | |
| 198 | 225 | return `<!doctype html> |
| 199 | 226 | <html lang="en"> |
| 200 | 227 | <meta charset="utf-8"> |
| 201 | 228 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> |
| 229 | <link rel="icon" href="${FAVICON}"> | |
| 202 | 230 | <title>${escape(options.title)}</title> |
| 203 | 231 | <meta name="description" content="${escape(options.description)}"> |
| 204 | 232 | <style>${STYLE}${options.extraStyle === undefined ? "" : cssSafe(options.extraStyle)}</style> |
| @@ -215,7 +243,6 @@ ${options.sectionsHtml} | ||
| 215 | 243 | <p class="terminal">${terminal(options.ssh, options.terminalLead)}</p> |
| 216 | 244 | </main> |
| 217 | 245 | <footer>${escape(options.heading)} · ${escape(here)} · ${escape(options.footer)}</footer> |
| 218 | <script type="module" src="${escape(options.script)}"></script> | |
| 219 | </html> | |
| 246 | ${script}</html> | |
| 220 | 247 | `; |
| 221 | 248 | } |
src/server.test.ts +66 −3
| @@ -14,7 +14,7 @@ process.env.PORT = "0"; | ||
| 14 | 14 | // The test is the forge: it holds the same key and signs identities with it. |
| 15 | 15 | const KEY = "the-key-the-forge-signs-with"; |
| 16 | 16 | process.env.FORGE_APP_IDENTITY_KEY = KEY; |
| 17 | const { server, MATCH_LIMIT, GUEST_ADDRESS_LIMIT } = await import("./server.ts"); | |
| 17 | const { server, MATCH_LIMIT, GUEST_ADDRESS_LIMIT, onError } = await import("./server.ts"); | |
| 18 | 18 | const base = `http://${server.hostname}:${server.port}`; |
| 19 | 19 | |
| 20 | 20 | /** The two headers the forge puts on a request it proxies for a signed-in person. */ |
| @@ -213,13 +213,76 @@ test("the 404 names both games instead of only the front page", async () => { | ||
| 213 | 213 | const response = await fetch(base + "/nope"); |
| 214 | 214 | expect(response.status).toBe(404); |
| 215 | 215 | const body = await response.text(); |
| 216 | expect(body).toContain("Not found"); | |
| 216 | expect(body).toContain("not found"); | |
| 217 | 217 | expect(body).toContain(`<a href="/">cs</a>`); |
| 218 | 218 | expect(body).toContain(`<a href="/range">range</a>`); |
| 219 | 219 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); |
| 220 | 220 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); |
| 221 | 221 | }); |
| 222 | 222 | |
| 223 | test("a wrong path lands on the same document a game does, not on a fragment", async () => { | |
| 224 | // It used to be four hundred bytes of unstyled markup. It is the shell now: | |
| 225 | // the same head, the same header and the same footer every game is served in. | |
| 226 | const floor = await fetch(base + "/"); | |
| 227 | const response = await fetch(base + "/nope"); | |
| 228 | expect(response.status).toBe(404); | |
| 229 | const body = await response.text(); | |
| 230 | expect(body.startsWith("<!doctype html>")).toBe(true); | |
| 231 | expect(body).toContain(`<meta charset="utf-8">`); | |
| 232 | expect(body).toContain(`content="width=device-width, initial-scale=1, viewport-fit=cover"`); | |
| 233 | expect(body).toContain(`<link rel="icon" href="data:image/svg+xml,`); | |
| 234 | expect(body).toContain(`<nav aria-label="Games">`); | |
| 235 | expect(body).toContain("<h1>not found</h1>"); | |
| 236 | expect(body).toContain("<footer>"); | |
| 237 | // Nothing runs on it, so nothing is downloaded for it. | |
| 238 | expect(body).not.toContain("<script"); | |
| 239 | // The whole floor, header for header, is the same as the front page's. | |
| 240 | for (const header of ["content-security-policy", "x-content-type-options", "referrer-policy", | |
| 241 | "permissions-policy", "content-type"]) | |
| 242 | expect(response.headers.get(header)).toBe(floor.headers.get(header)); | |
| 243 | expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8"); | |
| 244 | }); | |
| 245 | ||
| 246 | test("HEAD on a missing path is the 404 with nothing to read", async () => { | |
| 247 | const head = await server.fetch(new Request(base + "/nope", { method: "HEAD" })); | |
| 248 | expect(head.status).toBe(404); | |
| 249 | expect(await head.text()).toBe(""); | |
| 250 | expect(head.headers.get("content-type")).toBe("text/html; charset=utf-8"); | |
| 251 | expect(head.headers.get("content-security-policy")).toContain("default-src 'self'"); | |
| 252 | }); | |
| 253 | ||
| 254 | test("a bug is a document too, and the error stays in the log", async () => { | |
| 255 | // The handler itself, called with an error that carries a path and a stack: | |
| 256 | // reaching it through a broken route would mean leaving one behind. | |
| 257 | const logged: unknown[] = []; | |
| 258 | const console_error = console.error; | |
| 259 | console.error = (...args: unknown[]) => void logged.push(args[0]); | |
| 260 | const boom = new Error("ENOENT: no such file, open '/data/app/secret/config.json'"); | |
| 261 | const response = onError(boom); | |
| 262 | console.error = console_error; | |
| 263 | ||
| 264 | expect(response.status).toBe(500); | |
| 265 | expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8"); | |
| 266 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); | |
| 267 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 268 | expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin"); | |
| 269 | ||
| 270 | const body = await response.text(); | |
| 271 | expect(body.startsWith("<!doctype html>")).toBe(true); | |
| 272 | expect(body).toContain("<h1>something went wrong</h1>"); | |
| 273 | expect(body).toContain(`<nav aria-label="Games">`); | |
| 274 | expect(body).toContain("<footer>"); | |
| 275 | expect(body).not.toContain("<script"); | |
| 276 | // Nothing the error was carrying is on the page: not the message, not the | |
| 277 | // path in it, not the stack, not the file the stack names. | |
| 278 | expect(body).not.toContain("ENOENT"); | |
| 279 | expect(body).not.toContain("/data/app/secret/config.json"); | |
| 280 | expect(body).not.toContain("secret"); | |
| 281 | for (const line of (boom.stack ?? "").split("\n").slice(1)) expect(body).not.toContain(line.trim()); | |
| 282 | // And it did reach the log, which is the only place it is. | |
| 283 | expect(logged).toEqual([boom]); | |
| 284 | }); | |
| 285 | ||
| 223 | 286 | test("the range stands on the same security floor as the game", async () => { |
| 224 | 287 | const [game, range] = await Promise.all([fetch(base + "/"), fetch(base + "/range")]); |
| 225 | 288 | for (const header of ["content-security-policy", "x-content-type-options", "referrer-policy", |
| @@ -240,7 +303,7 @@ test("HEAD /range answers like GET, and an unknown path is still the same 404", | ||
| 240 | 303 | |
| 241 | 304 | const missing = await fetch(base + "/range/nope"); |
| 242 | 305 | expect(missing.status).toBe(404); |
| 243 | expect(await missing.text()).toContain("Not found"); | |
| 306 | expect(await missing.text()).toContain("<h1>not found</h1>"); | |
| 244 | 307 | expect(missing.headers.get("content-security-policy")).toContain("default-src 'self'"); |
| 245 | 308 | }); |
| 246 | 309 |
src/server.ts +27 −14
| @@ -25,9 +25,9 @@ import { bind } from "./bind.ts"; | ||
| 25 | 25 | import { openDatabase, type Player, saveMatch } from "./db.ts"; |
| 26 | 26 | import { GAMES } from "./games.ts"; |
| 27 | 27 | import { createLimiter } from "./limit.ts"; |
| 28 | import { listHtml } from "./pages/games.ts"; | |
| 29 | 28 | import { examplePage } from "./pages/example.ts"; |
| 30 | 29 | import { gamePage, statsHtml } from "./pages/game.ts"; |
| 30 | import { errorPage, notFoundPage } from "./pages/miss.ts"; | |
| 31 | 31 | import { rangePage } from "./pages/range.ts"; |
| 32 | 32 | import { cleanName, parseResult } from "./result.ts"; |
| 33 | 33 | import { who, type Who } from "./who.ts"; |
| @@ -222,11 +222,28 @@ const READ = new Map<string, (request: Request) => Response>( | ||
| 222 | 222 | }), |
| 223 | 223 | ); |
| 224 | 224 | |
| 225 | // A wrong path is a dead end unless it says where the games are, so the 404 | |
| 226 | // names every one of them and what each one is, from the registry. Built once | |
| 227 | // at start-up: it never changes, and every href, label and sentence in it is | |
| 228 | // escaped where it is rendered, like anywhere else markup is made here. | |
| 229 | const NOT_FOUND = `<h1>Not found</h1><p>The games on this server:</p>${listHtml()}`; | |
| 225 | // The two pages no game owns, rendered through the same shell every game is | |
| 226 | // served inside: `src/pages/miss.ts` writes both. A wrong path is a dead end | |
| 227 | // unless it says where the games are, so the 404 names every one of them and | |
| 228 | // what each one is, from the registry; the 500 names nothing at all. Both are | |
| 229 | // built once at start-up, because neither ever changes — a miss stays as cheap | |
| 230 | // as it was when it was a fragment — and every href, label and sentence in them | |
| 231 | // is escaped where it is rendered, like anywhere else markup is made here. | |
| 232 | const NOT_FOUND = notFoundPage({ env, pull, commit, ssh }); | |
| 233 | const SERVER_ERROR = errorPage({ env, pull, commit, ssh }); | |
| 234 | ||
| 235 | /** | |
| 236 | * What a bug answers with: the floor, the shell's document and nothing else — | |
| 237 | * never the runtime's own error page, which carries source and paths, and never | |
| 238 | * without the headers. The error itself goes to the log, where it belongs, and | |
| 239 | * is not handed to the page, so there is nothing on it to leak. Exported | |
| 240 | * because it is the whole of what a 500 is here, and a test can call it without | |
| 241 | * leaving a broken route behind. | |
| 242 | */ | |
| 243 | export function onError(error: unknown): Response { | |
| 244 | console.error(error); | |
| 245 | return html(SERVER_ERROR, 500); | |
| 246 | } | |
| 230 | 247 | |
| 231 | 248 | // HEAD is GET without the body: the headers, the status and nothing to read. |
| 232 | 249 | const withoutBodyIfHead = (request: Request, response: Response): Response => |
| @@ -287,15 +304,11 @@ export const server = Bun.serve({ | ||
| 287 | 304 | }); |
| 288 | 305 | } |
| 289 | 306 | |
| 290 | return html(NOT_FOUND, 404); | |
| 291 | }, | |
| 292 | // A bug answers with the floor and nothing else: never the runtime's own | |
| 293 | // error page, which carries source and paths, and never without the | |
| 294 | // headers. The detail goes to the log, where it belongs. | |
| 295 | error(error) { | |
| 296 | console.error(error); | |
| 297 | return html("<h1>Something went wrong</h1>", 500); | |
| 307 | // Nothing answers here. HEAD is GET without a body on a miss too: the | |
| 308 | // status and the headers, and nothing to read. | |
| 309 | return withoutBodyIfHead(request, html(NOT_FOUND, 404)); | |
| 298 | 310 | }, |
| 311 | error: onError, | |
| 299 | 312 | }); |
| 300 | 313 | |
| 301 | 314 | if (listen.unusable !== undefined) |
new above
offline