diff --git a/.changeset/cache-social-previews.md b/.changeset/cache-social-previews.md new file mode 100644 index 0000000..ffe26b9 --- /dev/null +++ b/.changeset/cache-social-previews.md @@ -0,0 +1,5 @@ +--- +"sideshow": patch +--- + +Cache fully pinned social-preview screenshots at the Cloudflare edge so repeated link unfurls avoid redundant Browser Rendering calls. Access, post existence, and revision are revalidated on every edge request, and token-protected boards keep private client cache headers. diff --git a/server/app.ts b/server/app.ts index 0067a83..46196f2 100644 --- a/server/app.ts +++ b/server/app.ts @@ -918,11 +918,25 @@ export function createApp({ return injectHead(text, ``); }; - const postPreviewHead = (post: Post, request: Request) => { + const postPreviewHead = ( + post: Post, + request: Request, + themeId: string, + rendererGeneration: string, + ) => { const origin = new URL(request.url).origin; const publicBasePath = requestBasePath(request); const canonical = `${origin}${publicBasePath}/p/${post.id}`; - const image = `${origin}${publicBasePath}/p/${post.id}.png?card=1`; + // Pin every pixel-affecting input in the advertised URL: post revision, + // workspace theme, deterministic color mode, and app/renderer generation. + // The Worker validates these before admitting the image to edge cache. + const imageUrl = new URL(`${origin}${publicBasePath}/p/${post.id}.png`); + imageUrl.searchParams.set("card", "1"); + imageUrl.searchParams.set("theme", themeId); + imageUrl.searchParams.set("mode", "dark"); + imageUrl.searchParams.set("v", String(post.version)); + imageUrl.searchParams.set("g", rendererGeneration); + const image = imageUrl.toString(); const title = escapeHtml(post.title); const description = "A https://sideshow.sh surface"; return [ @@ -941,7 +955,10 @@ export function createApp({ ].join("\n"); }; - const configuredViewerHtml = (c: Context, opts: { post?: Post; title?: string | null } = {}) => { + const configuredViewerHtml = async ( + c: Context, + opts: { post?: Post; title?: string | null } = {}, + ) => { // The viewer HTML is the trusted app origin — it shares that origin with the // authenticated API and the comment→agent channel, so a cross-origin page // that frames it could clickjack actions or the prompt-injection channel. @@ -960,16 +977,20 @@ export function createApp({ ), pageTitle, ); - return opts.post ? injectHead(html, postPreviewHead(opts.post, c.req.raw)) : html; + if (!opts.post) return html; + const themeId = (await store.getSetting("theme")) ?? DEFAULT_THEME_ID; + return injectHead(html, postPreviewHead(opts.post, c.req.raw, themeId, version ?? "dev")); }; - app.get("/", (c) => c.html(configuredViewerHtml(c))); - app.get("/connect", (c) => c.html(configuredViewerHtml(c, { title: "Connect an agent" }))); + app.get("/", async (c) => c.html(await configuredViewerHtml(c))); + app.get("/connect", async (c) => + c.html(await configuredViewerHtml(c, { title: "Connect an agent" })), + ); app.get("/session/:id", async (c) => { const session = await store.getSession(c.req.param("id")); if (isUnauthenticatedSessionRead(c) && !session) { return c.text("Session not found", 404); } - return c.html(configuredViewerHtml(c, { title: sessionDocumentTitle(session) })); + return c.html(await configuredViewerHtml(c, { title: sessionDocumentTitle(session) })); }); const sessionPostPage = async (c: any) => { const session = await store.getSession(c.req.param("id")); @@ -980,7 +1001,7 @@ export function createApp({ return c.text("Session or post not found", 404); } } - return c.html(configuredViewerHtml(c, { title: sessionDocumentTitle(session) })); + return c.html(await configuredViewerHtml(c, { title: sessionDocumentTitle(session) })); }; app.get("/session/:id/s/:surfaceId", sessionPostPage); // legacy alias app.get("/session/:id/p/:postId", sessionPostPage); @@ -1526,7 +1547,7 @@ export function createApp({ if (!post) return c.text("Post not found", 404); // `part` is the legacy query key; `surface` is canonical. const surfaceParam = c.req.query("surface") ?? c.req.query("part"); - if (surfaceParam == null) return c.html(configuredViewerHtml(c, { post })); + if (surfaceParam == null) return c.html(await configuredViewerHtml(c, { post })); const ver = c.req.query("ver"); let title = post.title; diff --git a/test/api.test.ts b/test/api.test.ts index 7df49d9..26aa3bd 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -14,6 +14,7 @@ function makeApp( basePath?: string; viewerHtml?: string; screenshots?: boolean; + version?: string; maxHoldConnections?: number; onEvent?: Parameters[0]["onEvent"]; store?: Store; @@ -359,8 +360,8 @@ test("GET /session/:id serves the viewer shell with the session title", async () assert.match(body, /Auth refactor · sideshow<\/title>/); }); -test("GET /s/:id emits absolute token-free canonical and preview image URLs", async () => { - const app = makeApp("secret"); +test("GET /s/:id emits fully pinned, absolute, token-free preview image URLs", async () => { + const app = makeApp("secret", { version: "1.2.3" }); const res = await app.request( "https://board.test/api/snippets", authedJson({ title: "Preview", html: "<p>x</p>" }), @@ -369,7 +370,7 @@ test("GET /s/:id emits absolute token-free canonical and preview image URLs", as const body = await (await app.request(`https://board.test/s/${surface.id}?key=secret`)).text(); const canonical = `https://board.test/p/${surface.id}`; - const image = `https://board.test/p/${surface.id}.png?card=1`; + const image = `https://board.test/p/${surface.id}.png?card=1&theme=github&mode=dark&v=${surface.version}&g=1.2.3`; assert.match(body, new RegExp(`<link rel="canonical" href="${canonical}">`)); assert.match(body, new RegExp(`<meta property="og:url" content="${canonical}">`)); assert.match( @@ -430,12 +431,26 @@ test("GET /s/:id preview metadata respects configured base path", async () => { assert.match( body, new RegExp( - `<meta property="og:image" content="https://board.test/u/alice/p/${surface.id}\\.png\\?card=1">`, + `<meta property="og:image" content="https://board.test/u/alice/p/${surface.id}\\.png\\?card=1&theme=github&mode=dark&v=${surface.version}&g=dev">`, ), ); assert.match(body, /window\.__SIDESHOW_BASE_PATH__="\/u\/alice"/); }); +test("post preview image URL changes with the workspace theme", async () => { + const app = makeApp(undefined, { version: "1.2.3" }); + const res = await app.request("/api/snippets", json({ title: "Themed", html: "<p>x</p>" })); + const post = (await res.json()) as any; + + const before = await (await app.request(`/p/${post.id}`)).text(); + assert.match(before, /theme=github/); + const update = await app.request("/api/theme", { ...json({ id: "gruvbox" }), method: "PUT" }); + assert.equal(update.status, 200); + const after = await (await app.request(`/p/${post.id}`)).text(); + assert.match(after, /theme=gruvbox/); + assert.doesNotMatch(after, /theme=github/); +}); + test("/s served versioned + themed is cacheable; an unpinned load is not", async () => { const app = makeApp(); const res = await app.request( diff --git a/test/workerScreenshot.test.ts b/test/workerScreenshot.test.ts index 69c6848..5e9f511 100644 --- a/test/workerScreenshot.test.ts +++ b/test/workerScreenshot.test.ts @@ -14,15 +14,17 @@ test("post screenshot route matches GET and HEAD requests without baking in an i test("card screenshots use stable social-card dimensions without fullPage", () => { const plan = planPostScreenshot( - new URL("https://workspace.test/p/abc123.png?card=1&w=640&theme=gruvbox&mode=dark&key=secret"), + new URL( + "https://workspace.test/p/abc123.png?card=1&w=640&theme=gruvbox&mode=dark&v=7&key=secret", + ), "abc123", "sideshow_mode=light", ); assert.deepEqual(plan.viewport, { width: 1200, height: 630 }); assert.deepEqual(plan.screenshotOptions, { fullPage: false }); - assert.equal(plan.target, "https://workspace.test/p/abc123?part=0&theme=gruvbox&mode=dark"); - assert.doesNotMatch(plan.target, /key=secret|card=1|w=640/); + assert.equal(plan.target, "https://workspace.test/p/abc123?part=0&ver=7&theme=gruvbox&mode=dark"); + assert.doesNotMatch(plan.target, /key=secret|card=1|w=640|(?:^|[?&])v=/); }); test("non-card screenshots preserve full-page behavior and configurable width", () => { diff --git a/test/workerScreenshotCache.test.ts b/test/workerScreenshotCache.test.ts new file mode 100644 index 0000000..aaa19bd --- /dev/null +++ b/test/workerScreenshotCache.test.ts @@ -0,0 +1,284 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { planPostScreenshot } from "../workers/screenshot.ts"; +import { + postScreenshotCacheKey, + postScreenshotClientCacheControl, + type EdgeCache, + servePostScreenshot, + withPostScreenshotCache, +} from "../workers/screenshotCache.ts"; + +const GENERATION = "1.2.3"; +const cardUrl = (version = "7", extra = "") => + `https://board.test/p/post_1.png?card=1&theme=github&mode=dark&v=${version}&g=${GENERATION}${extra}`; + +function plan(href: string) { + const url = new URL(href); + return { url, plan: planPostScreenshot(url, "post_1", null) }; +} + +function memoryCache() { + const entries = new Map<string, Response>(); + const cache: EdgeCache = { + async match(request) { + return entries.get(request.url)?.clone(); + }, + async put(request, response) { + entries.set(request.url, response.clone()); + }, + }; + return { cache, entries }; +} + +function png(cacheControl = "public, max-age=300", extraHeaders: Record<string, string> = {}) { + return new Response(new Uint8Array([137, 80, 78, 71]), { + headers: { + "content-type": "image/png", + "cache-control": cacheControl, + ...extraHeaders, + }, + }); +} + +test("protected screenshots stay private downstream; public-read cards remain shareable", () => { + assert.equal(postScreenshotClientCacheControl(false, undefined), "private, max-age=300"); + assert.equal(postScreenshotClientCacheControl(false, "unexpected"), "private, max-age=300"); + assert.equal(postScreenshotClientCacheControl(false, "session"), "public, max-age=300"); + assert.equal(postScreenshotClientCacheControl(false, "full"), "public, max-age=300"); + assert.equal(postScreenshotClientCacheControl(true, "full"), "no-store"); +}); + +test("social-card key pins version, theme, mode, and renderer generation", () => { + const { url, plan: screenshot } = plan(cardUrl("7", "&utm_source=preview")); + const key = postScreenshotCacheKey("GET", url, "post_1", screenshot, GENERATION); + assert.equal( + key?.url, + "https://board.test/__cache/post-screenshot/post_1.png?part=0&v=7&theme=github&mode=dark&g=1.2.3", + ); + assert.equal(screenshot.checkUrl.searchParams.get("ver"), "7"); + assert.equal(screenshot.checkUrl.searchParams.get("theme"), "github"); + assert.equal(screenshot.checkUrl.searchParams.get("mode"), "dark"); + assert.doesNotMatch(key!.url, /utm/); +}); + +test("only canonical, fully pinned metadata cards are cacheable", () => { + for (const [label, href, method, generation] of [ + ["unversioned", cardUrl("").replace("&v=&", "&"), "GET", GENERATION], + ["non-card", cardUrl().replace("card=1&", ""), "GET", GENERATION], + ["HEAD", cardUrl(), "HEAD", GENERATION], + ["nocache", cardUrl("1", "&nocache"), "GET", GENERATION], + ["missing theme", cardUrl().replace("theme=github&", ""), "GET", GENERATION], + ["unknown theme", cardUrl().replace("theme=github", "theme=made-up"), "GET", GENERATION], + ["missing mode", cardUrl().replace("mode=dark&", ""), "GET", GENERATION], + ["OS mode", cardUrl().replace("mode=dark", "mode=os"), "GET", GENERATION], + ["missing generation", cardUrl().replace(`&g=${GENERATION}`, ""), "GET", GENERATION], + ["stale generation", cardUrl(), "GET", "2.0.0"], + ["leading zero", cardUrl("01"), "GET", GENERATION], + ["unsafe integer", cardUrl(String(Number.MAX_SAFE_INTEGER + 1)), "GET", GENERATION], + ] as const) { + const { url, plan: screenshot } = plan(href); + assert.equal( + postScreenshotCacheKey(method, url, "post_1", screenshot, generation), + null, + label, + ); + } + + const { url, plan: screenshot } = plan(cardUrl("2")); + screenshot.checkUrl.searchParams.set("ver", "1"); + assert.equal(postScreenshotCacheKey("GET", url, "post_1", screenshot, GENERATION), null); +}); + +test("a miss stores a one-hour internal copy but restores a private client policy", async () => { + const { url, plan: screenshot } = plan(cardUrl()); + const key = postScreenshotCacheKey("GET", url, "post_1", screenshot, GENERATION)!; + const { cache, entries } = memoryCache(); + const deferred: Promise<unknown>[] = []; + let captures = 0; + + const response = await withPostScreenshotCache( + key, + (promise) => deferred.push(promise), + async () => { + captures++; + return png("private, max-age=300"); + }, + cache, + ); + await Promise.all(deferred); + + assert.equal(captures, 1); + assert.equal(response.headers.get("x-sideshow-screenshot-cache"), "miss"); + assert.equal(response.headers.get("cache-control"), "private, max-age=300"); + const stored = entries.get(key.url)!; + assert.equal(stored.headers.get("cache-control"), "public, max-age=3600"); + assert.equal(stored.headers.get("x-sideshow-origin-cache-control"), "private, max-age=300"); +}); + +test("a valid hit skips capture and restores its client cache policy", async () => { + const { url, plan: screenshot } = plan(cardUrl()); + const key = postScreenshotCacheKey("GET", url, "post_1", screenshot, GENERATION)!; + const { cache } = memoryCache(); + await cache.put( + key, + png("public, max-age=3600", { + "x-sideshow-origin-cache-control": "public, max-age=300", + }), + ); + let captures = 0; + + const response = await withPostScreenshotCache( + key, + () => {}, + async () => { + captures++; + return png(); + }, + cache, + ); + + assert.equal(captures, 0); + assert.equal(response.headers.get("x-sideshow-screenshot-cache"), "hit"); + assert.equal(response.headers.get("cache-control"), "public, max-age=300"); + assert.equal(response.headers.has("x-sideshow-origin-cache-control"), false); +}); + +test("malformed hits and unsafe capture responses never escape into shared cache", async () => { + const { url, plan: screenshot } = plan(cardUrl()); + const key = postScreenshotCacheKey("GET", url, "post_1", screenshot, GENERATION)!; + const { cache } = memoryCache(); + await cache.put( + key, + png("public, max-age=3600", { + "x-sideshow-origin-cache-control": "public, private, max-age=300", + }), + ); + let captures = 0; + const deferred: Promise<unknown>[] = []; + const repaired = await withPostScreenshotCache( + key, + (promise) => deferred.push(promise), + async () => { + captures++; + return png(); + }, + cache, + ); + await Promise.all(deferred); + assert.equal(captures, 1); + assert.equal(repaired.headers.get("x-sideshow-screenshot-cache"), "miss"); + + for (const [label, response] of [ + ["mixed-case no-store", png("public, No-Store")], + ["contradictory", png("private, public")], + ["cookie", png(undefined, { "set-cookie": "x=1" })], + [ + "not PNG", + new Response("text", { + headers: { "content-type": "text/plain", "cache-control": "public, max-age=300" }, + }), + ], + ] as const) { + let puts = 0; + const rejectingCache: EdgeCache = { + async match() { + return undefined; + }, + async put() { + puts++; + }, + }; + await withPostScreenshotCache( + key, + () => {}, + async () => response, + rejectingCache, + ); + assert.equal(puts, 0, label); + } +}); + +test("orchestration revalidates before hits and applies the current access policy", async () => { + const request = () => new Request(cardUrl()); + const { url, plan: screenshot } = plan(cardUrl()); + const { cache } = memoryCache(); + const deferred: Promise<unknown>[] = []; + let authorized = true; + let clientCacheControl = "public, max-age=300"; + let authorizationChecks = 0; + let captures = 0; + + const serve = () => + servePostScreenshot({ + request: request(), + requestUrl: url, + postId: "post_1", + plan: screenshot, + rendererGeneration: GENERATION, + clientCacheControl, + defer: (promise) => deferred.push(promise), + authorize: async () => { + authorizationChecks++; + return authorized ? new Response("renderable") : new Response("not found", { status: 404 }); + }, + capture: async () => { + captures++; + return png(); + }, + cache, + }); + + const miss = await serve(); + await Promise.all(deferred.splice(0)); + // Simulate disabling public-read after the public image was cached. The same + // pixels may be reused after authorization, but the OLD public directive must not. + clientCacheControl = "private, max-age=300"; + const hit = await serve(); + assert.equal(miss.headers.get("x-sideshow-screenshot-cache"), "miss"); + assert.equal(miss.headers.get("cache-control"), "public, max-age=300"); + assert.equal(hit.headers.get("x-sideshow-screenshot-cache"), "hit"); + assert.equal(hit.headers.get("cache-control"), "private, max-age=300"); + assert.equal(authorizationChecks, 2); + assert.equal(captures, 1); + + authorized = false; + const deletedOrDenied = await serve(); + assert.equal(deletedOrDenied.status, 404); + assert.equal(authorizationChecks, 3); + assert.equal(captures, 1); +}); + +test("HEAD authorizes but never reads cache or invokes Browser Rendering", async () => { + const request = new Request(cardUrl(), { method: "HEAD" }); + const { url, plan: screenshot } = plan(cardUrl()); + let cacheReads = 0; + let captures = 0; + const cache: EdgeCache = { + async match() { + cacheReads++; + return undefined; + }, + async put() {}, + }; + const response = await servePostScreenshot({ + request, + requestUrl: url, + postId: "post_1", + plan: screenshot, + rendererGeneration: GENERATION, + clientCacheControl: "public, max-age=300", + defer: () => {}, + authorize: async () => new Response("renderable"), + capture: async () => { + captures++; + return png(); + }, + cache, + }); + + assert.equal(response.status, 200); + assert.equal(response.headers.get("cache-control"), "public, max-age=300"); + assert.equal(cacheReads, 0); + assert.equal(captures, 0); +}); diff --git a/workers/index.ts b/workers/index.ts index 1db111e..03aab60 100644 --- a/workers/index.ts +++ b/workers/index.ts @@ -7,6 +7,7 @@ import { createApp } from "../server/app.ts"; import { SqlStore } from "../server/sqlStore.ts"; import viewerHtml from "../viewer/dist/index.html"; import { matchPostScreenshot, planPostScreenshot } from "./screenshot.ts"; +import { postScreenshotClientCacheControl, servePostScreenshot } from "./screenshotCache.ts"; interface Env { BOARD: DurableObjectNamespace<SideshowBoard>; @@ -47,7 +48,7 @@ export class SideshowBoard extends DurableObject<Env> { } export default { - async fetch(request: Request, env: Env) { + async fetch(request: Request, env: Env, ctx: ExecutionContext) { if (!env.SIDESHOW_TOKEN) { return new Response( "sideshow is not configured: set a token first —\n\n wrangler secret put SIDESHOW_TOKEN\n", @@ -68,35 +69,29 @@ export default { // page matches what the viewer shows; the width is configurable via ?w= // (default 800). Social card mode is fixed at 1200x630. const plan = planPostScreenshot(url, postId, request.headers.get("cookie")); - const checkRes = await workspace.fetch( - new Request(plan.checkUrl, { headers: request.headers }), + const clientCacheControl = postScreenshotClientCacheControl( + plan.noCache, + env.SIDESHOW_PUBLIC_READ, ); - if (!checkRes.ok) return checkRes; - // Auth passed and post exists — discard the HTML. For HEAD, return the - // same public image headers Slack checks without paying for Browser Rendering. - await checkRes.arrayBuffer(); - if (request.method === "HEAD") { - return new Response(null, { - headers: { - "Content-Type": "image/png", - "Cache-Control": plan.noCache ? "no-store" : "public, max-age=300", - }, - }); - } - const screenshot = await env.BROWSER.quickAction("screenshot", { - url: plan.target, - viewport: plan.viewport, - screenshotOptions: plan.screenshotOptions, - gotoOptions: { waitUntil: "networkidle0", timeout: 15000 }, - cacheTTL: 0, - cookies: [{ name: "sideshow_key", value: env.SIDESHOW_TOKEN, domain: url.hostname }], - }); - return new Response(await screenshot.arrayBuffer(), { - headers: { - "Content-Type": "image/png", - "Cache-Control": plan.noCache ? "no-store" : "public, max-age=300", - }, + return servePostScreenshot({ + request, + requestUrl: url, + postId, + plan, + rendererGeneration: pkg.version, + clientCacheControl, + defer: (promise) => ctx.waitUntil(promise), + authorize: () => workspace.fetch(new Request(plan.checkUrl, { headers: request.headers })), + capture: () => + env.BROWSER.quickAction("screenshot", { + url: plan.target, + viewport: plan.viewport, + screenshotOptions: plan.screenshotOptions, + gotoOptions: { waitUntil: "networkidle0", timeout: 15000 }, + cacheTTL: 0, + cookies: [{ name: "sideshow_key", value: env.SIDESHOW_TOKEN!, domain: url.hostname }], + }), }); }, } satisfies ExportedHandler<Env>; diff --git a/workers/screenshot.ts b/workers/screenshot.ts index 3490ab1..3f480ef 100644 --- a/workers/screenshot.ts +++ b/workers/screenshot.ts @@ -40,6 +40,10 @@ export function planPostScreenshot( checkUrl.pathname = `/p/${postId}`; checkUrl.search = ""; // clear .png query params, including tokens checkUrl.searchParams.set("part", "0"); + // Social metadata pins the post version as public `v`; the renderer calls it + // `ver` and validates it against current/history before any cache lookup. + const version = requestUrl.searchParams.get("v"); + if (version) checkUrl.searchParams.set("ver", version); if (theme) checkUrl.searchParams.set("theme", theme); if (mode) checkUrl.searchParams.set("mode", mode); diff --git a/workers/screenshotCache.ts b/workers/screenshotCache.ts new file mode 100644 index 0000000..5886e11 --- /dev/null +++ b/workers/screenshotCache.ts @@ -0,0 +1,219 @@ +// Short-lived edge cache for the versioned social-card screenshot advertised by +// post permalink metadata. +// +// Authorization and pixel-identity validation happen before cache lookup: the +// board DO must approve `/p/:id?part=0&ver=N&theme=T&mode=M`. A forged or +// unavailable version therefore gets a 404 before it can read or populate cache; +// a deleted post and changed auth policy are likewise enforced on every edge +// request. A hit avoids only the expensive Browser Rendering call. + +import { themeOptions } from "../server/themes.ts"; +import type { PostScreenshotPlan } from "./screenshot.ts"; + +const EDGE_MAX_AGE_SECONDS = 3600; +const ORIGIN_CACHE_CONTROL = "x-sideshow-origin-cache-control"; +const CACHE_STATUS_HEADER = "x-sideshow-screenshot-cache"; + +// Only the methods used here, so Node tests can provide a Map-backed stand-in. +export interface EdgeCache { + match(request: Request): Promise<Response | undefined>; + put(request: Request, response: Response): Promise<void>; +} + +// `caches.default` is absent under Node tests and unavailable on workers.dev. +// Both cases safely degrade to an ordinary uncached screenshot. +export function defaultEdgeCache(): EdgeCache | null { + const store = (globalThis as { caches?: { default?: EdgeCache } }).caches; + return store?.default ?? null; +} + +// A token-protected board must never advertise its bearer-gated image as shared +// cacheable downstream. The internal Cache API copy is separate and remains safe +// because servePostScreenshot reauthorizes before every lookup. +export function postScreenshotClientCacheControl( + noCache: boolean, + publicRead: string | undefined, +): string { + if (noCache) return "no-store"; + return `${publicRead === "session" || publicRead === "full" ? "public" : "private"}, max-age=300`; +} + +// Cache only the exact fixed-size, fully pinned shape emitted by postPreviewHead. +// Unrelated tracking parameters are ignored rather than fragmenting the cache. +export function postScreenshotCacheKey( + method: string, + requestUrl: URL, + postId: string, + plan: PostScreenshotPlan, + rendererGeneration: string, +): Request | null { + if (method !== "GET" || plan.noCache || requestUrl.searchParams.get("card") !== "1") { + return null; + } + + const version = requestUrl.searchParams.get("v"); + if (!version || !/^(0|[1-9]\d*)$/.test(version)) return null; + const numericVersion = Number(version); + if (!Number.isSafeInteger(numericVersion) || numericVersion < 0) return null; + + const theme = requestUrl.searchParams.get("theme"); + if (!theme || !themeOptions().some((option) => option.id === theme)) return null; + const mode = requestUrl.searchParams.get("mode"); + if (mode !== "light" && mode !== "dark") return null; + const generation = requestUrl.searchParams.get("g"); + if (!generation || generation !== rendererGeneration) return null; + + // planPostScreenshot maps public markers to renderer query names. Equality + // ties the cache key to what the DO just validated and Browser Rendering will + // actually capture. + if ( + plan.checkUrl.searchParams.get("ver") !== version || + plan.checkUrl.searchParams.get("theme") !== theme || + plan.checkUrl.searchParams.get("mode") !== mode + ) { + return null; + } + + const params = new URLSearchParams({ + part: plan.checkUrl.searchParams.get("part") ?? "0", + v: version, + theme, + mode, + g: generation, + }); + + return new Request( + `${requestUrl.origin}/__cache/post-screenshot/${encodeURIComponent(postId)}.png?${params}`, + { method: "GET" }, + ); +} + +function isSafeClientControl(control: string): boolean { + const normalized = control.toLowerCase(); + if (/\b(?:no-cache|no-store)\b/.test(normalized)) return false; + const isPublic = /\bpublic\b/.test(normalized); + const isPrivate = /\bprivate\b/.test(normalized); + // Exactly one audience directive must be present. The internal Cache API copy + // is public either way; this original policy is restored only to the client. + return isPublic !== isPrivate; +} + +function isPng(res: Response): boolean { + return res.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase() === "image/png"; +} + +function isStorable(res: Response): boolean { + return ( + res.status === 200 && + isPng(res) && + !res.headers.has("set-cookie") && + isSafeClientControl(res.headers.get("cache-control") ?? "") + ); +} + +function storableCopy(res: Response): Response { + const copy = res.clone(); + const stored = new Response(copy.body, copy); + stored.headers.set(ORIGIN_CACHE_CONTROL, res.headers.get("cache-control") ?? "no-store"); + stored.headers.set("cache-control", `public, max-age=${EDGE_MAX_AGE_SECONDS}`); + return stored; +} + +function isUsableHit(res: Response): boolean { + return ( + res.status === 200 && + isPng(res) && + isSafeClientControl(res.headers.get(ORIGIN_CACHE_CONTROL) ?? "") + ); +} + +function tagged(res: Response, state: "hit" | "miss"): Response { + const out = new Response(res.body, res); + const originControl = + out.headers.get(ORIGIN_CACHE_CONTROL) ?? + (state === "miss" ? (out.headers.get("cache-control") ?? "") : ""); + out.headers.set("cache-control", isSafeClientControl(originControl) ? originControl : "no-store"); + out.headers.delete(ORIGIN_CACHE_CONTROL); + out.headers.set(CACHE_STATUS_HEADER, state); + return out; +} + +export async function withPostScreenshotCache( + key: Request | null, + defer: (promise: Promise<unknown>) => void, + capture: () => Promise<Response>, + cache: EdgeCache | null = defaultEdgeCache(), +): Promise<Response> { + if (!key || !cache) return capture(); + + const hit = await cache.match(key).catch(() => undefined); + if (hit && isUsableHit(hit)) return tagged(hit, "hit"); + + const res = await capture(); + if (!isStorable(res)) return res; + defer(cache.put(key, storableCopy(res)).catch(() => {})); + return tagged(res, "miss"); +} + +export interface ServePostScreenshotOptions { + request: Request; + requestUrl: URL; + postId: string; + plan: PostScreenshotPlan; + rendererGeneration: string; + clientCacheControl: string; + defer: (promise: Promise<unknown>) => void; + // Must perform the real board-app read with the caller's credentials. Keeping + // this callback inside the orchestration function makes auth-before-cache a + // testable invariant rather than merely call-site ordering. + authorize: () => Promise<Response>; + capture: () => Promise<Response>; + cache?: EdgeCache | null; +} + +// Authorize and validate at the DO first, then serve/fill the edge cache. The +// capture callback returns Browser Rendering's response; this function owns the +// final PNG and client cache headers so private deployments cannot accidentally +// emit a public response. +export async function servePostScreenshot({ + request, + requestUrl, + postId, + plan, + rendererGeneration, + clientCacheControl, + defer, + authorize, + capture, + cache = defaultEdgeCache(), +}: ServePostScreenshotOptions): Promise<Response> { + const checkRes = await authorize(); + if (!checkRes.ok) return checkRes; + await checkRes.arrayBuffer(); + + if (request.method === "HEAD") { + return new Response(null, { + headers: { "content-type": "image/png", "cache-control": clientCacheControl }, + }); + } + + const key = postScreenshotCacheKey(request.method, requestUrl, postId, plan, rendererGeneration); + const response = await withPostScreenshotCache( + key, + defer, + async () => { + const screenshot = await capture(); + return new Response(await screenshot.arrayBuffer(), { + headers: { "content-type": "image/png", "cache-control": clientCacheControl }, + }); + }, + cache, + ); + // Access policy may change while the pixel identity stays the same (for + // example, public-read is disabled after a card was cached). Authorization + // above uses the CURRENT policy, so its client directive must also win over + // the policy recorded with an older internal entry. + const out = new Response(response.body, response); + out.headers.set("cache-control", clientCacheControl); + return out; +}