From 64c9f263e88dcbe3db55225e3cd8a81738a28995 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 17:30:18 +0100 Subject: [PATCH] Thumbnail plugin card screenshots through Cloudflare image transformations (#6240) * perf(website): thumbnail plugin card screenshots * fix(website): preserve and sandbox plugin screenshot fallbacks --- packages/website/e2e/plugins.spec.ts | 48 +++- packages/website/src/plugins/plugin-card.tsx | 4 +- packages/website/src/plugins/published.ts | 5 + .../website/src/plugins/thumbnails.test.ts | 234 ++++++++++++++++++ packages/website/src/plugins/thumbnails.ts | 118 +++++++++ 5 files changed, 407 insertions(+), 2 deletions(-) create mode 100644 packages/website/src/plugins/thumbnails.test.ts create mode 100644 packages/website/src/plugins/thumbnails.ts diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index 92a48d0c84..910f14cc1b 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -1,4 +1,5 @@ -import { expect, test, type Page } from "playwright/test"; +import { expect, test, type Page, type Locator } from "playwright/test"; +import registry from "./registry.fixture.json" with { type: "json" }; import { CATEGORIES } from "../src/plugins/categories"; async function openPlugins(page: Page) { @@ -306,6 +307,26 @@ test("keeps the directory unlinked until the coordinated announcement", async ({ test.describe("registry fixture layout", () => { test.skip(Boolean(process.env.WEBSITE_TEST_URL), "Requires the local registry fixture"); + test("uses density-aware card thumbnails and keeps detail screenshots original", async ({ + page, + }) => { + const plugin = registry.plugins.find((entry) => entry.id === "alhassanaraouf/base2tone")!; + const source = plugin.screenshots[0]; + await openPlugins(page); + await expectThumbnailCard( + page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Base2Tone/ }), + source, + plugin.id, + ); + await page.goto("/plugins/all"); + const card = page.getByRole("main").getByRole("link", { name: /Base2Tone/ }); + await expectThumbnailCard(card, source, plugin.id); + await card.click(); + await expect( + page.getByRole("img", { name: "Base2Tone screenshot 1", exact: true }), + ).toHaveAttribute("src", source); + }); + test("lists the nine categories in order with counts, and the newest plugins first", async ({ page, }) => { @@ -320,3 +341,28 @@ test.describe("registry fixture layout", () => { ).toHaveText([/Base2Tone/, /Sayr/, /PromptKit/, /Defer/]); }); }); + +async function expectThumbnailCard(card: Locator, source: string, id: string) { + // Card screenshots are decorative and hidden from the accessibility tree. + const image = card.locator("img").first(); + const path = (width: number) => + `/plugins/thumb/${width}/${encodeURIComponent(source)}?plugin=${encodeURIComponent(id)}`; + await expect(image).toHaveAttribute("src", path(592)); + await expect(image).toHaveAttribute("srcset", `${path(592)} 1x, ${path(1184)} 2x`); + await expect(image).toHaveAttribute("loading", "lazy"); + await expect(image).toHaveAttribute("decoding", "async"); + await image.scrollIntoViewIfNeeded(); + await expect + .poll( + () => + image.evaluate((element: HTMLImageElement) => element.complete && element.naturalWidth > 0), + { timeout: 15_000 }, + ) + .toBe(true); + const box = await image.evaluate((element) => { + const rect = element.parentElement!.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + }); + expect(box.width).toBeGreaterThan(0); + expect(box.width / box.height).toBeCloseTo(1.6, 2); +} diff --git a/packages/website/src/plugins/plugin-card.tsx b/packages/website/src/plugins/plugin-card.tsx index d8fdb01fc4..4df1d70d1d 100644 --- a/packages/website/src/plugins/plugin-card.tsx +++ b/packages/website/src/plugins/plugin-card.tsx @@ -1,4 +1,5 @@ import { Download } from "lucide-react"; +import { pluginCardScreenshot } from "./thumbnails"; import { pluginHref } from "./links"; import { PluginTile } from "./plugin-tile"; import { formatInstalls, getCategory, type Plugin } from "./registry"; @@ -16,9 +17,10 @@ function PluginShot({ plugin }: { plugin: Plugin }) { > {url ? ( ) : ( diff --git a/packages/website/src/plugins/published.ts b/packages/website/src/plugins/published.ts index ebf7e5256b..b57d16ec2a 100644 --- a/packages/website/src/plugins/published.ts +++ b/packages/website/src/plugins/published.ts @@ -4,6 +4,7 @@ import { PublishedPluginDetailSchema, } from "@getpaseo/protocol/plugin-registry"; import { getBlockingColdCache, type WebsiteCacheContext } from "../github-cache"; +import { handlePluginThumbnailRequest } from "./thumbnails"; import { CATEGORIES } from "./categories"; import { type InstallCounts, readInstallCounts, recordClientInstall } from "./installs"; export interface RegistryEnvironment { @@ -63,6 +64,10 @@ export async function handlePluginRegistryRequest( const url = new URL(request.url); if (request.method !== "GET") return null; const base = env.PLUGINS_REGISTRY_URL ?? "https://getpaseo.github.io/plugins"; + if (url.pathname.startsWith("/plugins/thumb/")) { + const index = await loadRegistryIndex(base, context); + return handlePluginThumbnailRequest(request, index.plugins); + } if (url.hostname === "plugins.paseo.sh" && url.pathname === "/index.json") return Response.json(await loadRegistryIndex(base, context)); if (url.pathname === "/sitemap-plugins.xml") { diff --git a/packages/website/src/plugins/thumbnails.test.ts b/packages/website/src/plugins/thumbnails.test.ts new file mode 100644 index 0000000000..ec089be62e --- /dev/null +++ b/packages/website/src/plugins/thumbnails.test.ts @@ -0,0 +1,234 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { handlePluginThumbnailRequest, pluginCardScreenshot } from "./thumbnails"; +import { NewPluginCard, PluginCard } from "./plugin-card"; +import type { Plugin } from "./registry"; + +const source = "https://cdn.jsdelivr.net/npm/paseo-example@1.2.3/preview.png"; +const plugin: Plugin = { + id: "acme/example", + name: "Example", + description: "An example", + categories: ["themes"], + author: { github: "acme" }, + repository: { url: "https://github.com/acme/example" }, + artifact: { + kind: "npm", + package: "paseo-example", + version: "1.2.3", + resolved: "https://registry.npmjs.org/example.tgz", + integrity: "sha512-YWJj", + }, + screenshots: [source], + submittedAt: "2026-10-03", + reviewedAt: "2026-10-03", + updatedAt: "2026-10-03", + publishedAt: "2026-10-03", + installs: 42, +}; + +describe("plugin card thumbnails", () => { + it("keeps a screenshot on an unsupported host visible at its original URL", () => { + const screenshot = "https://example.com/preview.png"; + const html = renderToStaticMarkup( + createElement(PluginCard, { plugin: { ...plugin, screenshots: [screenshot] } }), + ); + expect(html).toContain(`src="${screenshot}"`); + expect(html).not.toContain("/plugins/thumb/"); + }); + + it.each([PluginCard, NewPluginCard])( + "renders a thumbnail instead of downloading the original screenshot (%s)", + (Card) => { + const html = renderToStaticMarkup(createElement(Card, { plugin, added: "today" })); + expect(html).toContain( + `src="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample"`, + ); + expect(html).toContain( + `srcSet="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample 1x, /plugins/thumb/1184/${encodeURIComponent(source)}?plugin=acme%2Fexample 2x"`, + ); + expect(html).toContain('loading="lazy"'); + expect(html).toContain('decoding="async"'); + }, + ); +}); + +function thumbnailRequest( + sourceUrl = source, + width = 592, + id = plugin.id, + accept = "image/avif,image/webp", +) { + return new Request( + `https://paseo.sh/plugins/thumb/${width}/${encodeURIComponent(sourceUrl)}?plugin=${encodeURIComponent(id)}`, + { headers: { accept } }, + ); +} + +function imageResponse(body: string, headers: Record = {}) { + return new Response(body, { headers: { "content-type": "image/png", ...headers } }); +} + +// A typed fetch dependency, returning real Responses without patching the global fetch. +function imageFetch(...responses: (Response | Error)[]) { + const calls: { input: Parameters[0]; init: Parameters[1] }[] = []; + const fetchImage: typeof fetch = async (input, init) => { + calls.push({ input, init }); + const response = responses.shift(); + if (!response) throw new Error("Unexpected fetch"); + if (response instanceof Error) throw response; + return response; + }; + return { calls, fetchImage }; +} + +describe("thumbnail route", () => { + it("sandboxes an SVG fallback when opened as a document on the website origin", async () => { + const svg = + ''; + const upstream = imageFetch( + new Response("unsupported", { status: 415 }), + imageResponse(svg, { "content-type": "image/svg+xml" }), + ); + const response = await handlePluginThumbnailRequest( + thumbnailRequest(), + [plugin], + upstream.fetchImage, + ); + expect(await response.text()).toBe(svg); + expect(response.headers.get("content-security-policy")).toBe("sandbox"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + }); + + it.each([ + [592, source, "image/avif,image/webp", "avif"], + [592, source, "image/avif;q=0,image/webp", "webp"], + [592, source, "image/avif;q=0,image/webp;q=0", undefined], + [ + 1184, + "https://raw.githubusercontent.com/acme/example/abc123/preview.png", + "image/webp", + "webp", + ], + [592, source, "image/png,image/*;q=0.8", undefined], + ] as const)( + "transforms a registered screenshot at %s from %s for %s", + async (width, screenshot, accept, format) => { + const upstream = imageFetch(imageResponse("thumbnail")); + const response = await handlePluginThumbnailRequest( + thumbnailRequest(screenshot, width, plugin.id, accept), + [{ ...plugin, screenshots: [screenshot] }], + upstream.fetchImage, + ); + expect(upstream.calls).toEqual([ + { + input: screenshot, + init: { + cf: { image: { width, fit: "scale-down", format, quality: 80 } }, + }, + }, + ]); + expect(response.status).toBe(200); + expect(await response.text()).toBe("thumbnail"); + expect(response.headers.get("content-type")).toBe("image/png"); + expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); + expect(response.headers.get("vary")).toBe("Accept"); + }, + ); + + it.each([ + ["Cloudflare error", () => new Response("error 9401", { status: 403 })], + [ + "non-image success", + () => new Response("unavailable", { headers: { "content-type": "text/html" } }), + ], + ["Cf-Resized error", () => imageResponse("error", { "cf-resized": "err=9401" })], + ["network exception", () => new Error("Transform unavailable")], + ] as const)("proxies the original with immutable caching on %s", async (_, failure) => { + const upstream = imageFetch(failure(), imageResponse("original")); + const response = await handlePluginThumbnailRequest( + thumbnailRequest(), + [plugin], + upstream.fetchImage, + ); + expect(upstream.calls.map((call) => call.input)).toEqual([source, source]); + expect(upstream.calls[1].init).toBeUndefined(); + expect(response.status).toBe(200); + expect(await response.text()).toBe("original"); + expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); + }); + + it.each([ + ["arbitrary width", thumbnailRequest(source, 593), [plugin], 400], + ["unlisted source on allowed host", thumbnailRequest(source + "?different"), [plugin], 404], + ["wrong plugin ID", thumbnailRequest(source, 592, "acme/other"), [plugin], 404], + [ + "missing plugin ID", + new Request(`https://paseo.sh/plugins/thumb/592/${encodeURIComponent(source)}`), + [plugin], + 404, + ], + ["removed plugin", thumbnailRequest(), [], 404], + ["malformed encoding", new Request("https://paseo.sh/plugins/thumb/592/%ZZ"), [plugin], 400], + ["malformed URL", thumbnailRequest("not a URL"), [plugin], 400], + ...[ + "https://example.com/preview.png", + "https://cdn.jsdelivr.net.evil.test/preview.png", + "http://cdn.jsdelivr.net/preview.png", + "https://user:pass@cdn.jsdelivr.net/preview.png", + "https://cdn.jsdelivr.net:8080/preview.png", + ].map( + (url) => + [ + "disallowed registered source", + thumbnailRequest(url), + [{ ...plugin, screenshots: [url] }], + 400, + ] satisfies [string, Request, Plugin[], number], + ), + ] satisfies [string, Request, Plugin[], number][])( + "rejects %s without fetching an image", + async (_, request, plugins, status) => { + const upstream = imageFetch(); + const response = await handlePluginThumbnailRequest( + request, + [...plugins], + upstream.fetchImage, + ); + expect(response.status).toBe(status); + expect(upstream.calls).toEqual([]); + expect(response.headers.get("cache-control")).toBeNull(); + }, + ); + + it.each([ + () => new Response("missing", { status: 404 }), + () => new Response("error", { headers: { "content-type": "text/html" } }), + () => new Error("Origin unavailable"), + ])("does not cache a failed original for a year", async (failure) => { + const upstream = imageFetch(new Response("transform error", { status: 502 }), failure()); + const response = await handlePluginThumbnailRequest( + thumbnailRequest(), + [plugin], + upstream.fetchImage, + ); + expect(response.status).toBe(502); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + it("round-trips screenshot URL punctuation through the card URL", async () => { + const screenshot = source + "?name=a&other=b#preview"; + const upstream = imageFetch(imageResponse("thumbnail")); + const request = new Request( + "https://paseo.sh" + pluginCardScreenshot(plugin.id, screenshot).src, + ); + const response = await handlePluginThumbnailRequest( + request, + [{ ...plugin, screenshots: [screenshot] }], + upstream.fetchImage, + ); + expect(response.status).toBe(200); + expect(upstream.calls[0].input).toBe(screenshot); + }); +}); diff --git a/packages/website/src/plugins/thumbnails.ts b/packages/website/src/plugins/thumbnails.ts new file mode 100644 index 0000000000..755854d257 --- /dev/null +++ b/packages/website/src/plugins/thumbnails.ts @@ -0,0 +1,118 @@ +import type { Plugin } from "./registry"; + +// The single-column grid reaches 591px just below sm (640px minus 48px padding). +const CARD_WIDTH = 592; +const WIDTHS = new Set([CARD_WIDTH, CARD_WIDTH * 2]); +const SOURCE_HOSTS = new Set(["cdn.jsdelivr.net", "raw.githubusercontent.com"]); + +export function pluginCardScreenshot(id: string, source: string): { src: string; srcSet?: string } { + if (!parseSource(source)) return { src: source }; + return { + src: pluginThumbnailUrl(id, source, 1), + srcSet: `${pluginThumbnailUrl(id, source, 1)} 1x, ${pluginThumbnailUrl(id, source, 2)} 2x`, + }; +} + +function pluginThumbnailUrl(id: string, source: string, scale: 1 | 2): string { + return `/plugins/thumb/${CARD_WIDTH * scale}/${encodeURIComponent(source)}?plugin=${encodeURIComponent(id)}`; +} + +/** Only registry-listed screenshots are eligible for transformation or original fallback. */ +export async function handlePluginThumbnailRequest( + request: Request, + plugins: Plugin[], + fetchImage: typeof fetch = fetch, +): Promise { + const url = new URL(request.url); + const match = /^\/plugins\/thumb\/(\d+)\/([^/]+)$/.exec(url.pathname); + if (!match || !WIDTHS.has(Number(match[1]))) + return new Response("Invalid thumbnail width or source", { status: 400 }); + + let decoded: string; + try { + decoded = decodeURIComponent(match[2]); + } catch { + return new Response("Invalid thumbnail source", { status: 400 }); + } + const source = parseSource(decoded); + if (!source) return new Response("Invalid thumbnail source", { status: 400 }); + + const plugin = plugins.find((entry) => entry.id === url.searchParams.get("plugin")); + if (!plugin?.screenshots.includes(source)) + return new Response("Screenshot not found", { status: 404 }); + + const format = negotiatedFormat(request.headers.get("accept") ?? ""); + let response: Response | undefined; + try { + response = await fetchImage(source, { + cf: { image: { width: Number(match[1]), fit: "scale-down", format, quality: 80 } }, + }); + } catch { + // An unavailable transformation must not prevent fetching the original. + } + if (!response || !isImage(response) || /err=/i.test(response.headers.get("cf-resized") ?? "")) { + await response?.body?.cancel(); + try { + response = await fetchImage(source); + } catch { + return unavailableImage(); + } + } + if (!isImage(response)) { + await response.body?.cancel(); + return unavailableImage(); + } + return new Response(response.body, { + headers: { + "content-type": response.headers.get("content-type")!, + "cache-control": "public, max-age=31536000, immutable", + vary: "Accept", + // Originals can be SVGs. Keep direct navigation from executing them on our origin. + "content-security-policy": "sandbox", + "x-content-type-options": "nosniff", + }, + }); +} + +function isImage(response: Response): boolean { + return response.ok && /^image\//i.test(response.headers.get("content-type") ?? ""); +} + +function unavailableImage(): Response { + return new Response("Screenshot unavailable", { + status: 502, + headers: { "cache-control": "no-store" }, + }); +} + +function parseSource(source: string): string | null { + try { + const url = new URL(source); + if ( + url.protocol !== "https:" || + !SOURCE_HOSTS.has(url.hostname) || + url.username || + url.password || + url.port + ) + return null; + return source; + } catch { + return null; + } +} + +function negotiatedFormat(accept: string): "avif" | "webp" | undefined { + // The Worker API requires explicit negotiation; only the URL API accepts format=auto. + const accepted = new Map( + accept + .toLowerCase() + .split(",") + .map((range) => { + const [type, ...parameters] = range.split(";").map((part) => part.trim()); + const quality = parameters.find((parameter) => parameter.startsWith("q=")); + return [type, quality === undefined ? 1 : Number(quality.slice(2))] as const; + }), + ); + return (["avif", "webp"] as const).find((format) => (accepted.get(`image/${format}`) ?? 0) > 0); +}