From 3d757d4726e0a8ec3a5e6b685bf91fa48d5b78c8 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 10:00:03 +0100 Subject: [PATCH] item-34 - Serve every /public/ asset under a content-versioned URL with an immutable Cache-Control --- CHANGELOG.md | 9 +++ README.md | 3 + docs/reading-cost.md | 17 +++++ e2e-tests/visual.spec.ts | 18 +++++ src/function-limits.test.ts | 2 +- src/http/app.test.ts | 74 +++++++++++++++------ src/http/app.ts | 16 ++--- src/http/static.test.ts | 129 ++++++++++++++++++++++++++++++++++++ src/http/static.ts | 92 ++++++++++++++++++++++--- src/ui/shell.test.ts | 10 ++- todo.md | 31 --------- views/403.ejs | 2 +- views/404.ejs | 2 +- views/500.ejs | 2 +- views/503.ejs | 2 +- views/error.ejs | 2 +- views/oauth-consent.ejs | 6 +- views/partials/shell.ejs | 11 +-- 18 files changed, 340 insertions(+), 88 deletions(-) create mode 100644 src/http/static.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d22b83a..91bab14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ at boot. Entries start at 0.3.0. ## Unreleased +### Browsers cache stylesheets instead of downloading them on every page + +Every `/public/` URL the host renders carries `?v=` and is cached for a year as +immutable: the shell's stylesheets, favicon and logo, the `styles` a plugin passes to the shell, and +the standalone error and consent pages. A release changes the hash, so browsers fetch a changed file +at once. A plugin names its stylesheets exactly as before. Any other `/public/` URL, such as a script +a plugin's own markup links, is cached for five minutes, then revalidated with `ETag` or +`Last-Modified`. A cache or CDN in front of Plainpages may now store `/public/` responses. + ### A language chosen on a sign-in link no longer drops an absolute return target `/login?return_to=&locale=sv-SE` passed the visitor to the dashboard after sign-in and diff --git a/README.md b/README.md index 0f1871b..42cc5a3 100644 --- a/README.md +++ b/README.md @@ -510,6 +510,9 @@ export async function listThings(ctx: RequestContext) { nested names like `"things/edit"` work, out-of-bounds names are refused. The template may `include()` the core building-block partials and its own. To load the plugin's own CSS, pass its `/public//x.css` href in the shell's `styles` slot — see the reference's `views/shifts.ejs`. + The host adds the file's content hash to every `/public/` href the shell renders, so browsers and + any cache in front of Plainpages keep it for a year; a `/public/` URL your own markup writes is + kept for five minutes, then revalidated. - **Finer authorization than the route `permission`** uses the guards from `@plainpages/plugin-api`: `requireSession(ctx)`, `can(ctx, permission)` (coarse JWT-claim check, zero I/O), and `check(keto, ctx, {namespace, object, relation})` (a live Keto check; anonymous ⇒ denied). Throw diff --git a/docs/reading-cost.md b/docs/reading-cost.md index 821295e..caf9284 100644 --- a/docs/reading-cost.md +++ b/docs/reading-cost.md @@ -139,3 +139,20 @@ Read most (opens and searches): - `compose.yml` 1 - `e2e-tests/auth-refresh.spec.ts` 1 - `src/auth/flow-view.ts` 1 + +## Reading cost, chunk 3373527a4f72, finished 2026-10-31T08:52:59Z + +138664 new input tokens before the first commit. + +Read most (opens and searches): + +- `examples/plugins/admin/admin-users.ts` 8 +- `README.md` 7 +- `src/http/app.test.ts` 7 +- `e2e-tests/full-flow.spec.ts` 3 +- `examples/plugins/admin/admin-groups.ts` 3 +- `examples/plugins/admin/admin-users.test.ts` 3 +- `examples/plugins/admin/i18n/en-US.ts` 3 +- `src/ui/icons.ts` 3 +- `todo.md` 3 +- `CHANGELOG.md` 2 diff --git a/e2e-tests/visual.spec.ts b/e2e-tests/visual.spec.ts index 5a85eb3..0fd7919 100644 --- a/e2e-tests/visual.spec.ts +++ b/e2e-tests/visual.spec.ts @@ -250,6 +250,24 @@ test("the reference plugin's lead text is inset by --pad-x, like the bars around expect(inset).toBe(padX); }); +// transferSize is 0 only for a response the browser's own cache served; a 304 still costs a round trip. +test("a repeat page view fetches no stylesheet again", async ({ page }) => { + const stylesheets = (): Promise<{ name: string; transferSize: number }[]> => page.evaluate(() => + performance.getEntriesByType("resource") + .filter((entry): entry is PerformanceResourceTiming => entry instanceof PerformanceResourceTiming && entry.initiatorType === "link" && entry.name.includes(".css")) + .map(({ name, transferSize }) => ({ name, transferSize }))); + + await page.goto("/"); + const first = await stylesheets(); + expect(first.length, "the landing links styles.css and auth.css").toBeGreaterThanOrEqual(2); + for (const { name, transferSize } of first) expect(transferSize, `${name} on the first view`).toBeGreaterThan(0); + + await page.goto("/"); + const repeat = await stylesheets(); + expect(repeat.map(({ name }) => name)).toEqual(first.map(({ name }) => name)); + for (const { name, transferSize } of repeat) expect(transferSize, `${name} on the repeat view`).toBe(0); +}); + test("unknown routes serve the 404 page (a real user-facing flow, covered end-to-end)", async ({ page }) => { allowConsole(/status of 404 .*\/no-such-page$/); // the navigation under test, which Chromium and WebKit log — not a sub-resource of it const res = await page.goto("/no-such-page"); diff --git a/src/function-limits.test.ts b/src/function-limits.test.ts index 131157a..d2a852e 100644 --- a/src/function-limits.test.ts +++ b/src/function-limits.test.ts @@ -14,7 +14,7 @@ const LIMITS = { lines: 50, complexity: 20 }; // leaves the entry once it fits, so a function that shrank can't grow back. const grandfathered: Record = { "src/http/app.ts createApp": { lines: 318, complexity: 28 }, - "src/http/app.ts createApp > handleRequest": { lines: 168, complexity: 43 }, + "src/http/app.ts createApp > handleRequest": { lines: 167, complexity: 43 }, "src/auth/flow-view.ts buildFlowView": { complexity: 24 }, "src/auth/hydra-admin.ts createHydraAdmin": { lines: 87 }, "src/auth/kratos-admin.ts createKratosAdmin": { lines: 68 }, diff --git a/src/http/app.test.ts b/src/http/app.test.ts index 72990c2..8bc23ca 100644 --- a/src/http/app.test.ts +++ b/src/http/app.test.ts @@ -7,6 +7,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { after, before, test, type TestContext } from "node:test"; import { fileURLToPath } from "node:url"; +import ejs from "ejs"; import { createApp, type AppOptions } from "./app.ts"; import { readFormBody } from "./body.ts"; import { createLogger } from "../logger.ts"; @@ -20,11 +21,11 @@ import type { Identity, KratosAdmin } from "../auth/kratos-admin.ts"; import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "../auth/kratos-public.ts"; import { SESSION_COOKIE } from "../auth/login.ts"; import type { Plugin } from "../plugin-host/plugin.ts"; -import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts"; import adminManifest from "../../examples/plugins/admin/plugin.ts"; import { createI18n } from "../i18n/runtime.ts"; import type { MenuConfig } from "../ui/menu-config.ts"; import { loadI18n } from "../i18n/load.ts"; +import { ENGLISH_LOCALS } from "../i18n/view-locals.ts"; const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views"); // The HTTP-level admin tests mount the example plugin via createApp — stub Ory clients on @@ -286,7 +287,7 @@ test("every response carries the security headers; HSTS follows SECURE_COOKIES", assert.match(res.headers.get("strict-transport-security") ?? "", /max-age=\d+/); }); -test("Cache-Control: pages and JSON are private, no-store unless the route sets its own; assets are left alone", async (t) => { +test("Cache-Control: pages and JSON are private, no-store unless the route sets its own", async (t) => { const NO_STORE = "private, no-store"; const csrf = (await fetch(base + "/")).headers.get("set-cookie")?.split(";")[0] ?? ""; assert.match(csrf, new RegExp(`^${CSRF_COOKIE}=`)); @@ -300,7 +301,7 @@ test("Cache-Control: pages and JSON are private, no-store unless the route sets assert.equal((await fetch(base + "/dashboard", { headers: { cookie: session() } })).headers.get("cache-control"), NO_STORE); assert.equal((await fetch(base + "/dashboard", { redirect: "manual" })).headers.get("cache-control"), NO_STORE); // sign-in bounce assert.equal((await fetch(base + "/nope", { headers: { cookie: csrf } })).headers.get("cache-control"), NO_STORE); // 404 - assert.equal((await fetch(base + "/public/css/styles.css")).headers.get("cache-control"), null); + assert.equal((await fetch(base + "/public/css/styles.css")).headers.get("cache-control"), "public, max-age=300"); // static.test.ts covers asset caching const demo: Plugin = { apiVersion: "1.0.0", @@ -418,6 +419,54 @@ test("renders the 500 HTML page when a handler throws", async () => { } }); +// Every /public/ reference in the page, as written in the HTML. +const publicRefs = (html: string): string[] => [...html.matchAll(/(?:href|src)="(\/public\/[^"]*)"/g)].map((m) => m[1] ?? ""); + +test("every /public/ href the host renders is versioned and answers a year-long immutable Cache-Control", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "pp-plugins-")); + mkdirSync(join(dir, "styled", "views"), { recursive: true }); + mkdirSync(join(dir, "styled", "public"), { recursive: true }); + writeFileSync(join(dir, "styled", "views", "page.ejs"), `<%- include("partials/shell", { body: "

