item-34 - Serve every /public/ asset under a content-versioned URL with an immutable Cache-Control
This commit is contained in:
@@ -6,6 +6,15 @@ at boot. Entries start at 0.3.0.
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Browsers cache stylesheets instead of downloading them on every page
|
||||||
|
|
||||||
|
Every `/public/` URL the host renders carries `?v=<hash of the file>` 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
|
### A language chosen on a sign-in link no longer drops an absolute return target
|
||||||
|
|
||||||
`/login?return_to=<absolute URL>&locale=sv-SE` passed the visitor to the dashboard after sign-in and
|
`/login?return_to=<absolute URL>&locale=sv-SE` passed the visitor to the dashboard after sign-in and
|
||||||
|
|||||||
@@ -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
|
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
|
`include()` the core building-block partials and its own. To load the plugin's own CSS, pass its
|
||||||
`/public/<id>/x.css` href in the shell's `styles` slot — see the reference's `views/shifts.ejs`.
|
`/public/<id>/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`:
|
- **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
|
`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
|
`check(keto, ctx, {namespace, object, relation})` (a live Keto check; anonymous ⇒ denied). Throw
|
||||||
|
|||||||
@@ -139,3 +139,20 @@ Read most (opens and searches):
|
|||||||
- `compose.yml` 1
|
- `compose.yml` 1
|
||||||
- `e2e-tests/auth-refresh.spec.ts` 1
|
- `e2e-tests/auth-refresh.spec.ts` 1
|
||||||
- `src/auth/flow-view.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
|
||||||
|
|||||||
@@ -250,6 +250,24 @@ test("the reference plugin's lead text is inset by --pad-x, like the bars around
|
|||||||
expect(inset).toBe(padX);
|
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 }) => {
|
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
|
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");
|
const res = await page.goto("/no-such-page");
|
||||||
|
|||||||
@@ -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.
|
// leaves the entry once it fits, so a function that shrank can't grow back.
|
||||||
const grandfathered: Record<string, { lines?: number; complexity?: number }> = {
|
const grandfathered: Record<string, { lines?: number; complexity?: number }> = {
|
||||||
"src/http/app.ts createApp": { lines: 318, complexity: 28 },
|
"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/flow-view.ts buildFlowView": { complexity: 24 },
|
||||||
"src/auth/hydra-admin.ts createHydraAdmin": { lines: 87 },
|
"src/auth/hydra-admin.ts createHydraAdmin": { lines: 87 },
|
||||||
"src/auth/kratos-admin.ts createKratosAdmin": { lines: 68 },
|
"src/auth/kratos-admin.ts createKratosAdmin": { lines: 68 },
|
||||||
|
|||||||
+52
-22
@@ -7,6 +7,7 @@ import { tmpdir } from "node:os";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { after, before, test, type TestContext } from "node:test";
|
import { after, before, test, type TestContext } from "node:test";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import ejs from "ejs";
|
||||||
import { createApp, type AppOptions } from "./app.ts";
|
import { createApp, type AppOptions } from "./app.ts";
|
||||||
import { readFormBody } from "./body.ts";
|
import { readFormBody } from "./body.ts";
|
||||||
import { createLogger } from "../logger.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 { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "../auth/kratos-public.ts";
|
||||||
import { SESSION_COOKIE } from "../auth/login.ts";
|
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||||
import type { Plugin } from "../plugin-host/plugin.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 adminManifest from "../../examples/plugins/admin/plugin.ts";
|
||||||
import { createI18n } from "../i18n/runtime.ts";
|
import { createI18n } from "../i18n/runtime.ts";
|
||||||
import type { MenuConfig } from "../ui/menu-config.ts";
|
import type { MenuConfig } from "../ui/menu-config.ts";
|
||||||
import { loadI18n } from "../i18n/load.ts";
|
import { loadI18n } from "../i18n/load.ts";
|
||||||
|
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
|
||||||
|
|
||||||
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
|
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
|
||||||
// The HTTP-level admin tests mount the example plugin via createApp — stub Ory clients on
|
// 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+/);
|
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 NO_STORE = "private, no-store";
|
||||||
const csrf = (await fetch(base + "/")).headers.get("set-cookie")?.split(";")[0] ?? "";
|
const csrf = (await fetch(base + "/")).headers.get("set-cookie")?.split(";")[0] ?? "";
|
||||||
assert.match(csrf, new RegExp(`^${CSRF_COOKIE}=`));
|
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", { 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 + "/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 + "/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 = {
|
const demo: Plugin = {
|
||||||
apiVersion: "1.0.0",
|
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: "<p>styled</p>", 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<string>();
|
||||||
|
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.
|
// A test plugin exercising each RouteResult shape, a path param, and the permission gate.
|
||||||
const demoPlugin: Plugin = {
|
const demoPlugin: Plugin = {
|
||||||
apiVersion: "1.0.0",
|
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);
|
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/<id>, no file
|
|
||||||
assert.deepEqual(routePublic("css/styles.css", "/core", "/plugins", ids), { dir: "/core", subPath: "css/styles.css" }); // not a plugin → core
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- language (i18n) ----
|
// ---- language (i18n) ----
|
||||||
|
|
||||||
// The installed catalogs, as server.ts wires them: the shipped core locales (en-US + sv-SE).
|
// The installed catalogs, as server.ts wires them: the shipped core locales (en-US + sv-SE).
|
||||||
|
|||||||
+8
-8
@@ -32,7 +32,7 @@ import { allowedMethods, matchRoute } from "../plugin-host/router.ts";
|
|||||||
import { buildAuthRoutes } from "../auth/routes.ts";
|
import { buildAuthRoutes } from "../auth/routes.ts";
|
||||||
import { securityHeaders } from "./security-headers.ts";
|
import { securityHeaders } from "./security-headers.ts";
|
||||||
import { localPath } from "./safe-url.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";
|
import { renderPluginView } from "../plugin-host/view-resolver.ts";
|
||||||
|
|
||||||
const NO_STORE = "private, no-store";
|
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 anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
|
||||||
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
|
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
|
||||||
const publicDir = options.publicDir ?? join(rootDir, "public");
|
const publicDir = options.publicDir ?? join(rootDir, "public");
|
||||||
|
const assets = publicAssets(publicDir, pluginsDir, pluginIds);
|
||||||
const viewsDir = options.viewsDir ?? join(rootDir, "views");
|
const viewsDir = options.viewsDir ?? join(rootDir, "views");
|
||||||
// Response security headers, fixed at boot (only HSTS depends on the https deployment signal).
|
// Response security headers, fixed at boot (only HSTS depends on the https deployment signal).
|
||||||
const secHeaderEntries = Object.entries(securityHeaders({ secure: secureCookies }));
|
const secHeaderEntries = Object.entries(securityHeaders({ secure: secureCookies }));
|
||||||
@@ -131,10 +132,10 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
t: ctx.t,
|
t: ctx.t,
|
||||||
url: ctx.url,
|
url: ctx.url,
|
||||||
});
|
});
|
||||||
// i18n locals go last: their names are reserved, so a handler's colliding key loses instead of
|
// i18n locals and `assetHref` (static.ts) go last: their names are reserved, so a handler's
|
||||||
// breaking the shell around it.
|
// colliding key loses instead of breaking the shell around it.
|
||||||
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)) });
|
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)) });
|
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 => {
|
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
|
||||||
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
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<void> => {
|
const handleRequest = async (req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<void> => {
|
||||||
// Error pages can render before this request has a context at all (a throw on the way to one),
|
// 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.
|
// 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 {
|
try {
|
||||||
const method = req.method ?? "GET";
|
const method = req.method ?? "GET";
|
||||||
const url = new URL(req.url ?? "/", "http://localhost");
|
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.
|
// Before auth: assets don't need a verified user, and the JWT cookie rides every request.
|
||||||
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
|
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
|
||||||
const { dir, subPath } = routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds);
|
await assets.serve(url, req, res, (err) => reqLog.error("static stream error", { error: String(err) }));
|
||||||
await serveStatic(dir, subPath, res, method === "HEAD", (err) => reqLog.error("static stream error", { error: String(err) }));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<void>((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/<id>, 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,<svg/>", "/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);
|
||||||
|
});
|
||||||
+81
-11
@@ -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 { 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";
|
import { extname, isAbsolute, join, relative } from "node:path";
|
||||||
|
|
||||||
const contentTypes: Record<string, string> = {
|
const contentTypes: Record<string, string> = {
|
||||||
@@ -38,8 +39,8 @@ export interface StaticRoute {
|
|||||||
|
|
||||||
// Route a `/public/<rest>` request to a base dir + sub-path: a leading segment naming a discovered
|
// Route a `/public/<rest>` request to a base dir + sub-path: a leading segment naming a discovered
|
||||||
// plugin serves from plugins/<id>/public/, anything else from the core public/. Plugin ids are
|
// plugin serves from plugins/<id>/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
|
// URL-safe (no %-encoding), so the raw segment compares directly to the id set; the sub-path is
|
||||||
// and traversal-guards the sub-path as before.
|
// decoded and traversal-guarded after.
|
||||||
export function routePublic(restPath: string, publicDir: string, pluginsDir: string, pluginIds: Set<string>): StaticRoute {
|
export function routePublic(restPath: string, publicDir: string, pluginsDir: string, pluginIds: Set<string>): StaticRoute {
|
||||||
const slash = restPath.indexOf("/");
|
const slash = restPath.indexOf("/");
|
||||||
const first = slash === -1 ? restPath : restPath.slice(0, slash);
|
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 };
|
return { dir: publicDir, subPath: restPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Caching: the host renders every /public/ href through assetHref, which appends `?v=<hash of the
|
||||||
|
// file's content>`. 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<string, { hash: string; mtimeMs: number; size: number }>();
|
||||||
|
|
||||||
|
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 {
|
function plain(res: ServerResponse, status: number, body: string): void {
|
||||||
res.writeHead(status, { "content-type": "text/plain; charset=utf-8" }).end(body);
|
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
|
interface StaticRequest extends StaticRoute {
|
||||||
// static.ts stays standalone, while app.ts passes the request logger for structured output.
|
version: string | null; // the request's `v` query parameter
|
||||||
export async function serveStatic(dir: string, requestedPath: string, res: ServerResponse, head = false, onError: (err: Error) => void = (err) => console.error(err)): Promise<void> {
|
}
|
||||||
|
|
||||||
|
async function serveStatic(route: StaticRequest, req: IncomingMessage, res: ServerResponse, onError: (err: Error) => void): Promise<void> {
|
||||||
let decoded: string;
|
let decoded: string;
|
||||||
try {
|
try {
|
||||||
decoded = decodeURIComponent(requestedPath);
|
decoded = decodeURIComponent(route.subPath);
|
||||||
} catch {
|
} catch {
|
||||||
return plain(res, 400, "Bad Request");
|
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");
|
if (filePath === null) return plain(res, 403, "Forbidden");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const info = await stat(filePath);
|
const info = await stat(filePath);
|
||||||
if (!info.isFile()) return plain(res, 404, "Not Found");
|
if (!info.isFile()) return plain(res, 404, "Not Found");
|
||||||
res.writeHead(200, { "content-length": info.size, "content-type": contentTypeFor(filePath) });
|
const hash = contentHash(filePath, info);
|
||||||
if (head) return void res.end(); // headers only — skip opening the file
|
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 —
|
// 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.
|
// log and destroy the response to signal a truncated body, not a hung socket.
|
||||||
createReadStream(filePath)
|
createReadStream(filePath)
|
||||||
@@ -83,3 +138,18 @@ export async function serveStatic(dir: string, requestedPath: string, res: Serve
|
|||||||
plain(res, 404, "Not Found");
|
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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The /public/ namespace both ways: the hrefs views render, and the requests those hrefs make.
|
||||||
|
export function publicAssets(publicDir: string, pluginsDir: string, pluginIds: Set<string>): 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import ejs from "ejs";
|
|||||||
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
|
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.
|
// 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 shell = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "shell.ejs");
|
||||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(shell, { ...ENGLISH_LOCALS, ...data });
|
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(shell, { ...ENGLISH_LOCALS, assetHref: (href: string) => href, ...data });
|
||||||
|
|
||||||
test("app shell renders sidebar, topbar and the content slot", async () => {
|
test("app shell renders sidebar, topbar and the content slot", async () => {
|
||||||
const html = await render({
|
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);
|
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 () => {
|
test("app shell can disable the menu: no sidebar, focused single-column layout", async () => {
|
||||||
const bare = await render({ menu: false, title: "Focus", body: '<section id="b">x</section>', nav: '<a href="/x">Overview</a>' });
|
const bare = await render({ menu: false, title: "Focus", body: '<section id="b">x</section>', nav: '<a href="/x">Overview</a>' });
|
||||||
assert.doesNotMatch(bare, /<aside class="sidebar"/); // sidebar dropped
|
assert.doesNotMatch(bare, /<aside class="sidebar"/); // sidebar dropped
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
|
|
||||||
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
| 34 | 0.4.21 | | | **Serve every stylesheet and script under a content-versioned URL with a year-long immutable `Cache-Control`, so a page view downloads none of them again.** | 4 | 2 | 3 | 7 | 6 | 8 by 6, 2 by 2 | 1.8 |
|
|
||||||
| 3 | 0.4.21 | | | **Guard the group paths against self-lockout, or accept the gap explicitly.** | 3 | 2 | 2 | 5 | 6 | 2 by 6 | 1.4 |
|
| 3 | 0.4.21 | | | **Guard the group paths against self-lockout, or accept the gap explicitly.** | 3 | 2 | 2 | 5 | 6 | 2 by 6 | 1.4 |
|
||||||
| 40 | 0.4.21 | | | **Show a `users:read` holder "View user" as the user page's heading and breadcrumb, not "Edit user".** | 1 | 2 | 1 | 2 | 2 | 2 by 1 | 1.4 |
|
| 40 | 0.4.21 | | | **Show a `users:read` holder "View user" as the user page's heading and breadcrumb, not "Edit user".** | 1 | 2 | 1 | 2 | 2 | 2 by 1 | 1.4 |
|
||||||
| 1 | 0.4.21 | | owner | **Publish every release to Docker Hub, and give the Hub page its short description.** | 3 | 3 | 2 | 6 | 5 | 1 by 3 | 1.3 |
|
| 1 | 0.4.21 | | owner | **Publish every release to Docker Hub, and give the Hub page its short description.** | 3 | 3 | 2 | 6 | 5 | 1 by 3 | 1.3 |
|
||||||
@@ -30,36 +29,6 @@
|
|||||||
|
|
||||||
## Details
|
## Details
|
||||||
|
|
||||||
### 34. Serve every stylesheet and script under a content-versioned URL with a year-long immutable `Cache-Control`, so a page view downloads none of them again.
|
|
||||||
|
|
||||||
`serveStatic` (`src/http/static.ts`) sends only `content-length` and `content-type`, so every page
|
|
||||||
view downloads `styles.css`, `auth.css` and each plugin's stylesheet again: about 50 kB on a plugin's
|
|
||||||
issue page, seconds per page over GPRS. Firefox can paint the page unstyled meanwhile, when an
|
|
||||||
extension forces layout early.
|
|
||||||
|
|
||||||
- A versioned URL carries a hash of the file's content, as `?v=<hash>` or in the path, and answers
|
|
||||||
`Cache-Control: public, max-age=31536000, immutable`. A release changes the hash, so it still
|
|
||||||
reaches every browser at once.
|
|
||||||
- An unversioned `/public/` URL answers a short `max-age` with revalidation: `ETag` or
|
|
||||||
`Last-Modified`, and 304 on `If-None-Match` or `If-Modified-Since`, for GET and HEAD.
|
|
||||||
- Every `/public/` href the host renders comes out versioned: the shell's `<link>`s and favicon,
|
|
||||||
the error pages (`views/403.ejs`, `404`, `500`, `503`, `error`), `views/oauth-consent.ejs`, and
|
|
||||||
the `styles` hrefs a plugin passes to `views/partials/shell.ejs`.
|
|
||||||
- **Recommended: the host versions a plugin's hrefs itself.** A plugin names its asset once, as
|
|
||||||
today, and the shell adds the version to any `/public/` href. A helper would make every plugin
|
|
||||||
author remember to call it, and adds to the barrel.
|
|
||||||
- **Contract:** `views/partials/*.ejs` are contract surface (AGENTS.md → Rules). Host-side
|
|
||||||
versioning keeps the `styles` option and its meaning, and an unversioned href keeps working, so
|
|
||||||
no minor bump of `HOST_API_VERSION` is needed and 0.4.21 holds. A helper exported through
|
|
||||||
`@plainpages/plugin-api` would add to the contract and need 0.5.0.
|
|
||||||
- Pages stay `private, no-store` (`src/http/app.ts`); the static branch returns before that header
|
|
||||||
is set.
|
|
||||||
|
|
||||||
Tests first, with expected values from this item: `src/http` unit tests for both header sets and the
|
|
||||||
304s, and an E2E test that a repeat page view fetches no stylesheet again. State the choice once in a
|
|
||||||
comment in `static.ts`, and in a sentence where the README tells plugin authors and operators about
|
|
||||||
assets. Requested by plainpages-plugin-fastjira, whose own item waits on the release that ships this.
|
|
||||||
|
|
||||||
### 3. Guard the group paths against self-lockout, or accept the gap explicitly.
|
### 3. Guard the group paths against self-lockout, or accept the gap explicitly.
|
||||||
|
|
||||||
The self-revoke guard covers only your own direct grants on the Users screen. Unticking a permission
|
The self-revoke guard covers only your own direct grants on the Users screen. Unticking a permission
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title><%= t("error.403.docTitle") %></title>
|
<title><%= t("error.403.docTitle") %></title>
|
||||||
<link rel="stylesheet" href="/public/css/styles.css" />
|
<link rel="stylesheet" href="<%= assetHref("/public/css/styles.css") %>" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title><%= t("error.404.docTitle") %></title>
|
<title><%= t("error.404.docTitle") %></title>
|
||||||
<link rel="stylesheet" href="/public/css/styles.css" />
|
<link rel="stylesheet" href="<%= assetHref("/public/css/styles.css") %>" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title><%= t("error.500.docTitle") %></title>
|
<title><%= t("error.500.docTitle") %></title>
|
||||||
<link rel="stylesheet" href="/public/css/styles.css" />
|
<link rel="stylesheet" href="<%= assetHref("/public/css/styles.css") %>" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title><%= t("error.503.docTitle") %></title>
|
<title><%= t("error.503.docTitle") %></title>
|
||||||
<link rel="stylesheet" href="/public/css/styles.css" />
|
<link rel="stylesheet" href="<%= assetHref("/public/css/styles.css") %>" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title><%= t("error.flow.docTitle") %></title>
|
<title><%= t("error.flow.docTitle") %></title>
|
||||||
<link rel="stylesheet" href="/public/css/styles.css" />
|
<link rel="stylesheet" href="<%= assetHref("/public/css/styles.css") %>" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
|
|||||||
@@ -11,9 +11,9 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title><%= t("consent.title", { client: consent.client }) %></title>
|
<title><%= t("consent.title", { client: consent.client }) %></title>
|
||||||
<link rel="stylesheet" href="/public/css/styles.css" />
|
<link rel="stylesheet" href="<%= assetHref("/public/css/styles.css") %>" />
|
||||||
<link rel="stylesheet" href="/public/css/auth.css" />
|
<link rel="stylesheet" href="<%= assetHref("/public/css/auth.css") %>" />
|
||||||
<link rel="icon" href="/public/favicon.svg" />
|
<link rel="icon" href="<%= assetHref("/public/favicon.svg") %>" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<%- include("partials/icons") %>
|
<%- include("partials/icons") %>
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
(theme-switch default), `user`, `breadcrumbs`, `csrfToken` (the Sign-out form's hidden field),
|
(theme-switch default), `user`, `breadcrumbs`, `csrfToken` (the Sign-out form's hidden field),
|
||||||
`signInHref` (anonymous "Sign in" target; default /login). `menu` (default true) — set false to
|
`signInHref` (anonymous "Sign in" target; default /login). `menu` (default true) — set false to
|
||||||
drop the sidebar and render a focused single-column page. `t`, `locale`, `dir` and `localeSwitch`
|
drop the sidebar and render a focused single-column page. `t`, `locale`, `dir` and `localeSwitch`
|
||||||
come from the host with every render (README → Languages).
|
come from the host with every render (README → Languages), as does `assetHref`, which versions a
|
||||||
|
/public/ href so the browser caches it (src/http/static.ts).
|
||||||
%><%
|
%><%
|
||||||
const brand = locals.brand || { name: "Plainpages" };
|
const brand = locals.brand || { name: "Plainpages" };
|
||||||
const title = locals.title || ""; // topbar heading; empty ⇒ no topbar <h1> (the body owns it)
|
const title = locals.title || ""; // topbar heading; empty ⇒ no topbar <h1> (the body owns it)
|
||||||
@@ -29,9 +30,9 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title><%= docTitle %></title>
|
<title><%= docTitle %></title>
|
||||||
<link rel="stylesheet" href="/public/css/styles.css" />
|
<link rel="stylesheet" href="<%= assetHref("/public/css/styles.css") %>" />
|
||||||
<% styles.forEach((href) => { %><link rel="stylesheet" href="<%= href %>" />
|
<% styles.forEach((href) => { %><link rel="stylesheet" href="<%= assetHref(href) %>" />
|
||||||
<% }) %><link rel="icon" href="/public/favicon.svg" />
|
<% }) %><link rel="icon" href="<%= assetHref("/public/favicon.svg") %>" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<a class="skip-link" href="#main-content"><%= t("shell.skipToContent") %></a>
|
<a class="skip-link" href="#main-content"><%= t("shell.skipToContent") %></a>
|
||||||
@@ -45,7 +46,7 @@
|
|||||||
<% if (menu) { %>
|
<% if (menu) { %>
|
||||||
<aside class="sidebar" aria-label="<%= t("shell.sidebar") %>">
|
<aside class="sidebar" aria-label="<%= t("shell.sidebar") %>">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<% if (brand.logo) { %><img class="brand-logo" src="<%= brand.logo %>" alt="" /><% } else { %><span class="brand-mark"><svg class="ico ico-sm"><use href="#i-box" /></svg></span><% } %>
|
<% if (brand.logo) { %><img class="brand-logo" src="<%= assetHref(brand.logo) %>" alt="" /><% } else { %><span class="brand-mark"><svg class="ico ico-sm"><use href="#i-box" /></svg></span><% } %>
|
||||||
<span class="brand-name"><%= brand.name %></span>
|
<span class="brand-name"><%= brand.name %></span>
|
||||||
<% if (brand.sub) { %><span class="brand-sub"><%= brand.sub %></span><% } %>
|
<% if (brand.sub) { %><span class="brand-sub"><%= brand.sub %></span><% } %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user