From 428d6b371c1d17801bcbcde2b7a3819dfa739443 Mon Sep 17 00:00:00 2001 From: Madraka Date: Fri, 23 Oct 2026 19:56:42 +0300 Subject: [PATCH] fix: demo images load in Studio's article preview Studio's preview is a sandboxed document, so its image requests are cross-site and carry no session cookie (SameSite=Lax). Without a session the playground answered /media/... with a 303 to the landing page, and every cover showed as a broken image there (seen on "Pages are made of sections"). Uploads are refused in the playground, so its only media are the demo images, already public under /demo/: the Worker now answers their media URLs from those static files. Adds node --test to pnpm check. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka --- biome.json | 10 ++++++- package.json | 3 ++- src/playground/demo-media-route.ts | 42 ++++++++++++++++++++++++++++++ src/playground/worker.ts | 6 +++++ test/demo-media-route.test.ts | 41 +++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 src/playground/demo-media-route.ts create mode 100644 test/demo-media-route.test.ts diff --git a/biome.json b/biome.json index 282883b..20f7ff6 100644 --- a/biome.json +++ b/biome.json @@ -2,7 +2,15 @@ "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "files": { - "includes": ["src/playground/**", "scripts/**", "*.mjs", "*.json", "*.jsonc", "!public"] + "includes": [ + "src/playground/**", + "scripts/**", + "test/**", + "*.mjs", + "*.json", + "*.jsonc", + "!public" + ] }, "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 }, "linter": { "enabled": true, "rules": { "preset": "recommended" } } diff --git a/package.json b/package.json index 15c7103..33788c1 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "typecheck": "astro sync && tsc --noEmit -p tsconfig.json", "lint": "biome check .", "lint:fix": "biome check --write .", - "check": "pnpm lint && pnpm typecheck" + "test": "node --test test/*.test.ts", + "check": "pnpm lint && pnpm typecheck && pnpm test" }, "dependencies": { "@astrojs/cloudflare": "14.3.4", diff --git a/src/playground/demo-media-route.ts b/src/playground/demo-media-route.ts new file mode 100644 index 0000000..81faa4b --- /dev/null +++ b/src/playground/demo-media-route.ts @@ -0,0 +1,42 @@ +/** + * Demo images by their media URL, without a session (`/media//.png`). + * + * Studio's article preview is a sandboxed document (its CSP carries `sandbox`), so its image + * requests are cross-site and leave out the session cookie (`SameSite=Lax`). Without a session + * the playground answers `/media/…` with a redirect to the landing page, and the preview showed a + * broken image for every cover. Uploads are refused in the playground, so its only media are the + * demo images, whose bytes are public static files already (`public/demo/`): answer those here. + */ +import { DEMO_MEDIA, DEMO_SITE_ID } from "./demo-media.ts"; + +const BY_PREFIX = new Map(DEMO_MEDIA.map((m) => [m.sha256.slice(0, 16), m])); +const MEDIA_PATH = /^\/media\/([0-9a-f]{16})\/[^/]+\.png$/; + +/** The static file behind a demo image's media URL (`/demo/media//`), if it is one. */ +export function demoMediaFile(pathname: string): string | undefined { + const prefix = MEDIA_PATH.exec(pathname)?.[1]; + const media = prefix ? BY_PREFIX.get(prefix) : undefined; + return media ? `/demo/media/${DEMO_SITE_ID}/${media.sha256}` : undefined; +} + +/** A demo image's media response from the static assets, or undefined for any other request. */ +export async function serveDemoMedia( + request: Request, + assets: { fetch(request: Request): Promise }, +): Promise { + if (request.method !== "GET" && request.method !== "HEAD") return undefined; + const url = new URL(request.url); + const file = demoMediaFile(url.pathname); + if (!file) return undefined; + const res = await assets.fetch(new Request(new URL(file, url), { method: request.method })); + if (!res.ok) return undefined; + return new Response(request.method === "HEAD" ? null : res.body, { + headers: { + "content-type": "image/png", + "content-disposition": "inline", + "content-security-policy": "default-src 'none'", + "x-content-type-options": "nosniff", + "cache-control": "public, max-age=3600", + }, + }); +} diff --git a/src/playground/worker.ts b/src/playground/worker.ts index 3fe1197..160b2c4 100644 --- a/src/playground/worker.ts +++ b/src/playground/worker.ts @@ -10,6 +10,7 @@ import "@cloudhal/cloudflare/workerd-prelude"; import { handle } from "@astrojs/cloudflare/handler"; import { serveMedia, withRequestStore } from "@cloudhal/astro-renderer/runtime"; import { createPlaygroundWorker, type PlaygroundEnv } from "@cloudhal/cloudflare/playground"; +import { serveDemoMedia } from "./demo-media-route.ts"; import { DEMO_MEDIA_KEYS, seedDemo } from "./demo-pack.ts"; import { LANDING_PATH, landingPage } from "./landing.ts"; @@ -56,6 +57,11 @@ export default { return env.ASSETS.fetch(new Request(new URL("/index.html", url), { headers: req.headers })); }, } as unknown as Fetcher; + // Demo images answer without a session: Studio's sandboxed preview sends no cookie. + if (enabled) { + const media = await serveDemoMedia(request, env.ASSETS); + if (media) return media; + } return playground.worker.fetch(request, { ...env, ASSETS: assets, diff --git a/test/demo-media-route.test.ts b/test/demo-media-route.test.ts new file mode 100644 index 0000000..72dba31 --- /dev/null +++ b/test/demo-media-route.test.ts @@ -0,0 +1,41 @@ +// `node --test`: demo images answer by their media URL without a session (demo-media-route.ts). +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { DEMO_MEDIA } from "../src/playground/demo-media.ts"; +import { demoMediaFile, serveDemoMedia } from "../src/playground/demo-media-route.ts"; + +const forest = DEMO_MEDIA.find((m) => m.slug === "forest-light"); +const url = (path: string) => `https://try.cloudhal.com${path}`; +const mediaPath = `/media/${forest?.sha256.slice(0, 16)}/forest-light.png`; + +test("maps a demo image's media URL to its static file, and nothing else", () => { + assert.equal(demoMediaFile(mediaPath), `/demo/media/site_playgrounddemo/${forest?.sha256}`); + assert.equal(demoMediaFile("/media/0000000000000000/other.png"), undefined); + assert.equal(demoMediaFile(`/media/${forest?.sha256.slice(0, 16)}/x.png.sha256`), undefined); + assert.equal(demoMediaFile("/s/01ABC/media/x.png"), undefined); +}); + +test("serves the file as an inert PNG to a request without a session cookie", async () => { + const asked: string[] = []; + const assets = { + async fetch(req: Request) { + asked.push(new URL(req.url).pathname); + return new Response(new Uint8Array([137, 80, 78, 71])); + }, + }; + const res = await serveDemoMedia(new Request(url(mediaPath)), assets); + assert.equal(res?.status, 200); + assert.equal(res?.headers.get("content-type"), "image/png"); + assert.equal(res?.headers.get("x-content-type-options"), "nosniff"); + assert.deepEqual(asked, [`/demo/media/site_playgrounddemo/${forest?.sha256}`]); + assert.equal( + await serveDemoMedia(new Request(url("/media/0000000000000000/a.png")), assets), + undefined, + ); + assert.equal( + await serveDemoMedia(new Request(url(mediaPath), { method: "POST" }), assets), + undefined, + ); + const missing = { fetch: async () => new Response(null, { status: 404 }) }; + assert.equal(await serveDemoMedia(new Request(url(mediaPath)), missing), undefined); +});