mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Thumbnail plugin card screenshots through Cloudflare image transformations (#6240)
* perf(website): thumbnail plugin card screenshots * fix(website): preserve and sandbox plugin screenshot fallbacks
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
<img
|
||||
src={url}
|
||||
{...pluginCardScreenshot(plugin.id, url)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-full w-full object-cover object-left-top"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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<string, string> = {}) {
|
||||
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<typeof fetch>[0]; init: Parameters<typeof fetch>[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 =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(document.domain)</script></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("<html>unavailable</html>", { 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("<html>error</html>", { 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);
|
||||
});
|
||||
});
|
||||
@@ -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<Response> {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user