styled

", styles: ["/public/styled/app.css"] }) %>`); + writeFileSync(join(dir, "styled", "public", "app.css"), ".styled{}"); + t.after(() => rmSync(dir, { force: true, recursive: true })); + const styled: Plugin = { apiVersion: "1.0.0", id: "styled", routes: [{ handler: () => ({ data: {}, view: "page" }), method: "GET", path: "/page", public: true }] }; + const url = await startApp(t, [styled], dir); + + const pages = { home: "/", notFound: "/no-such-page", plugin: "/styled/page" }; + const seen = new Set(); + for (const [name, path] of Object.entries(pages)) { + const refs = publicRefs(await (await fetch(url + path)).text()); + assert.ok(refs.length > 0, name); + for (const ref of refs) { + assert.match(ref, /\?v=[\w-]+$/, `${name}: ${ref}`); + seen.add(ref); + } + } + assert.ok([...seen].some((ref) => ref.startsWith("/public/styled/app.css?v=")), "the plugin's styles href"); + assert.ok([...seen].some((ref) => ref.startsWith("/public/favicon.svg?v=")), "the favicon"); + for (const ref of seen) { + const res = await fetch(url + ref); + assert.equal(res.status, 200, ref); + assert.equal(res.headers.get("cache-control"), "public, max-age=31536000, immutable", ref); + } +}); + +test("the standalone error and consent views version their stylesheets", async () => { + for (const view of ["403", "404", "500", "503", "error", "oauth-consent"]) { + const html = await ejs.renderFile(join(viewsDir, `${view}.ejs`), { + ...ENGLISH_LOCALS, + assetHref: (href: string) => `${href}?v=test`, + consent: { account: "a@b.c", challenge: "c", client: "x", scopes: [] }, + csrfField: "csrf", + csrfToken: "t", + error: { message: "m" }, + }); + const refs = publicRefs(html); + assert.ok(refs.length > 0, view); + for (const ref of refs) assert.match(ref, /\?v=test$/, `${view}: ${ref}`); + } +}); + // A test plugin exercising each RouteResult shape, a path param, and the permission gate. const demoPlugin: Plugin = { apiVersion: "1.0.0", @@ -1664,25 +1713,6 @@ test("admin OAuth2 clients screen: gate, list, register (one-time secret), detai assert.equal((await get("/admin/clients/%ZZ")).status, 404); }); -test("resolveStaticPath blocks traversal and control chars, allows nested files", () => { - assert.equal(resolveStaticPath("/srv/public", "../app.ts"), null); - assert.equal(resolveStaticPath("/srv/public", "a\x00b"), null); - assert.equal(resolveStaticPath("/srv/public", "css/styles.css"), "/srv/public/css/styles.css"); -}); - -test("contentTypeFor maps known and unknown extensions", () => { - assert.match(contentTypeFor("a.css"), /text\/css/); - assert.equal(contentTypeFor("a.bin"), "application/octet-stream"); -}); - -test("routePublic sends a plugin-id segment to its public/ dir, everything else to core", () => { - const ids = new Set(["scheduling"]); - assert.deepEqual(routePublic("scheduling/app.css", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "app.css" }); - assert.deepEqual(routePublic("scheduling/img/logo.svg", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "img/logo.svg" }); - assert.deepEqual(routePublic("scheduling", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "" }); // bare /public/, no file - assert.deepEqual(routePublic("css/styles.css", "/core", "/plugins", ids), { dir: "/core", subPath: "css/styles.css" }); // not a plugin → core -}); - // ---- language (i18n) ---- // The installed catalogs, as server.ts wires them: the shipped core locales (en-US + sv-SE). diff --git a/src/http/app.ts b/src/http/app.ts index f55baff..ae1534a 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -32,7 +32,7 @@ import { allowedMethods, matchRoute } from "../plugin-host/router.ts"; import { buildAuthRoutes } from "../auth/routes.ts"; import { securityHeaders } from "./security-headers.ts"; import { localPath } from "./safe-url.ts"; -import { routePublic, serveStatic } from "./static.ts"; +import { publicAssets } from "./static.ts"; import { renderPluginView } from "../plugin-host/view-resolver.ts"; const NO_STORE = "private, no-store"; @@ -98,6 +98,7 @@ export function createApp(options: AppOptions = {}): Server { const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse); const pluginsDir = options.pluginsDir ?? PLUGINS_DIR; const publicDir = options.publicDir ?? join(rootDir, "public"); + const assets = publicAssets(publicDir, pluginsDir, pluginIds); const viewsDir = options.viewsDir ?? join(rootDir, "views"); // Response security headers, fixed at boot (only HSTS depends on the https deployment signal). const secHeaderEntries = Object.entries(securityHeaders({ secure: secureCookies })); @@ -131,10 +132,10 @@ export function createApp(options: AppOptions = {}): Server { t: ctx.t, url: ctx.url, }); - // i18n locals go last: their names are reserved, so a handler's colliding key loses instead of - // breaking the shell around it. - const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)) }); - const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)) }); + // i18n locals and `assetHref` (static.ts) go last: their names are reserved, so a handler's + // colliding key loses instead of breaking the shell around it. + const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)), assetHref: assets.href }); + const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)), assetHref: assets.href }); const sendHtml = (res: ServerResponse, status: number, html: string): void => { res.writeHead(status, { "content-type": "text/html; charset=utf-8" }); @@ -184,7 +185,7 @@ export function createApp(options: AppOptions = {}): Server { const handleRequest = async (req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise => { // Error pages can render before this request has a context at all (a throw on the way to one), // so they start on the built-in English and switch to the visitor's locale once it is resolved. - let renderPage: ViewRenderer = (view, data) => render(view, { ...data, ...ENGLISH_LOCALS }); + let renderPage: ViewRenderer = (view, data) => render(view, { ...data, ...ENGLISH_LOCALS, assetHref: assets.href }); try { const method = req.method ?? "GET"; const url = new URL(req.url ?? "/", "http://localhost"); @@ -196,8 +197,7 @@ export function createApp(options: AppOptions = {}): Server { // Before auth: assets don't need a verified user, and the JWT cookie rides every request. if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) { - const { dir, subPath } = routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds); - await serveStatic(dir, subPath, res, method === "HEAD", (err) => reqLog.error("static stream error", { error: String(err) })); + await assets.serve(url, req, res, (err) => reqLog.error("static stream error", { error: String(err) })); return; } diff --git a/src/http/static.test.ts b/src/http/static.test.ts new file mode 100644 index 0000000..d5b2eaa --- /dev/null +++ b/src/http/static.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; +import { contentTypeFor, publicAssets, resolveStaticPath, routePublic } from "./static.ts"; + +const IMMUTABLE = "public, max-age=31536000, immutable"; +const SHORT = "public, max-age=300"; + +const root = mkdtempSync(join(tmpdir(), "static-test-")); +const publicDir = join(root, "public"); +const pluginsDir = join(root, "plugins"); +mkdirSync(join(publicDir, "css"), { recursive: true }); +mkdirSync(join(pluginsDir, "demo", "public"), { recursive: true }); +writeFileSync(join(publicDir, "css", "a.css"), "body { color: red }"); +writeFileSync(join(pluginsDir, "demo", "public", "app.css"), ".demo {}"); +const assets = publicAssets(publicDir, pluginsDir, new Set(["demo"])); +const assetHref = assets.href; + +let base = ""; +const server = createServer((req, res) => void assets.serve(new URL(req.url ?? "/", "http://localhost"), req, res, (err) => assert.fail(err))); +before(() => new Promise((r) => server.listen(0, () => { + base = `http://localhost:${(server.address() as AddressInfo).port}`; + r(); +}))); +after(() => { + server.close(); + rmSync(root, { force: true, recursive: true }); +}); + +test("resolveStaticPath blocks traversal and control chars, allows nested files", () => { + assert.equal(resolveStaticPath("/srv/public", "../app.ts"), null); + assert.equal(resolveStaticPath("/srv/public", "a\x00b"), null); + assert.equal(resolveStaticPath("/srv/public", "css/styles.css"), "/srv/public/css/styles.css"); +}); + +test("contentTypeFor maps known and unknown extensions", () => { + assert.match(contentTypeFor("a.css"), /text\/css/); + assert.equal(contentTypeFor("a.bin"), "application/octet-stream"); +}); + +test("routePublic sends a plugin-id segment to its public/ dir, everything else to core", () => { + const ids = new Set(["scheduling"]); + assert.deepEqual(routePublic("scheduling/app.css", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "app.css" }); + assert.deepEqual(routePublic("scheduling/img/logo.svg", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "img/logo.svg" }); + assert.deepEqual(routePublic("scheduling", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "" }); // bare /public/, no file + assert.deepEqual(routePublic("css/styles.css", "/core", "/plugins", ids), { dir: "/core", subPath: "css/styles.css" }); // not a plugin → core +}); + +test("assetHref versions a /public/ file, core or plugin, and leaves every other href alone", () => { + assert.match(assetHref("/public/css/a.css"), /^\/public\/css\/a\.css\?v=[\w-]+$/); + assert.match(assetHref("/public/demo/app.css"), /^\/public\/demo\/app\.css\?v=[\w-]+$/); + assert.notEqual(assetHref("/public/css/a.css"), assetHref("/public/demo/app.css")); + for (const href of ["/public/css/missing.css", "/public/css", "/public/css/a.css?x=1", "/public/css/a.css#top", "/public/..%2f..%2fetc/passwd", "/public/%E0%A4%A", "https://cdn.example/a.css", "data:image/svg+xml,", "/elsewhere/a.css"]) { + assert.equal(assetHref(href), href, href); + } +}); + +test("assetHref changes when the file's content does", () => { + const file = join(publicDir, "css", "changing.css"); + writeFileSync(file, "a {}"); + const first = assetHref("/public/css/changing.css"); + assert.equal(assetHref("/public/css/changing.css"), first); + writeFileSync(file, "a { color: blue }"); + assert.notEqual(assetHref("/public/css/changing.css"), first); +}); + +test("a URL carrying the current version is cached for a year as immutable", async () => { + for (const method of ["GET", "HEAD"]) { + const res = await fetch(base + assetHref("/public/css/a.css"), { method }); + assert.equal(res.status, 200, method); + assert.equal(res.headers.get("cache-control"), IMMUTABLE, method); + } + assert.equal((await fetch(base + assetHref("/public/demo/app.css"))).headers.get("cache-control"), IMMUTABLE); +}); + +test("an unversioned or stale-versioned URL is cached briefly, with an ETag and Last-Modified to revalidate", async () => { + const lastModified = statSync(join(publicDir, "css", "a.css")).mtime.toUTCString(); + for (const path of ["/public/css/a.css", "/public/css/a.css?v=stale"]) { + const res = await fetch(base + path); + assert.equal(res.status, 200, path); + assert.equal(await res.text(), "body { color: red }", path); + assert.equal(res.headers.get("cache-control"), SHORT, path); + assert.match(res.headers.get("etag") ?? "", /^"[\w-]+"$/, path); + assert.equal(res.headers.get("last-modified"), lastModified, path); + } +}); + +test("If-None-Match naming the current ETag answers 304 with no body, for GET and HEAD", async () => { + const etag = (await fetch(base + "/public/css/a.css")).headers.get("etag") ?? ""; + for (const method of ["GET", "HEAD"]) { + for (const ifNoneMatch of [etag, `W/${etag}`, `"other", ${etag}`, "*"]) { + const res = await fetch(base + "/public/css/a.css", { headers: { "if-none-match": ifNoneMatch }, method }); + assert.equal(res.status, 304, `${method} ${ifNoneMatch}`); + assert.equal(await res.text(), ""); + assert.equal(res.headers.get("etag"), etag); + assert.equal(res.headers.get("cache-control"), SHORT); + } + } + const versioned = await fetch(base + assetHref("/public/css/a.css"), { headers: { "if-none-match": etag } }); + assert.equal(versioned.status, 304); + assert.equal(versioned.headers.get("cache-control"), IMMUTABLE); + + assert.equal((await fetch(base + "/public/css/a.css", { headers: { "if-none-match": '"other"' } })).status, 200); +}); + +test("If-Modified-Since at or after the file's mtime answers 304; before it, or beside a failed If-None-Match, 200", async () => { + const lastModified = (await fetch(base + "/public/css/a.css")).headers.get("last-modified") ?? ""; + for (const method of ["GET", "HEAD"]) { + const res = await fetch(base + "/public/css/a.css", { headers: { "if-modified-since": lastModified }, method }); + assert.equal(res.status, 304, method); + assert.equal(await res.text(), ""); + } + const earlier = new Date(Date.parse(lastModified) - 1000).toUTCString(); + assert.equal((await fetch(base + "/public/css/a.css", { headers: { "if-modified-since": earlier } })).status, 200); + assert.equal((await fetch(base + "/public/css/a.css", { headers: { "if-modified-since": "not a date" } })).status, 200); + // RFC 9110 §13.2.2: If-None-Match, when present, decides alone. + assert.equal((await fetch(base + "/public/css/a.css", { headers: { "if-modified-since": lastModified, "if-none-match": '"other"' } })).status, 200); +}); + +test("unsafe or missing paths still answer 400, 403 and 404", async () => { + assert.equal((await fetch(base + "/public/%E0%A4%A")).status, 400); + assert.equal((await fetch(base + "/public/..%2f..%2fetc%2fpasswd")).status, 403); + assert.equal((await fetch(base + "/public/css/missing.css")).status, 404); + assert.equal((await fetch(base + "/public/css")).status, 404); +}); diff --git a/src/http/static.ts b/src/http/static.ts index ef8ce54..3adfe3c 100644 --- a/src/http/static.ts +++ b/src/http/static.ts @@ -1,6 +1,7 @@ -import { createReadStream } from "node:fs"; +import { createHash } from "node:crypto"; +import { createReadStream, readFileSync, statSync, type Stats } from "node:fs"; import { stat } from "node:fs/promises"; -import type { ServerResponse } from "node:http"; +import type { IncomingMessage, ServerResponse } from "node:http"; import { extname, isAbsolute, join, relative } from "node:path"; const contentTypes: Record = { @@ -38,8 +39,8 @@ export interface StaticRoute { // Route a `/public/` request to a base dir + sub-path: a leading segment naming a discovered // plugin serves from plugins//public/, anything else from the core public/. Plugin ids are -// URL-safe (no %-encoding), so the raw segment compares directly to the id set; serveStatic decodes -// and traversal-guards the sub-path as before. +// URL-safe (no %-encoding), so the raw segment compares directly to the id set; the sub-path is +// decoded and traversal-guarded after. export function routePublic(restPath: string, publicDir: string, pluginsDir: string, pluginIds: Set): StaticRoute { const slash = restPath.indexOf("/"); const first = slash === -1 ? restPath : restPath.slice(0, slash); @@ -49,28 +50,82 @@ export function routePublic(restPath: string, publicDir: string, pluginsDir: str return { dir: publicDir, subPath: restPath }; } +// Caching: the host renders every /public/ href through assetHref, which appends `?v=`. A request whose `v` is the current hash is cached for a year as immutable, so a +// repeat page view downloads no asset; a release changes the hash, and with it the URL. Any other +// request — an href a plugin's own markup emits, a stale `v` mid-deploy — is cached for five minutes, +// then revalidated against the ETag (that same hash) or Last-Modified. +const IMMUTABLE = "public, max-age=31536000, immutable"; +const SHORT = "public, max-age=300"; + +// Keyed on mtime and size, so a file edited in place (a dev bind mount) gets a new hash. +const hashes = new Map(); + +function contentHash(filePath: string, info: Stats): string { + const known = hashes.get(filePath); + if (known?.mtimeMs === info.mtimeMs && known.size === info.size) return known.hash; + const hash = createHash("sha256").update(readFileSync(filePath)).digest("base64url").slice(0, 16); + hashes.set(filePath, { hash, mtimeMs: info.mtimeMs, size: info.size }); + return hash; +} + +// Synchronous because EJS templates are: a view calls it mid-render. The stat per href is the +// price of picking up an edited file; the hash itself is read once per file version. +function versioned(href: string, route: StaticRoute): string { + try { + const filePath = resolveStaticPath(route.dir, decodeURIComponent(route.subPath)); + if (filePath === null) return href; + const info = statSync(filePath); + return info.isFile() ? `${href}?v=${contentHash(filePath, info)}` : href; + } catch { + return href; // malformed %-encoding or a missing file: the request answers 400/404 on its own + } +} + +// RFC 9110 §13.1.2, weak comparison: `W/"x"` matches `"x"`, and `*` matches any existing file. +function etagMatches(ifNoneMatch: string, etag: string): boolean { + return ifNoneMatch.split(",").some((tag) => { + const t = tag.trim(); + return t === "*" || t.replace(/^W\//, "") === etag; + }); +} + +// RFC 9110 §13.2.2: If-None-Match, when present, decides alone; HTTP dates have whole seconds. +function notModified(req: IncomingMessage, etag: string, info: Stats): boolean { + const ifNoneMatch = req.headers["if-none-match"]; + if (ifNoneMatch !== undefined) return etagMatches(ifNoneMatch, etag); + const since = Date.parse(req.headers["if-modified-since"] ?? ""); + return !Number.isNaN(since) && Math.floor(info.mtimeMs / 1000) * 1000 <= since; +} + function plain(res: ServerResponse, status: number, body: string): void { res.writeHead(status, { "content-type": "text/plain; charset=utf-8" }).end(body); } -// onError handles a mid-stream read failure (headers already sent); defaults to console.error so -// static.ts stays standalone, while app.ts passes the request logger for structured output. -export async function serveStatic(dir: string, requestedPath: string, res: ServerResponse, head = false, onError: (err: Error) => void = (err) => console.error(err)): Promise { +interface StaticRequest extends StaticRoute { + version: string | null; // the request's `v` query parameter +} + +async function serveStatic(route: StaticRequest, req: IncomingMessage, res: ServerResponse, onError: (err: Error) => void): Promise { let decoded: string; try { - decoded = decodeURIComponent(requestedPath); + decoded = decodeURIComponent(route.subPath); } catch { return plain(res, 400, "Bad Request"); } - const filePath = resolveStaticPath(dir, decoded); + const filePath = resolveStaticPath(route.dir, decoded); if (filePath === null) return plain(res, 403, "Forbidden"); try { const info = await stat(filePath); if (!info.isFile()) return plain(res, 404, "Not Found"); - res.writeHead(200, { "content-length": info.size, "content-type": contentTypeFor(filePath) }); - if (head) return void res.end(); // headers only — skip opening the file + const hash = contentHash(filePath, info); + const etag = `"${hash}"`; + const validators = { "cache-control": route.version === hash ? IMMUTABLE : SHORT, etag, "last-modified": info.mtime.toUTCString() }; + if (notModified(req, etag, info)) return void res.writeHead(304, validators).end(); + res.writeHead(200, { ...validators, "content-length": info.size, "content-type": contentTypeFor(filePath) }); + if (req.method === "HEAD") return void res.end(); // headers only — skip opening the file // Headers are already sent, so a mid-stream read error can't become an HTTP status — // log and destroy the response to signal a truncated body, not a hung socket. createReadStream(filePath) @@ -83,3 +138,18 @@ export async function serveStatic(dir: string, requestedPath: string, res: Serve plain(res, 404, "Not Found"); } } + +export interface PublicAssets { + href: (href: string) => string; // a /public/ href with its version; any other href unchanged + // onError handles a mid-stream read failure (headers already sent), e.g. with the request logger. + serve: (url: URL, req: IncomingMessage, res: ServerResponse, onError: (err: Error) => void) => Promise; +} + +// The /public/ namespace both ways: the hrefs views render, and the requests those hrefs make. +export function publicAssets(publicDir: string, pluginsDir: string, pluginIds: Set): PublicAssets { + const route = (pathname: string): StaticRoute => routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds); + return { + href: (href) => (href.startsWith("/public/") && !/[?#]/.test(href) ? versioned(href, route(href)) : href), + serve: (url, req, res, onError) => serveStatic({ ...route(url.pathname), version: url.searchParams.get("v") }, req, res, onError), + }; +} diff --git a/src/ui/shell.test.ts b/src/ui/shell.test.ts index b062e73..62397e6 100644 --- a/src/ui/shell.test.ts +++ b/src/ui/shell.test.ts @@ -6,10 +6,10 @@ import ejs from "ejs"; import { ENGLISH_LOCALS } from "../i18n/view-locals.ts"; // A localeHref that marks what it touches, so a raw href in the chrome is visible to a test. -const CARRYING = { ...ENGLISH_LOCALS, localeHref: (href: string) => `${href}?locale=sv-SE` }; +const CARRYING = { ...ENGLISH_LOCALS, assetHref: (href: string) => href, localeHref: (href: string) => `${href}?locale=sv-SE` }; const shell = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "shell.ejs"); -const render = (data: Record = {}): Promise => ejs.renderFile(shell, { ...ENGLISH_LOCALS, ...data }); +const render = (data: Record = {}): Promise => ejs.renderFile(shell, { ...ENGLISH_LOCALS, assetHref: (href: string) => href, ...data }); test("app shell renders sidebar, topbar and the content slot", async () => { const html = await render({ @@ -90,6 +90,12 @@ test("app shell links extra per-page stylesheets via the styles slot (e.g. a plu assert.equal((none.match(/rel="stylesheet"/g) ?? []).length, 1); }); +test("app shell passes every /public/ reference through the host's assetHref, the plugin's styles included", async () => { + const html = await render({ assetHref: (href: string) => `${href}?v=1`, brand: { logo: "/public/brand/logo.svg", name: "Acme" }, styles: ["/public/scheduling/scheduling.css"] }); + const refs = [...html.matchAll(/(?:href|src)="(\/public\/[^"]*)"/g)].map((m) => m[1]); + assert.deepEqual(refs, ["/public/css/styles.css?v=1", "/public/scheduling/scheduling.css?v=1", "/public/favicon.svg?v=1", "/public/brand/logo.svg?v=1"]); +}); + test("app shell can disable the menu: no sidebar, focused single-column layout", async () => { const bare = await render({ menu: false, title: "Focus", body: '
x
', nav: 'Overview' }); assert.doesNotMatch(bare, /