Serve every /public/ asset under a content-versioned URL with an immutable Cache-Control #193

Merged
lilleman merged 7 commits from item-34 into main 2026-10-31 10:32:20 +01:00
18 changed files with 340 additions and 88 deletions
Showing only changes of commit 3d757d4726 - Show all commits
+9
View File
@@ -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
+3
View File
@@ -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
+17
View File
@@ -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
+18
View File
@@ -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");
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
} }
+129
View File
@@ -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
View File
@@ -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),
};
}
+8 -2
View File
@@ -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
-31
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+3 -3
View File
@@ -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") %>
+6 -5
View File
@@ -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>