diff --git a/deploy/Caddyfile.snippet b/deploy/Caddyfile.snippet index a294138..ecf0bfd 100644 --- a/deploy/Caddyfile.snippet +++ b/deploy/Caddyfile.snippet @@ -23,9 +23,10 @@ lichen.wiki { header Cache-Control "public, max-age=31536000, immutable" } - # HTML microcache + # No blanket Cache-Control: the app renders per visitor and sets its own + # policy per response. Caddy appends rather than replaces, so a header here + # would mark private pages publicly cacheable. handle { - header Cache-Control "public, max-age=2" reverse_proxy localhost:3000 } @@ -57,6 +58,7 @@ staging.lichen.wiki { header Cache-Control "public, max-age=31536000, immutable" } + # Staging is never cached anywhere, whatever the app says. handle { header Cache-Control "no-store" reverse_proxy localhost:3001 diff --git a/src/lib/response.ts b/src/lib/response.ts index 8dff926..9a9628a 100644 --- a/src/lib/response.ts +++ b/src/lib/response.ts @@ -42,10 +42,18 @@ export function htmlResponse( "X-Content-Type-Options": "nosniff", "Referrer-Policy": "strict-origin-when-cross-origin", }; + // Every response states its own policy: an HTML page that says nothing is + // left to whatever a proxy in front of us appends, and access control here + // is per-request, so the page one visitor gets is not the page for the next. if (opts.edgeCacheSeconds && opts.edgeCacheSeconds > 0) { headers["Cache-Control"] = `public, s-maxage=${opts.edgeCacheSeconds}, stale-while-revalidate=${opts.edgeCacheSeconds * 5}`; + // Honoured by standards-compliant shared caches, but Cloudflare varies on + // Accept-Encoding alone — so an HTML "Cache Everything" rule would need + // locale and theme folded into the cache key before it could be safe. headers["Vary"] = "Cookie, Accept-Language"; + } else { + headers["Cache-Control"] = "private, no-store"; } return new Response(body, { status: opts.status ?? 200, headers }); } diff --git a/src/server/app.ts b/src/server/app.ts index 0c3f255..895f046 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -33,6 +33,10 @@ if (getAtprotoEnv() !== null && process.env["DEV_ACCOUNTS"]) { ); } +// An error page reflects who asked and what they may see; nothing in front of +// us may store one for the next visitor. +const PRIVATE_CACHE = "private, no-store"; + export function buildApp() { getDb(); pruneExpiredAppSessions(); @@ -52,7 +56,10 @@ export function buildApp() { const body = vars ? fmt(template, vars) : template; return new Response(notFoundPage(body, { locale }), { status: 404, - headers: { "content-type": "text/html; charset=utf-8" }, + headers: { + "content-type": "text/html; charset=utf-8", + "cache-control": PRIVATE_CACHE, + }, }); }; @@ -71,16 +78,25 @@ export function buildApp() { ); return new Response(removedPage({ locale }), { status: 410, - headers: { "content-type": "text/html; charset=utf-8" }, + headers: { + "content-type": "text/html; charset=utf-8", + "cache-control": PRIVATE_CACHE, + }, }); } - return new Response(error.message, { status: error.statusCode }); + return new Response(error.message, { + status: error.statusCode, + headers: { "cache-control": PRIVATE_CACHE }, + }); } if (code === "NOT_FOUND") { return renderNotFound("routeNotFound", "Not found"); } console.error("Unhandled error:", error); - return new Response("Internal server error", { status: 500 }); + return new Response("Internal server error", { + status: 500, + headers: { "cache-control": PRIVATE_CACHE }, + }); }) .use(staticPlugin({ prefix: "/public", assets: "public" })) .use(canonicalHandlePlugin) diff --git a/tests/integration/cache-headers.test.ts b/tests/integration/cache-headers.test.ts new file mode 100644 index 0000000..cef66e9 --- /dev/null +++ b/tests/integration/cache-headers.test.ts @@ -0,0 +1,63 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { cleanupWikiAndDependents } from "../helpers/cleanup.ts"; +import { + ALICE, + createDiff, + emitNote, + emitRevision, + emitWiki, +} from "./helpers.ts"; +import { fetch, loginCookie } from "./http-helpers.ts"; + +const PUBLIC_SLUG = "cache-public"; +const PRIVATE_SLUG = "cache-private"; + +const publicWiki = emitWiki(ALICE.did, "Cache Public", "public", PUBLIC_SLUG); +const privateWiki = emitWiki( + ALICE.did, + "Cache Private", + "private", + PRIVATE_SLUG, +); + +for (const wiki of [publicWiki, privateWiki]) { + const note = emitNote(ALICE.did, "page", "Page", wiki.uri); + emitRevision(ALICE.did, note.uri, createDiff("", "Body text.")); +} + +afterAll(() => { + cleanupWikiAndDependents(PUBLIC_SLUG); + cleanupWikiAndDependents(PRIVATE_SLUG); +}); + +describe("cache headers on rendered pages", () => { + test("an anonymous read of a public note is edge-cacheable", async () => { + const res = await fetch("GET", `/@${ALICE.handle}/${PUBLIC_SLUG}/page`); + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toContain("s-maxage"); + }); + + test("the same note read with a session is not", async () => { + const cookie = await loginCookie(ALICE.handle); + const res = await fetch("GET", `/@${ALICE.handle}/${PUBLIC_SLUG}/page`, { + cookie, + }); + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + }); + + test("a private wiki's note is never publicly cacheable", async () => { + const cookie = await loginCookie(ALICE.handle); + const res = await fetch("GET", `/@${ALICE.handle}/${PRIVATE_SLUG}/page`, { + cookie, + }); + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + }); + + test("an access-denied page is not publicly cacheable", async () => { + const res = await fetch("GET", `/@${ALICE.handle}/${PRIVATE_SLUG}/page`); + expect(res.status).toBe(403); + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + }); +}); diff --git a/tests/lib/response.test.ts b/tests/lib/response.test.ts new file mode 100644 index 0000000..d57eb51 --- /dev/null +++ b/tests/lib/response.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import { htmlResponse } from "../../src/lib/response.ts"; + +describe("htmlResponse cache policy", () => { + test("declares a private policy when no edge cache is requested", () => { + const res = htmlResponse("

hi

"); + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + expect(res.headers.get("Vary")).toBeNull(); + }); + + test("declares a private policy for a bare status argument", () => { + const res = htmlResponse("

nope

", 403); + expect(res.status).toBe(403); + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + }); + + test("edgeCacheSeconds 0 is private, not silent", () => { + const res = htmlResponse("

hi

", { edgeCacheSeconds: 0 }); + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + }); + + test("a positive edge cache is public and varies on cookie", () => { + const res = htmlResponse("

hi

", { edgeCacheSeconds: 60 }); + const cc = res.headers.get("Cache-Control") ?? ""; + expect(cc).toContain("public"); + expect(cc).toContain("s-maxage=60"); + expect(cc).not.toContain("no-store"); + expect(res.headers.get("Vary")).toContain("Cookie"); + }); +});