From 47c9fdd88507e0ec8000f23a98d185e213bb2fe1 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 31 May 2026 11:56:31 +0100 Subject: [PATCH 1/5] feat(registry): icon / screenshot / banner artifacts end-to-end Wire release.artifacts.{icon,screenshot,banner} through publish and admin. CLI: - Manifest schema gains a `release.artifacts` block: icon/banner as single `{ file }` refs, screenshot as an array. JSON Schema regenerated. - `publish` resolves each ref relative to the manifest, measures dimensions and content type via image-size, uploads to `--artifact-base-url`, and embeds `{ url, checksum, contentType, width, height, lang? }` in the release. The lexicon types `screenshot` as a single artifact, so the first screenshot uses that slot and extras ride in `x-screenshot-N` custom keys. - image-size catalog-pinned. Server: - New admin proxy `GET /registry/artifact?url=` for publisher-supplied image URLs. Applies SSRF defences via assertSafeArtifactUrl (re-validating each redirect hop), enforces an image content-type allowlist, caps the body, and serves back with `private, no-store` plus attachment + sandbox CSP so a navigated SVG can't execute in the admin origin. Admin: - RegistryPluginDetail renders the icon, banner, and a screenshot gallery through the proxy. Every image URL goes through artifactProxyUrl (scheme allow-list) before the proxy. --- .changeset/registry-image-artifacts.md | 7 + .../src/components/RegistryPluginDetail.tsx | 60 +++++- packages/admin/src/lib/api/registry.ts | 97 +++++++++ .../tests/lib/registry-artifacts.test.ts | 77 +++++++ packages/core/src/api/handlers/index.ts | 1 + packages/core/src/astro/integration/routes.ts | 5 + .../api/admin/plugins/registry/artifact.ts | 181 ++++++++++++++++ .../unit/api/registry-artifact-proxy.test.ts | 184 +++++++++++++++++ packages/plugin-cli/package.json | 2 +- .../schemas/emdash-plugin.schema.json | 80 ++++++++ packages/plugin-cli/src/commands/publish.ts | 64 ++++++ packages/plugin-cli/src/manifest/schema.ts | 95 +++++++++ packages/plugin-cli/src/manifest/translate.ts | 15 +- packages/plugin-cli/src/publish/api.ts | 81 ++++++++ packages/plugin-cli/src/publish/artifacts.ts | 121 +++++++++++ .../src/publish/upload-artifacts.ts | 194 ++++++++++++++++++ .../plugin-cli/tests/manifest-schema.test.ts | 77 +++++++ .../tests/publish-artifacts.test.ts | 108 ++++++++++ .../tests/publish-upload-artifacts.test.ts | 163 +++++++++++++++ packages/plugin-cli/tests/publish.test.ts | 83 ++++++++ pnpm-lock.yaml | 13 +- pnpm-workspace.yaml | 1 + 22 files changed, 1700 insertions(+), 9 deletions(-) create mode 100644 .changeset/registry-image-artifacts.md create mode 100644 packages/admin/tests/lib/registry-artifacts.test.ts create mode 100644 packages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts create mode 100644 packages/core/tests/unit/api/registry-artifact-proxy.test.ts create mode 100644 packages/plugin-cli/src/publish/artifacts.ts create mode 100644 packages/plugin-cli/src/publish/upload-artifacts.ts create mode 100644 packages/plugin-cli/tests/publish-artifacts.test.ts create mode 100644 packages/plugin-cli/tests/publish-upload-artifacts.test.ts diff --git a/.changeset/registry-image-artifacts.md b/.changeset/registry-image-artifacts.md new file mode 100644 index 0000000000..fb9cdd3bd8 --- /dev/null +++ b/.changeset/registry-image-artifacts.md @@ -0,0 +1,7 @@ +--- +"@emdash-cms/plugin-cli": minor +"emdash": minor +"@emdash-cms/admin": minor +--- + +Plugins published to the experimental registry can now ship icon, screenshot, and banner images. Declare them in `emdash-plugin.jsonc` under `release.artifacts` as file refs; `emdash-plugin publish --artifact-base-url ` measures each image's dimensions, uploads it, and records it in the release. The admin plugin detail page renders the icon, banner, and a screenshot gallery, fetched through a server-side image proxy that applies SSRF defences and an image content-type allowlist to the arbitrary publisher-supplied URLs. diff --git a/packages/admin/src/components/RegistryPluginDetail.tsx b/packages/admin/src/components/RegistryPluginDetail.tsx index f0cd9260af..55a613cec5 100644 --- a/packages/admin/src/components/RegistryPluginDetail.tsx +++ b/packages/admin/src/components/RegistryPluginDetail.tsx @@ -21,7 +21,9 @@ import { Link } from "@tanstack/react-router"; import * as React from "react"; import { + artifactProxyUrl, canonicalCapabilitiesForDriftCheck, + extractMediaArtifacts, getRegistryPackage, installRegistryPlugin, listRegistryReleases, @@ -209,6 +211,17 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP const repoHref = safeExternalHref(release?.release?.repo); const verified = (pkg?.labels ?? []).some((l: { val?: string }) => l.val === "verified"); + // Media artifacts (icon / screenshot / banner) live on the release record's + // `artifacts` map. Each carries a publisher-supplied `url`; we never point an + // `` at it directly — every image goes through the server's SSRF-defended + // proxy, which also enforces an image content-type allowlist. + const mediaArtifacts = extractMediaArtifacts(release?.release?.artifacts); + const iconSrc = artifactProxyUrl(mediaArtifacts.icon?.url); + const bannerSrc = artifactProxyUrl(mediaArtifacts.banner?.url); + const screenshots = mediaArtifacts.screenshots + .map((shot) => ({ ...shot, src: artifactProxyUrl(shot.url) })) + .filter((shot): shot is typeof shot & { src: string } => shot.src !== null); + const policyOk = release && pkg ? releasePassesPolicy(release, { did: pkg.did, slug }, config.policy) : true; // Handle resolution affects display only -- installs are addressed @@ -300,10 +313,29 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP
+ {/* Banner */} + {bannerSrc ? ( + {t`${displayName + ) : null} + {/* Header */}
-
- +
+ {iconSrc ? ( + {t`${displayName + ) : ( + + )}
@@ -437,6 +469,30 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP {/* Description */} {description ?

{description}

: null} + {/* Screenshot gallery */} + {screenshots.length > 0 ? ( +
+
    + {screenshots.map((shot, i) => ( +
  • + {t`Screenshot +
  • + ))} +
+
+ ) : null} + {/* License / keywords / repository */} {licenseText || repoHref || keywordList.length > 0 ? (
diff --git a/packages/admin/src/lib/api/registry.ts b/packages/admin/src/lib/api/registry.ts index 655ffe3b58..8a295798dc 100644 --- a/packages/admin/src/lib/api/registry.ts +++ b/packages/admin/src/lib/api/registry.ts @@ -428,6 +428,103 @@ export async function resolveDidToHandle(did: string): Promise`. + */ +export function artifactProxyUrl(value: unknown): string | null { + if (typeof value !== "string" || value.length === 0) return null; + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return null; + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null; + // Forward the original value, not `parsed.href`: WHATWG normalisation can + // rewrite the path/query and 404 against byte-sensitive hosting. The server + // re-validates independently, so the scheme check here is the only gate. + return `${ARTIFACT_PROXY_ENDPOINT}?url=${encodeURIComponent(value)}`; +} + +/** A single image artifact lifted off a release record. */ +export interface MediaArtifact { + url: string; + width?: number; + height?: number; +} + +export interface MediaArtifacts { + icon?: MediaArtifact; + banner?: MediaArtifact; + screenshots: MediaArtifact[]; +} + +const SCREENSHOT_OVERFLOW_KEY_RE = /^x-screenshot-(\d+)$/; + +/** + * Narrow one entry of a release's `artifacts` map to the fields we render. + * Returns `null` when the value isn't an object with a string `url`. + * + * Records are lexicon-validated at the DiscoveryClient boundary, but + * `artifacts` is an open map (the `x-screenshot-N` overflow keys are + * unrecognised by the lexicon), so each entry still needs shape-narrowing. + */ +function asMediaArtifact(value: unknown): MediaArtifact | null { + if (!value || typeof value !== "object") return null; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- narrowed to non-null object above; field shapes checked below + const v = value as Record; + if (typeof v.url !== "string" || v.url.length === 0) return null; + const artifact: MediaArtifact = { url: v.url }; + if (typeof v.width === "number") artifact.width = v.width; + if (typeof v.height === "number") artifact.height = v.height; + return artifact; +} + +/** + * Pull icon, banner, and the screenshot gallery out of a release's `artifacts` + * map. The lexicon types `screenshot` as a single artifact, so the gallery is + * the lexicon `screenshot` plus any `x-screenshot-N` overflow keys (the CLI + * writes additional screenshots there), ordered by N. + */ +export function extractMediaArtifacts(artifacts: unknown): MediaArtifacts { + const result: MediaArtifacts = { screenshots: [] }; + if (!artifacts || typeof artifacts !== "object") return result; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- narrowed to non-null object above; each entry is shape-narrowed by asMediaArtifact + const map = artifacts as Record; + + const icon = asMediaArtifact(map.icon); + if (icon) result.icon = icon; + const banner = asMediaArtifact(map.banner); + if (banner) result.banner = banner; + + const first = asMediaArtifact(map.screenshot); + const overflow: Array<{ index: number; artifact: MediaArtifact }> = []; + for (const [key, value] of Object.entries(map)) { + const match = SCREENSHOT_OVERFLOW_KEY_RE.exec(key); + if (!match) continue; + const artifact = asMediaArtifact(value); + if (artifact) overflow.push({ index: Number(match[1]), artifact }); + } + overflow.sort((a, b) => a.index - b.index); + + if (first) result.screenshots.push(first); + for (const entry of overflow) result.screenshots.push(entry.artifact); + return result; +} + // --------------------------------------------------------------------------- // Install (server POST) // --------------------------------------------------------------------------- diff --git a/packages/admin/tests/lib/registry-artifacts.test.ts b/packages/admin/tests/lib/registry-artifacts.test.ts new file mode 100644 index 0000000000..904237ef63 --- /dev/null +++ b/packages/admin/tests/lib/registry-artifacts.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { artifactProxyUrl, extractMediaArtifacts } from "../../src/lib/api/registry"; + +describe("artifactProxyUrl", () => { + it("routes an https artifact URL through the server proxy", () => { + const url = artifactProxyUrl("https://cdn.example.com/gallery/1.0.0/icon.png"); + expect(url).toBe( + "/_emdash/api/admin/plugins/registry/artifact?url=https%3A%2F%2Fcdn.example.com%2Fgallery%2F1.0.0%2Ficon.png", + ); + }); + + it("returns null for a javascript: URL (lexicon uri permits it)", () => { + expect(artifactProxyUrl("javascript:alert(1)")).toBeNull(); + }); + + it("returns null for a relative URL", () => { + expect(artifactProxyUrl("/icon.png")).toBeNull(); + }); + + it("returns null for a non-string / empty value", () => { + expect(artifactProxyUrl(undefined)).toBeNull(); + expect(artifactProxyUrl(42)).toBeNull(); + expect(artifactProxyUrl("")).toBeNull(); + }); +}); + +describe("extractMediaArtifacts", () => { + const icon = { url: "https://x/icon.png", width: 256, height: 256 }; + const banner = { url: "https://x/banner.png", width: 1280, height: 320 }; + const s1 = { url: "https://x/s1.png" }; + const s2 = { url: "https://x/s2.png" }; + const s3 = { url: "https://x/s3.png" }; + + it("returns empty results for non-object input", () => { + expect(extractMediaArtifacts(undefined)).toEqual({ screenshots: [] }); + expect(extractMediaArtifacts(null)).toEqual({ screenshots: [] }); + expect(extractMediaArtifacts("nope")).toEqual({ screenshots: [] }); + }); + + it("extracts icon and banner", () => { + const result = extractMediaArtifacts({ package: { url: "https://x/a.tgz" }, icon, banner }); + expect(result.icon).toEqual(icon); + expect(result.banner).toEqual(banner); + expect(result.screenshots).toEqual([]); + }); + + it("collects the screenshot slot plus x-screenshot-N overflow, in order", () => { + const result = extractMediaArtifacts({ + package: { url: "https://x/a.tgz" }, + screenshot: s1, + "x-screenshot-2": s2, + "x-screenshot-3": s3, + }); + expect(result.screenshots.map((s) => s.url)).toEqual([s1.url, s2.url, s3.url]); + }); + + it("orders overflow keys numerically, not lexically", () => { + const result = extractMediaArtifacts({ + screenshot: s1, + "x-screenshot-10": { url: "https://x/s10.png" }, + "x-screenshot-2": s2, + }); + expect(result.screenshots.map((s) => s.url)).toEqual([s1.url, s2.url, "https://x/s10.png"]); + }); + + it("skips entries without a usable url", () => { + const result = extractMediaArtifacts({ + icon: { width: 10 }, + screenshot: { url: 123 }, + "x-screenshot-2": s2, + }); + expect(result.icon).toBeUndefined(); + // The malformed `screenshot` is dropped; the valid overflow survives. + expect(result.screenshots.map((s) => s.url)).toEqual([s2.url]); + }); +}); diff --git a/packages/core/src/api/handlers/index.ts b/packages/core/src/api/handlers/index.ts index 3f92e6dff5..09be34fef0 100644 --- a/packages/core/src/api/handlers/index.ts +++ b/packages/core/src/api/handlers/index.ts @@ -171,6 +171,7 @@ export { // Registry handlers (experimental) export { + assertSafeArtifactUrl, handleRegistryInstall, handleRegistryUninstall, handleRegistryUpdate, diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index 7fda40712c..ba388c9ae1 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -382,6 +382,11 @@ export function injectCoreRoutes(injectRoute: InjectRoute): void { entrypoint: resolveRoute("api/admin/plugins/registry/install.ts"), }); + injectRoute({ + pattern: "/_emdash/api/admin/plugins/registry/artifact", + entrypoint: resolveRoute("api/admin/plugins/registry/artifact.ts"), + }); + injectRoute({ pattern: "/_emdash/api/admin/plugins/[id]/update", entrypoint: resolveRoute("api/admin/plugins/[id]/update.ts"), diff --git a/packages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts b/packages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts new file mode 100644 index 0000000000..694df746a7 --- /dev/null +++ b/packages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts @@ -0,0 +1,181 @@ +/** + * Registry artifact proxy + * + * GET /_emdash/api/admin/plugins/registry/artifact?url= + * + * Proxies an icon / screenshot / banner image referenced by a registry + * release record so the admin UI can display it without cross-origin + * requests to arbitrary publisher hosting. + * + * Trust model (CRITICAL): unlike the marketplace icon proxy — which fetches + * a single, trusted, operator-configured origin — this proxy fetches an + * ARBITRARY, publisher-supplied URL taken from a registry record. It MUST + * therefore apply the SSRF defences (`assertSafeArtifactUrl`, which wraps + * `resolveAndValidateExternalUrl`) before every fetch, re-validating each + * redirect hop, and serve back only image content types. + */ + +import type { APIRoute } from "astro"; + +import { requirePerm } from "#api/authorize.js"; +import { apiError } from "#api/error.js"; +import { assertSafeArtifactUrl } from "#api/index.js"; + +export const prerender = false; + +/** Image content types the proxy will pass through. Anything else is rejected. */ +const ALLOWED_IMAGE_TYPES = new Set([ + "image/png", + "image/jpeg", + "image/webp", + "image/gif", + "image/svg+xml", + "image/avif", +]); + +/** Cap proxied images so a hostile host can't stream an unbounded body. */ +const MAX_IMAGE_BYTES = 5 * 1024 * 1024; + +/** Redirect hops to follow, re-validating each target against SSRF rules. */ +const MAX_REDIRECTS = 5; + +/** Wall-clock budget covering connect + headers + body. */ +const FETCH_TIMEOUT_MS = 15_000; + +export const GET: APIRoute = async ({ url, locals }) => { + const { emdash, user } = locals; + + if (!emdash?.db) { + return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + } + + const denied = requirePerm(user, "plugins:read"); + if (denied) return denied; + + const target = url.searchParams.get("url"); + if (!target) { + return apiError("INVALID_REQUEST", "Missing artifact url", 400); + } + if (target.length > 2048) { + return apiError("INVALID_REQUEST", "Artifact url too long", 400); + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + // `assertSafeArtifactUrl` validates scheme / credentials / loopback + + // resolves the hostname and rejects private / link-local / metadata + // targets (DNS-rebinding defence). It throws a plain Error on any + // block, so a rejection here means the URL is unsafe. + let current: URL; + try { + current = await assertSafeArtifactUrl(target); + } catch { + return apiError("ARTIFACT_URL_REJECTED", "Artifact URL is not allowed", 400); + } + + let response: Response; + for (let hop = 0; ; hop++) { + response = await fetch(current.href, { redirect: "manual", signal: controller.signal }); + if (response.status < 300 || response.status >= 400) break; + const location = response.headers.get("location"); + if (!location) break; + if (hop === MAX_REDIRECTS) { + return apiError("ARTIFACT_URL_REJECTED", "Too many redirects", 502); + } + let next: URL; + try { + next = await assertSafeArtifactUrl(new URL(location, current).href); + } catch { + return apiError("ARTIFACT_URL_REJECTED", "Redirect target is not allowed", 400); + } + current = next; + } + + if (!response.ok) { + return apiError("ARTIFACT_FETCH_FAILED", "Failed to fetch artifact", 502); + } + + // Content-Type allowlist: only image types are proxied. A non-image + // (HTML error page, JSON, octet-stream) is rejected so the admin + // never renders publisher-controlled markup from the EmDash origin. + const rawType = response.headers.get("content-type") ?? ""; + const contentType = rawType.split(";", 1)[0]!.trim().toLowerCase(); + if (!ALLOWED_IMAGE_TYPES.has(contentType)) { + return apiError("ARTIFACT_NOT_IMAGE", "Artifact is not an allowed image type", 415); + } + + const declaredLength = response.headers.get("content-length"); + if (declaredLength) { + const declared = Number(declaredLength); + if (Number.isFinite(declared) && declared > MAX_IMAGE_BYTES) { + return apiError("ARTIFACT_TOO_LARGE", "Artifact exceeds size limit", 413); + } + } + + const bytes = await readCapped(response, MAX_IMAGE_BYTES); + if (bytes === null) { + return apiError("ARTIFACT_TOO_LARGE", "Artifact exceeds size limit", 413); + } + + // Only the allowlisted Content-Type is forwarded — never copy other + // upstream headers. `private, no-store` keeps publisher images out of + // shared caches in the authenticated admin origin. + // + // SVG is active content: an `` + // rendering — the only way the admin UI uses these — never runs that + // script, but the proxy URL is directly navigable. `Content-Disposition: + // attachment` forces a download instead of rendering, and the sandbox + // CSP neutralises script/plugins if a client renders it anyway. Both + // apply to every image type, not just SVG. + return new Response(bytes, { + headers: { + "Content-Type": contentType, + "Cache-Control": "private, no-store", + "X-Content-Type-Options": "nosniff", + "Content-Disposition": "attachment", + "Content-Security-Policy": "default-src 'none'; sandbox", + }, + }); + } catch { + return apiError("ARTIFACT_FETCH_FAILED", "Failed to fetch artifact", 502); + } finally { + clearTimeout(timer); + } +}; + +/** + * Read a response body into memory, aborting once it exceeds `limit`. Returns + * `null` when the cap is breached (the streamed body lied about / omitted + * Content-Length). The cap is the real defence against an unbounded body. + */ +async function readCapped(response: Response, limit: number): Promise { + const body = response.body; + if (!body) { + const buf = new Uint8Array(await response.arrayBuffer()); + return buf.length > limit ? null : buf; + } + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + total += value.length; + if (total > limit) { + await reader.cancel(); + return null; + } + chunks.push(value); + } + } + const combined = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.length; + } + return combined; +} diff --git a/packages/core/tests/unit/api/registry-artifact-proxy.test.ts b/packages/core/tests/unit/api/registry-artifact-proxy.test.ts new file mode 100644 index 0000000000..18ae555688 --- /dev/null +++ b/packages/core/tests/unit/api/registry-artifact-proxy.test.ts @@ -0,0 +1,184 @@ +/** + * Registry artifact proxy route. + * + * The proxy fetches ARBITRARY publisher-supplied URLs, so it must: + * - reject private / loopback / link-local hosts (SSRF defence), + * - reject non-image content types (allowlist), + * - pass image bytes through with a private, no-store cache header. + * + * We drive the route's `GET` directly with a fabricated context, stub + * `globalThis.fetch`, and inject a DNS resolver so hostnames resolve to + * controlled IPs without real network access. + */ + +import type { APIContext } from "astro"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { GET } from "../../../src/astro/routes/api/admin/plugins/registry/artifact.js"; +import { setDefaultDnsResolver } from "../../../src/security/ssrf.js"; + +const PNG_1x1 = Uint8Array.from( + Buffer.from( + "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c6360000002000100ffff03000006000557bf0a8a0000000049454e44ae426082", + "hex", + ), +); + +// Roles are numeric levels: SUBSCRIBER 10, EDITOR 40, ADMIN 50. `plugins:read` +// requires EDITOR. +const adminUser = { id: "u1", role: 50 }; +const subscriberUser = { id: "v", role: 10 }; + +function makeContext(target: string | null, user: unknown = adminUser): APIContext { + const u = new URL("https://site.test/_emdash/api/admin/plugins/registry/artifact"); + if (target !== null) u.searchParams.set("url", target); + return { + url: u, + locals: { emdash: { db: {} }, user }, + } as unknown as APIContext; +} + +function imageResponse( + bytes: Uint8Array, + contentType = "image/png", + extra: Record = {}, +) { + return new Response(bytes, { status: 200, headers: { "content-type": contentType, ...extra } }); +} + +describe("registry artifact proxy", () => { + let realFetch: typeof globalThis.fetch; + + beforeEach(() => { + realFetch = globalThis.fetch; + // Default: every hostname resolves to a public IP. Individual tests + // override the resolver to exercise private-IP rejection. + setDefaultDnsResolver(async () => ["93.184.216.34"]); + }); + + afterEach(() => { + globalThis.fetch = realFetch; + setDefaultDnsResolver(null); + vi.restoreAllMocks(); + }); + + it("requires authentication", async () => { + const res = await GET(makeContext("https://cdn.example.com/icon.png", null)); + expect(res.status).toBe(401); + }); + + it("forbids users without plugins:read", async () => { + const res = await GET(makeContext("https://cdn.example.com/icon.png", subscriberUser)); + // subscriber lacks plugins:read (editor minimum), so 403. + expect(res.status).toBe(403); + }); + + it("rejects a missing url param", async () => { + const res = await GET(makeContext(null)); + expect(res.status).toBe(400); + }); + + it("passes a happy-path image through with a private cache header", async () => { + globalThis.fetch = vi.fn(async () => imageResponse(PNG_1x1)) as typeof globalThis.fetch; + const res = await GET(makeContext("https://cdn.example.com/icon.png")); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("image/png"); + expect(res.headers.get("cache-control")).toBe("private, no-store"); + expect(res.headers.get("x-content-type-options")).toBe("nosniff"); + // Active-content (SVG) defence: force download + sandbox CSP so a direct + // navigation to the proxy URL can't execute script in the admin origin. + expect(res.headers.get("content-disposition")).toBe("attachment"); + expect(res.headers.get("content-security-policy")).toBe("default-src 'none'; sandbox"); + const body = new Uint8Array(await res.arrayBuffer()); + expect(body).toEqual(PNG_1x1); + }); + + it("normalises a content type with parameters", async () => { + globalThis.fetch = vi.fn(async () => + imageResponse(PNG_1x1, "image/png; charset=binary"), + ) as typeof globalThis.fetch; + const res = await GET(makeContext("https://cdn.example.com/icon.png")); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("image/png"); + }); + + it("rejects a non-image content type", async () => { + globalThis.fetch = vi.fn( + async () => + new Response("nope", { + status: 200, + headers: { "content-type": "text/html" }, + }), + ) as typeof globalThis.fetch; + const res = await GET(makeContext("https://cdn.example.com/icon.png")); + expect(res.status).toBe(415); + }); + + it("rejects octet-stream (no content-type sniffing escape)", async () => { + globalThis.fetch = vi.fn(async () => + imageResponse(PNG_1x1, "application/octet-stream"), + ) as typeof globalThis.fetch; + const res = await GET(makeContext("https://cdn.example.com/icon.png")); + expect(res.status).toBe(415); + }); + + it("rejects a non-http(s) scheme", async () => { + globalThis.fetch = vi.fn(async () => imageResponse(PNG_1x1)) as typeof globalThis.fetch; + const res = await GET(makeContext("file:///etc/passwd")); + expect(res.status).toBe(400); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + // Loopback / localhost are deliberately permitted under `import.meta.env.DEV` + // (the same dev escape hatch `assertSafeArtifactUrl` documents), so they are + // not asserted here — vitest runs in DEV. Production rejection of those is + // covered by `assertSafeArtifactUrl`'s own suite. The link-local, private, + // and DNS-rebinding cases below hold in every environment. + + it("rejects the cloud metadata IP", async () => { + globalThis.fetch = vi.fn(async () => imageResponse(PNG_1x1)) as typeof globalThis.fetch; + const res = await GET(makeContext("http://169.254.169.254/latest/meta-data/")); + expect(res.status).toBe(400); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it("rejects a hostname that resolves to a private IP (DNS rebinding)", async () => { + setDefaultDnsResolver(async () => ["10.0.0.5"]); + globalThis.fetch = vi.fn(async () => imageResponse(PNG_1x1)) as typeof globalThis.fetch; + const res = await GET(makeContext("https://rebind.attacker.test/icon.png")); + expect(res.status).toBe(400); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it("re-validates a redirect target and rejects a private hop", async () => { + setDefaultDnsResolver(async (host) => + host === "cdn.example.com" ? ["93.184.216.34"] : ["169.254.169.254"], + ); + globalThis.fetch = vi.fn( + async () => + new Response(null, { + status: 302, + headers: { location: "http://internal.attacker.test/secret" }, + }), + ) as typeof globalThis.fetch; + const res = await GET(makeContext("https://cdn.example.com/redirect")); + expect(res.status).toBe(400); + }); + + it("rejects an upstream error status", async () => { + globalThis.fetch = vi.fn( + async () => + new Response("not found", { status: 404, headers: { "content-type": "text/plain" } }), + ) as typeof globalThis.fetch; + const res = await GET(makeContext("https://cdn.example.com/missing.png")); + expect(res.status).toBe(502); + }); + + it("rejects an oversized declared content-length", async () => { + globalThis.fetch = vi.fn(async () => + imageResponse(PNG_1x1, "image/png", { "content-length": String(10 * 1024 * 1024) }), + ) as typeof globalThis.fetch; + const res = await GET(makeContext("https://cdn.example.com/huge.png")); + expect(res.status).toBe(413); + }); +}); diff --git a/packages/plugin-cli/package.json b/packages/plugin-cli/package.json index 5b60a7fc01..eb297c0af5 100644 --- a/packages/plugin-cli/package.json +++ b/packages/plugin-cli/package.json @@ -40,7 +40,7 @@ "chokidar": "catalog:", "citty": "^0.1.6", "consola": "^3.4.2", - "image-size": "^2.0.2", + "image-size": "catalog:", "jsonc-parser": "catalog:", "modern-tar": "^0.7.5", "picocolors": "^1.1.1", diff --git a/packages/plugin-cli/schemas/emdash-plugin.schema.json b/packages/plugin-cli/schemas/emdash-plugin.schema.json index 690e5a0d0a..8556ded8b4 100644 --- a/packages/plugin-cli/schemas/emdash-plugin.schema.json +++ b/packages/plugin-cli/schemas/emdash-plugin.schema.json @@ -55,6 +55,9 @@ }, "repo": { "$ref": "#/$defs/__schema42" + }, + "release": { + "$ref": "#/$defs/__schema43" } }, "required": [ @@ -465,6 +468,83 @@ "examples": [ "https://github.com/emdash-cms/plugin-gallery" ] + }, + "__schema43": { + "type": "object", + "properties": { + "artifacts": { + "$ref": "#/$defs/__schema44" + } + }, + "additionalProperties": false, + "title": "Release", + "description": "Per-release fields, such as media artifacts (icon / screenshot / banner)." + }, + "__schema44": { + "type": "object", + "properties": { + "icon": { + "$ref": "#/$defs/__schema45" + }, + "banner": { + "$ref": "#/$defs/__schema49" + }, + "screenshot": { + "$ref": "#/$defs/__schema50" + } + }, + "additionalProperties": false, + "title": "Artifacts", + "description": "Release media artifacts. `icon` and `banner` are single images; `screenshot` is a gallery array." + }, + "__schema45": { + "$ref": "#/$defs/__schema46" + }, + "__schema46": { + "type": "object", + "properties": { + "file": { + "$ref": "#/$defs/__schema47" + }, + "lang": { + "$ref": "#/$defs/__schema48" + } + }, + "required": [ + "file" + ], + "additionalProperties": false, + "title": "Artifact file reference", + "description": "A media file (PNG / JPEG / WebP / GIF / SVG) bundled into a release as an icon, screenshot, or banner." + }, + "__schema47": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Path to the image file, relative to the manifest. Resolved, hashed, measured, and uploaded at publish time." + }, + "__schema48": { + "type": "string", + "minLength": 2, + "maxLength": 64, + "description": "BCP 47 language tag for a localised artifact (e.g. \"en\", \"pt-BR\").", + "examples": [ + "en", + "pt-BR" + ] + }, + "__schema49": { + "$ref": "#/$defs/__schema46" + }, + "__schema50": { + "minItems": 1, + "maxItems": 8, + "type": "array", + "items": { + "$ref": "#/$defs/__schema46" + }, + "title": "Screenshots", + "description": "Screenshot gallery for the plugin's detail page (<= 8 entries)." } } } diff --git a/packages/plugin-cli/src/commands/publish.ts b/packages/plugin-cli/src/commands/publish.ts index 6ff3a5b83a..46400e3c15 100644 --- a/packages/plugin-cli/src/commands/publish.ts +++ b/packages/plugin-cli/src/commands/publish.ts @@ -44,7 +44,9 @@ import { publishRelease, type ProfileInput, type PublishLogger, + type ReleaseArtifactsInput, } from "../publish/api.js"; +import { ArtifactUploadError, resolveReleaseArtifacts } from "../publish/upload-artifacts.js"; /** * Hard cap on the gzipped tarball we'll buffer into memory. Sized for the @@ -116,6 +118,11 @@ export const publishCommand = defineCommand({ "Allow overwriting an existing release at :. Default refuses, since FAIR treats version records as immutable and aggregators/labellers may flag any change as a takedown event.", default: false, }, + "artifact-base-url": { + type: "string", + description: + "Base URL the CLI PUTs media artifacts (icon / screenshot / banner) to. Each file is uploaded to /// and that URL is recorded in the release. The target must serve the bytes back unchanged with a stable content type. Required when the manifest declares any `release.artifacts`.", + }, json: { type: "boolean", description: @@ -303,6 +310,8 @@ async function runPublish(args: PublishArgs): Promise { warn: (m) => consola.warn(m), }; + const artifacts = await resolveManifestArtifacts(args, manifestLoad, logger); + const result = await publishRelease({ publisher, did: session.did, @@ -311,6 +320,7 @@ async function runPublish(args: PublishArgs): Promise { url: args.url, profileInput, repo: manifestLoad?.manifest.repo, + artifacts, allowOverwrite: args["allow-overwrite"], logger, }); @@ -371,6 +381,59 @@ async function runPublish(args: PublishArgs): Promise { console.log(` ${pc.cyan(`emdash-plugin info ${session.handle ?? session.did} ${result.slug}`)}`); } +/** + * Resolve the manifest's `release.artifacts` block into uploaded, embeddable + * records. Returns `undefined` when no manifest was loaded or it declared no + * artifacts. Throws `CliError` when artifacts are declared but the publisher + * didn't supply `--artifact-base-url`, or when resolution/upload fails. + */ +async function resolveManifestArtifacts( + args: PublishArgs, + manifestLoad: ManifestLoadOutcome | null, + logger: PublishLogger, +): Promise { + const artifacts = manifestLoad?.manifest.artifacts; + if (!manifestLoad || !artifacts) return undefined; + + const baseUrl = args["artifact-base-url"]; + if (baseUrl === undefined || baseUrl.length === 0) { + throw new CliError( + "The manifest declares `release.artifacts` (icon / screenshot / banner) but no --artifact-base-url was given. Pass --artifact-base-url so the CLI can upload the images and record where they're hosted.", + 2, + "ARTIFACT_BASE_URL_REQUIRED", + ); + } + let parsedBase: URL; + try { + parsedBase = new URL(baseUrl); + } catch { + throw new CliError(`--artifact-base-url is not a valid URL: ${baseUrl}`, 2, "INVALID_FLAG"); + } + if (parsedBase.protocol !== "https:") { + throw new CliError( + `--artifact-base-url must use https; got ${parsedBase.protocol}. Host artifacts over TLS.`, + 2, + "INVALID_FLAG", + ); + } + + try { + return await resolveReleaseArtifacts({ + artifacts, + manifestDir: dirname(manifestLoad.path), + baseUrl, + slug: manifestLoad.manifest.slug, + version: manifestLoad.manifest.version, + logger, + }); + } catch (error) { + if (error instanceof ArtifactUploadError) { + throw new CliError(error.message, 1, error.code); + } + throw error; + } +} + /** * Render any error from the publish flow, in human or JSON shape. Always * writes to stderr (consola was already redirected for --json mode); in @@ -435,6 +498,7 @@ type PublishArgs = { manifest?: string; "no-manifest"?: boolean; "allow-overwrite"?: boolean; + "artifact-base-url"?: string; json?: boolean; }; diff --git a/packages/plugin-cli/src/manifest/schema.ts b/packages/plugin-cli/src/manifest/schema.ts index ca4b087dbc..cb3ee9d64e 100644 --- a/packages/plugin-cli/src/manifest/schema.ts +++ b/packages/plugin-cli/src/manifest/schema.ts @@ -526,6 +526,91 @@ export const AdminSchema = z "Pages and widgets the plugin exposes in the admin UI. The plugin's `admin` route handler renders Block Kit content for each path / widget id at runtime.", }); +// ────────────────────────────────────────────────────────────────────────── +// Media artifacts (icon / screenshot / banner) +// ────────────────────────────────────────────────────────────────────────── + +/** + * BCP 47 language tag for a localised artifact. Mirrors `release.json#artifact.lang`. + * Structural check only — the registry aggregator owns the strict grammar. + */ +const ArtifactLangSchema = z + .string() + .min(2, 'lang must be a BCP 47 language tag (e.g. "en", "pt-BR")') + .max(64, "lang must be <= 64 characters") + .meta({ + description: 'BCP 47 language tag for a localised artifact (e.g. "en", "pt-BR").', + examples: ["en", "pt-BR"], + }); + +/** + * A single media-artifact file reference. The `file` path is resolved relative + * to the manifest at publish time; the CLI reads the bytes, computes the + * checksum and pixel dimensions, uploads them to the publisher's artifact + * hosting, and writes a `#artifact` record (url, checksum, contentType, width, + * height, lang?) into the release. Only the authoring inputs live here — the + * derived fields never appear in the manifest. + */ +export const ArtifactFileSchema = z + .object({ + file: z + .string() + .min(1, "artifact `file` path cannot be empty") + .max(1024, "artifact `file` path must be <= 1024 characters") + .meta({ + description: + "Path to the image file, relative to the manifest. Resolved, hashed, measured, and uploaded at publish time.", + }), + lang: ArtifactLangSchema.optional(), + }) + .strict() + .meta({ + title: "Artifact file reference", + description: + "A media file (PNG / JPEG / WebP / GIF / SVG) bundled into a release as an icon, screenshot, or banner.", + }); + +/** + * Release media artifacts. `icon` and `banner` are single files; `screenshot` + * is an array (a plugin can ship a gallery). Mirrors `release.json#artifacts` + * minus the `package` entry, which the CLI derives from the tarball. + */ +export const ArtifactsSchema = z + .object({ + icon: ArtifactFileSchema.optional(), + banner: ArtifactFileSchema.optional(), + screenshot: z + .array(ArtifactFileSchema) + .min(1, "screenshot[] must have at least one entry when set") + .max(8, "screenshot[] must have <= 8 entries") + .meta({ + title: "Screenshots", + description: "Screenshot gallery for the plugin's detail page (<= 8 entries).", + }) + .optional(), + }) + .strict() + .meta({ + title: "Artifacts", + description: + "Release media artifacts. `icon` and `banner` are single images; `screenshot` is a gallery array.", + }); + +/** + * Release-level block. Holds fields scoped to a single version rather than the + * package profile. Today that's media `artifacts`; the source `repo` stays at + * the top level for backwards compatibility. + */ +export const ReleaseSchema = z + .object({ + artifacts: ArtifactsSchema.optional(), + }) + .strict() + .meta({ + title: "Release", + description: "Per-release fields, such as media artifacts (icon / screenshot / banner).", + }); + // ────────────────────────────────────────────────────────────────────────── // Top-level manifest // ────────────────────────────────────────────────────────────────────────── @@ -631,6 +716,10 @@ export const ManifestSchema = z // Optional release fields. repo: RepoSchema.optional(), + + // Per-release media artifacts (icon / screenshot / banner). File + // refs are resolved relative to the manifest at publish time. + release: ReleaseSchema.optional(), }) .strict() .refine((v) => !(v.author !== undefined && v.authors !== undefined), { @@ -706,3 +795,9 @@ export type ManifestAuthor = z.infer; /** A single security contact entry, normalised. */ export type ManifestSecurityContact = z.infer; + +/** A single media-artifact file reference (icon / screenshot / banner). */ +export type ManifestArtifactFile = z.infer; + +/** The release media-artifacts block. */ +export type ManifestArtifacts = z.infer; diff --git a/packages/plugin-cli/src/manifest/translate.ts b/packages/plugin-cli/src/manifest/translate.ts index 1c8e15ed93..25428abae2 100644 --- a/packages/plugin-cli/src/manifest/translate.ts +++ b/packages/plugin-cli/src/manifest/translate.ts @@ -9,7 +9,12 @@ import type { PluginCapability, PluginStorageConfig } from "@emdash-cms/plugin-types"; import type { ProfileBootstrap, ProfileInput } from "../publish/api.js"; -import type { Manifest, ManifestAuthor, ManifestSecurityContact } from "./schema.js"; +import type { + Manifest, + ManifestArtifacts, + ManifestAuthor, + ManifestSecurityContact, +} from "./schema.js"; /** * Normalised "after the schema's single/multi convenience has been @@ -45,6 +50,13 @@ export interface NormalisedManifest { keywords: string[] | undefined; repo: string | undefined; + /** + * Release media artifacts (icon / screenshot / banner). File refs only — + * the publish command resolves, measures, and uploads them. `undefined` + * when the manifest declared none. + */ + artifacts: ManifestArtifacts | undefined; + // Trust contract (defaults applied by the schema; always present here). capabilities: PluginCapability[]; allowedHosts: string[]; @@ -166,6 +178,7 @@ export function normaliseManifest(manifest: Manifest, packageVersion?: string): description: manifest.description, keywords: manifest.keywords, repo: manifest.repo, + artifacts: manifest.release?.artifacts, // Schema validation already gates capability strings to the // current vocabulary via a runtime check, so by the time we get // here the strings are guaranteed members of PluginCapability. diff --git a/packages/plugin-cli/src/publish/api.ts b/packages/plugin-cli/src/publish/api.ts index 84b47a0577..e2e1c8ad96 100644 --- a/packages/plugin-cli/src/publish/api.ts +++ b/packages/plugin-cli/src/publish/api.ts @@ -132,6 +132,32 @@ export interface ProfileInput { keywords?: string[]; } +/** + * A resolved image artifact ready to embed in the release. The CLI command + * reads the file, computes the checksum, measures the dimensions, and uploads + * the bytes before constructing this; `publishRelease` only writes it. + */ +export interface ReleaseArtifactInput { + url: string; + checksum: string; + contentType: string; + width: number; + height: number; + lang?: string; +} + +/** + * Resolved release media artifacts. `icon` / `banner` are single images; + * `screenshots` is the ordered gallery. The first screenshot is written to the + * lexicon's `artifacts.screenshot` slot and the rest to `x-screenshot-N` + * custom keys. + */ +export interface ReleaseArtifactsInput { + icon?: ReleaseArtifactInput; + banner?: ReleaseArtifactInput; + screenshots?: ReleaseArtifactInput[]; +} + export interface PublishOptions { /** Authenticated client against the publisher's PDS. */ publisher: PublishingClient; @@ -162,6 +188,13 @@ export interface PublishOptions { * immutable per version, so this is not a first-publish-only field. */ repo?: string; + /** + * Resolved media artifacts (icon / screenshot / banner) for this release. + * Already uploaded and measured by the caller. Written verbatim into the + * release record. Releases are immutable per version, so this is not a + * first-publish-only field. + */ + artifacts?: ReleaseArtifactsInput; /** * Allow overwriting an existing release at `:`. Default * is `false`, which causes publish to refuse with `RELEASE_ALREADY_PUBLISHED`. @@ -232,6 +265,16 @@ interface PackageProfileRecordShape { keywords?: string[]; } +/** An image artifact embedded in a release (`release.json#artifact`). */ +interface ImageArtifact { + url: string; + checksum: string; + contentType: string; + width: number; + height: number; + lang?: string; +} + interface PackageReleaseRecordShape { $type: typeof NSID.packageRelease; package: string; @@ -242,6 +285,17 @@ interface PackageReleaseRecordShape { checksum: string; contentType?: string; }; + icon?: ImageArtifact; + banner?: ImageArtifact; + /** + * First screenshot. The lexicon's `artifacts` object types + * `screenshot` as a single `#artifact`, so additional screenshots + * ride along under `x-screenshot-N` custom keys (which the lexicon + * sanctions: "Custom types use 'x-' prefix and pass through as + * unrecognised fields"). + */ + screenshot?: ImageArtifact; + [extraScreenshot: `x-screenshot-${number}`]: ImageArtifact | undefined; }; /** Source-repository URL (`release.repo`). Omitted when not provided. */ repo?: string; @@ -400,6 +454,7 @@ export async function publishRelease(options: PublishOptions): Promise { + if (index === 0) { + record.artifacts.screenshot = { ...shot }; + } else { + record.artifacts[`x-screenshot-${index + 1}`] = { ...shot }; + } + }); +} + /** * Determine which capabilities in `normalizedCaps` have no representation * in the `declaredAccess` we just built. The mapping rules are derived diff --git a/packages/plugin-cli/src/publish/artifacts.ts b/packages/plugin-cli/src/publish/artifacts.ts new file mode 100644 index 0000000000..2be0879c1f --- /dev/null +++ b/packages/plugin-cli/src/publish/artifacts.ts @@ -0,0 +1,121 @@ +/** + * Media-artifact resolution for the publish flow. + * + * Given the bytes of an image file and the public URL it's hosted at, build + * the `#artifact` record the release embeds: the multibase-multihash checksum, + * the MIME content type, and the pixel dimensions. Dimensions come from + * `image-size`, which reads only the header bytes (no decode), so it's cheap + * and works for PNG / JPEG / WebP / GIF / SVG. + * + * Kept filesystem- and network-free so it tests against raw byte fixtures. + * The CLI command reads files and uploads them; this module turns bytes + + * URL into a record. + */ + +import { imageSize } from "image-size"; + +import { sha256Multihash } from "../multihash.js"; + +/** An artifact record ready to embed in a release. Mirrors `release.json#artifact`. */ +export interface ArtifactRecord { + url: string; + checksum: string; + contentType: string; + width: number; + height: number; + lang?: string; +} + +/** + * Image formats `image-size` reports that we accept as plugin artifacts, mapped + * to their canonical MIME type. The `type` field is the format name from the + * header sniff; we don't trust a file extension for the content type. + */ +/** Per-dimension pixel ceiling, matching `release.json#artifact.width/height`. */ +const MAX_ARTIFACT_DIMENSION = 8192; + +const TYPE_TO_CONTENT_TYPE: Record = { + png: "image/png", + jpg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", +}; + +/** Thrown when an artifact file isn't a supported image. */ +export class ArtifactError extends Error { + override readonly name = "ArtifactError"; + readonly code: "ARTIFACT_UNSUPPORTED" | "ARTIFACT_UNREADABLE"; + + constructor(code: "ARTIFACT_UNSUPPORTED" | "ARTIFACT_UNREADABLE", message: string) { + super(message); + this.code = code; + } +} + +/** + * Sniff `bytes` as an image and return its content type and dimensions. Throws + * `ArtifactError` when the bytes aren't a supported image or carry no usable + * dimensions (e.g. a width-less SVG that `image-size` can't measure). + */ +export function measureImage(bytes: Uint8Array): { + contentType: string; + width: number; + height: number; +} { + let result: { width?: number; height?: number; type?: string }; + try { + result = imageSize(bytes); + } catch (error) { + throw new ArtifactError( + "ARTIFACT_UNREADABLE", + `Artifact is not a recognised image: ${error instanceof Error ? error.message : String(error)}`, + ); + } + const type = result.type; + if (type === undefined || !(type in TYPE_TO_CONTENT_TYPE)) { + throw new ArtifactError( + "ARTIFACT_UNSUPPORTED", + `Artifact image format ${type ? `"${type}"` : "(unknown)"} is not supported. Use PNG, JPEG, WebP, GIF, or SVG.`, + ); + } + const { width, height } = result; + if (typeof width !== "number" || typeof height !== "number" || width < 1 || height < 1) { + throw new ArtifactError( + "ARTIFACT_UNSUPPORTED", + "Artifact image has no readable pixel dimensions.", + ); + } + // The lexicon caps artifact dimensions at 8192px; a larger image would be + // rejected at publish-time lexicon validation with an opaque error. + if (width > MAX_ARTIFACT_DIMENSION || height > MAX_ARTIFACT_DIMENSION) { + throw new ArtifactError( + "ARTIFACT_UNSUPPORTED", + `Artifact image is ${width}x${height}px; each dimension must be <= ${MAX_ARTIFACT_DIMENSION}px.`, + ); + } + return { contentType: TYPE_TO_CONTENT_TYPE[type]!, width, height }; +} + +/** + * Build the `#artifact` record for an image hosted at `url`. Computes the + * checksum from the same bytes the consumer will fetch, and reads the + * dimensions and content type from the header. `lang` is carried through when + * the manifest set it. + */ +export function buildArtifactRecord(input: { + bytes: Uint8Array; + url: string; + lang?: string; +}): ArtifactRecord { + const { contentType, width, height } = measureImage(input.bytes); + const record: ArtifactRecord = { + url: input.url, + checksum: sha256Multihash(input.bytes), + contentType, + width, + height, + }; + if (input.lang !== undefined) record.lang = input.lang; + return record; +} diff --git a/packages/plugin-cli/src/publish/upload-artifacts.ts b/packages/plugin-cli/src/publish/upload-artifacts.ts new file mode 100644 index 0000000000..dbf886ac94 --- /dev/null +++ b/packages/plugin-cli/src/publish/upload-artifacts.ts @@ -0,0 +1,194 @@ +/** + * Resolve, upload, and record a manifest's media artifacts. + * + * The manifest declares artifacts as file refs (`{ file: "./icon.png" }`) + * relative to itself. At publish time the CLI: + * + * 1. resolves each ref under the manifest directory (rejecting paths that + * escape it), + * 2. reads the bytes and measures content type + dimensions, + * 3. PUTs the bytes to `///`, + * 4. records `{ url, checksum, contentType, width, height, lang? }`. + * + * The hosting contract: the publisher's `--artifact-base-url` target must + * accept the PUT and serve the same bytes back, unchanged, with a stable + * content type, at the URL we record. Consumers fetch through the EmDash + * server's SSRF-defended proxy. + */ + +import { readFile } from "node:fs/promises"; +import { basename, relative, resolve, sep } from "node:path"; + +import type { ManifestArtifacts, ManifestArtifactFile } from "../manifest/schema.js"; +import type { ReleaseArtifactInput, ReleaseArtifactsInput } from "./api.js"; +import { ArtifactError, buildArtifactRecord } from "./artifacts.js"; + +/** Hard cap on a single artifact file, so a runaway image can't OOM the CLI. */ +const MAX_ARTIFACT_BYTES = 2 * 1024 * 1024; + +/** Strip trailing slashes from the artifact base URL. */ +const TRAILING_SLASHES = /\/+$/; + +export interface ResolveArtifactsOptions { + /** Parsed `release.artifacts` block, or `undefined` when none declared. */ + artifacts: ManifestArtifacts | undefined; + /** Absolute path to the directory containing the manifest. */ + manifestDir: string; + /** Base URL the CLI PUTs artifact bytes to (no trailing slash required). */ + baseUrl: string; + /** Plugin slug, used in the upload path. */ + slug: string; + /** Release version, used in the upload path. */ + version: string; + /** Optional progress reporter. */ + logger?: { info?(m: string): void; success?(m: string): void }; + /** + * Injectable uploader. Defaults to an HTTP PUT. Tests pass a stub so the + * resolve flow runs without a network. + */ + upload?: ArtifactUploader; +} + +/** + * Uploads `bytes` to `url` with the given content type and resolves once the + * bytes are durably stored. Throws on any non-success. + */ +export type ArtifactUploader = (input: { + url: string; + bytes: Uint8Array; + contentType: string; +}) => Promise; + +/** Thrown when artifact resolution or upload fails. */ +export class ArtifactUploadError extends Error { + override readonly name = "ArtifactUploadError"; + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.code = code; + } +} + +/** + * Resolve every declared artifact to an embeddable record, uploading the bytes + * along the way. Returns `undefined` when the manifest declared no artifacts. + */ +export async function resolveReleaseArtifacts( + options: ResolveArtifactsOptions, +): Promise { + const { artifacts } = options; + if (!artifacts) return undefined; + if (!artifacts.icon && !artifacts.banner && !(artifacts.screenshot?.length ?? 0)) { + return undefined; + } + + const upload = options.upload ?? httpPutUploader; + const out: ReleaseArtifactsInput = {}; + + if (artifacts.icon) { + out.icon = await resolveOne(artifacts.icon, "icon", options, upload); + } + if (artifacts.banner) { + out.banner = await resolveOne(artifacts.banner, "banner", options, upload); + } + if (artifacts.screenshot && artifacts.screenshot.length > 0) { + const screenshots: ReleaseArtifactInput[] = []; + for (const [index, ref] of artifacts.screenshot.entries()) { + screenshots.push(await resolveOne(ref, `screenshot ${index + 1}`, options, upload)); + } + out.screenshots = screenshots; + } + + return out; +} + +async function resolveOne( + ref: ManifestArtifactFile, + label: string, + options: ResolveArtifactsOptions, + upload: ArtifactUploader, +): Promise { + const absolute = resolveWithinManifest(options.manifestDir, ref.file, label); + let bytes: Uint8Array; + try { + bytes = await readFile(absolute); + } catch (error) { + throw new ArtifactUploadError( + "ARTIFACT_FILE_UNREADABLE", + `Could not read ${label} artifact at ${ref.file}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (bytes.length > MAX_ARTIFACT_BYTES) { + throw new ArtifactUploadError( + "ARTIFACT_TOO_LARGE", + `${label} artifact ${ref.file} is ${bytes.length} bytes, exceeding the ${MAX_ARTIFACT_BYTES}-byte limit.`, + ); + } + + let record; + try { + record = buildArtifactRecord({ + bytes, + url: artifactUrl(options.baseUrl, options.slug, options.version, ref.file), + lang: ref.lang, + }); + } catch (error) { + if (error instanceof ArtifactError) { + throw new ArtifactUploadError(error.code, `${label} artifact: ${error.message}`); + } + throw error; + } + + options.logger?.info?.(`Uploading ${label} (${record.width}x${record.height}) -> ${record.url}`); + try { + await upload({ url: record.url, bytes, contentType: record.contentType }); + } catch (error) { + throw new ArtifactUploadError( + "ARTIFACT_UPLOAD_FAILED", + `Failed to upload ${label} artifact to ${record.url}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + options.logger?.success?.(`Uploaded ${label}`); + return record; +} + +/** + * Resolve `file` under `manifestDir` and refuse paths that escape it (via + * `..` or an absolute path). The manifest is publisher-authored, but a + * traversal would let `publish` read arbitrary files off the machine running + * the CLI and upload them, so the boundary is enforced. + */ +function resolveWithinManifest(manifestDir: string, file: string, label: string): string { + const absolute = resolve(manifestDir, file); + const rel = relative(manifestDir, absolute); + if (rel === "" || rel.startsWith("..") || rel.startsWith(`..${sep}`)) { + throw new ArtifactUploadError( + "ARTIFACT_PATH_ESCAPE", + `${label} artifact path ${file} resolves outside the manifest directory.`, + ); + } + return absolute; +} + +/** + * Build the public URL for an artifact: `///`. + * Only the basename of the manifest ref is used so nested source paths don't + * leak into the published URL. + */ +function artifactUrl(baseUrl: string, slug: string, version: string, file: string): string { + const trimmed = baseUrl.replace(TRAILING_SLASHES, ""); + const name = basename(file); + return `${trimmed}/${encodeURIComponent(slug)}/${encodeURIComponent(version)}/${encodeURIComponent(name)}`; +} + +const httpPutUploader: ArtifactUploader = async ({ url, bytes, contentType }) => { + const response = await fetch(url, { + method: "PUT", + headers: { "Content-Type": contentType }, + body: bytes, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status} ${response.statusText}`); + } +}; diff --git a/packages/plugin-cli/tests/manifest-schema.test.ts b/packages/plugin-cli/tests/manifest-schema.test.ts index c90f035a18..8764f25a60 100644 --- a/packages/plugin-cli/tests/manifest-schema.test.ts +++ b/packages/plugin-cli/tests/manifest-schema.test.ts @@ -15,6 +15,8 @@ import { describe, expect, it } from "vitest"; import { + ArtifactFileSchema, + ArtifactsSchema, AuthorSchema, LicenseSchema, ManifestSchema, @@ -121,6 +123,59 @@ describe("RepoSchema", () => { }); }); +describe("ArtifactFileSchema", () => { + it("accepts a bare file ref", () => { + expect(ArtifactFileSchema.safeParse({ file: "./icon.png" }).success).toBe(true); + }); + + it("accepts a file ref with a lang tag", () => { + expect(ArtifactFileSchema.safeParse({ file: "./icon-fr.png", lang: "fr" }).success).toBe(true); + }); + + it("rejects an empty file path", () => { + expect(ArtifactFileSchema.safeParse({ file: "" }).success).toBe(false); + }); + + it("rejects unknown keys (e.g. a hand-written url/checksum)", () => { + const result = ArtifactFileSchema.safeParse({ + file: "./icon.png", + url: "https://example.com/icon.png", + }); + expect(result.success).toBe(false); + }); +}); + +describe("ArtifactsSchema", () => { + it("accepts icon and banner as single file refs", () => { + const result = ArtifactsSchema.safeParse({ + icon: { file: "./icon.png" }, + banner: { file: "./banner.png" }, + }); + expect(result.success).toBe(true); + }); + + it("accepts screenshot as an array of file refs", () => { + const result = ArtifactsSchema.safeParse({ + screenshot: [{ file: "./s1.png" }, { file: "./s2.png", lang: "de" }], + }); + expect(result.success).toBe(true); + }); + + it("rejects a single (non-array) screenshot", () => { + const result = ArtifactsSchema.safeParse({ screenshot: { file: "./s1.png" } }); + expect(result.success).toBe(false); + }); + + it("rejects an empty screenshot array", () => { + expect(ArtifactsSchema.safeParse({ screenshot: [] }).success).toBe(false); + }); + + it("rejects more than eight screenshots", () => { + const screenshot = Array.from({ length: 9 }, (_, i) => ({ file: `./s${i}.png` })); + expect(ArtifactsSchema.safeParse({ screenshot }).success).toBe(false); + }); +}); + describe("ManifestSchema (full document)", () => { const minimal = { slug: "my-plugin", @@ -136,6 +191,28 @@ describe("ManifestSchema (full document)", () => { expect(result.success).toBe(true); }); + it("accepts a manifest with a release.artifacts block", () => { + const result = ManifestSchema.safeParse({ + ...minimal, + release: { + artifacts: { + icon: { file: "./icon.png" }, + banner: { file: "./banner.png" }, + screenshot: [{ file: "./s1.png" }, { file: "./s2.png" }], + }, + }, + }); + expect(result.success).toBe(true); + }); + + it("rejects an unknown key inside release", () => { + const result = ManifestSchema.safeParse({ + ...minimal, + release: { artifacts: { icon: { file: "./icon.png" } }, bogus: true }, + }); + expect(result.success).toBe(false); + }); + it("accepts a manifest with $schema for IDE completion", () => { const result = ManifestSchema.safeParse({ ...minimal, diff --git a/packages/plugin-cli/tests/publish-artifacts.test.ts b/packages/plugin-cli/tests/publish-artifacts.test.ts new file mode 100644 index 0000000000..f0efb82790 --- /dev/null +++ b/packages/plugin-cli/tests/publish-artifacts.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; + +import { ArtifactError, buildArtifactRecord, measureImage } from "../src/publish/artifacts.js"; + +/** + * A 1x1 transparent PNG. `image-size` reads the IHDR chunk from the header, + * so the full image isn't needed — but this is a real, decodable PNG. + */ +const PNG_1x1 = Uint8Array.from( + Buffer.from( + "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c6360000002000100ffff03000006000557bf0a8a0000000049454e44ae426082", + "hex", + ), +); + +/** A 3x5 GIF87a. The logical-screen descriptor at bytes 6-9 carries the size. */ +const GIF_3x5 = Uint8Array.from(Buffer.from("4749463837610300050080000000000000ffffff", "hex")); + +/** A minimal SVG with explicit width/height attributes. */ +const SVG_12x8 = new TextEncoder().encode( + '', +); + +describe("measureImage", () => { + it("reads PNG dimensions and content type from header bytes", () => { + expect(measureImage(PNG_1x1)).toEqual({ + contentType: "image/png", + width: 1, + height: 1, + }); + }); + + it("reads GIF dimensions and content type", () => { + expect(measureImage(GIF_3x5)).toEqual({ + contentType: "image/gif", + width: 3, + height: 5, + }); + }); + + it("reads SVG dimensions and maps to image/svg+xml", () => { + expect(measureImage(SVG_12x8)).toEqual({ + contentType: "image/svg+xml", + width: 12, + height: 8, + }); + }); + + it("rejects bytes that are not a recognised image", () => { + const garbage = new TextEncoder().encode("this is not an image"); + expect(() => measureImage(garbage)).toThrow(ArtifactError); + }); + + it("rejects an image whose format isn't in the allowlist", () => { + // A BMP header — image-size recognises it, but it's not an allowed type. + const bmp = Uint8Array.from( + Buffer.from("424d3a0000000000000036000000280000000100000001000000", "hex"), + ); + try { + measureImage(bmp); + throw new Error("expected measureImage to throw"); + } catch (error) { + expect(error).toBeInstanceOf(ArtifactError); + expect((error as ArtifactError).code).toBe("ARTIFACT_UNSUPPORTED"); + } + }); +}); + +describe("buildArtifactRecord", () => { + it("writes url, checksum, contentType, and dimensions", () => { + const record = buildArtifactRecord({ + bytes: PNG_1x1, + url: "https://cdn.example.com/gallery/1.0.0/icon.png", + }); + expect(record).toMatchObject({ + url: "https://cdn.example.com/gallery/1.0.0/icon.png", + contentType: "image/png", + width: 1, + height: 1, + }); + // Multibase-multihash sha2-256: base32 prefix `b`, 56 chars total. + expect(record.checksum).toMatch(/^b[a-z2-7]+$/); + expect(record.checksum).toHaveLength(56); + }); + + it("carries lang through when set", () => { + const record = buildArtifactRecord({ + bytes: PNG_1x1, + url: "https://cdn.example.com/gallery/1.0.0/icon-fr.png", + lang: "fr", + }); + expect(record.lang).toBe("fr"); + }); + + it("omits lang when not set", () => { + const record = buildArtifactRecord({ + bytes: PNG_1x1, + url: "https://cdn.example.com/gallery/1.0.0/icon.png", + }); + expect(record).not.toHaveProperty("lang"); + }); + + it("derives the same checksum the consumer would compute over the bytes", () => { + const a = buildArtifactRecord({ bytes: PNG_1x1, url: "https://x/a.png" }); + const b = buildArtifactRecord({ bytes: PNG_1x1, url: "https://x/b.png" }); + expect(a.checksum).toBe(b.checksum); + }); +}); diff --git a/packages/plugin-cli/tests/publish-upload-artifacts.test.ts b/packages/plugin-cli/tests/publish-upload-artifacts.test.ts new file mode 100644 index 0000000000..1d55b50145 --- /dev/null +++ b/packages/plugin-cli/tests/publish-upload-artifacts.test.ts @@ -0,0 +1,163 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + ArtifactUploadError, + resolveReleaseArtifacts, + type ArtifactUploader, +} from "../src/publish/upload-artifacts.js"; + +const PNG_1x1 = Uint8Array.from( + Buffer.from( + "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c6360000002000100ffff03000006000557bf0a8a0000000049454e44ae426082", + "hex", + ), +); + +interface Uploaded { + url: string; + contentType: string; + bytes: number; +} + +function recordingUploader(): { uploader: ArtifactUploader; uploads: Uploaded[] } { + const uploads: Uploaded[] = []; + const uploader: ArtifactUploader = async ({ url, contentType, bytes }) => { + uploads.push({ url, contentType, bytes: bytes.length }); + }; + return { uploader, uploads }; +} + +describe("resolveReleaseArtifacts", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "emdash-artifacts-")); + await writeFile(join(dir, "icon.png"), PNG_1x1); + await writeFile(join(dir, "banner.png"), PNG_1x1); + await writeFile(join(dir, "s1.png"), PNG_1x1); + await writeFile(join(dir, "s2.png"), PNG_1x1); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("returns undefined when no artifacts are declared", async () => { + const result = await resolveReleaseArtifacts({ + artifacts: undefined, + manifestDir: dir, + baseUrl: "https://cdn.example.com", + slug: "gallery", + version: "1.0.0", + upload: recordingUploader().uploader, + }); + expect(result).toBeUndefined(); + }); + + it("uploads and records icon, banner, and a screenshot gallery", async () => { + const { uploader, uploads } = recordingUploader(); + const result = await resolveReleaseArtifacts({ + artifacts: { + icon: { file: "./icon.png" }, + banner: { file: "./banner.png" }, + screenshot: [{ file: "./s1.png" }, { file: "./s2.png", lang: "de" }], + }, + manifestDir: dir, + baseUrl: "https://cdn.example.com/", + slug: "gallery", + version: "1.0.0", + upload: uploader, + }); + + expect(result?.icon).toMatchObject({ + url: "https://cdn.example.com/gallery/1.0.0/icon.png", + contentType: "image/png", + width: 1, + height: 1, + }); + expect(result?.banner?.url).toBe("https://cdn.example.com/gallery/1.0.0/banner.png"); + expect(result?.screenshots).toHaveLength(2); + expect(result?.screenshots?.[0]?.url).toBe("https://cdn.example.com/gallery/1.0.0/s1.png"); + expect(result?.screenshots?.[1]?.lang).toBe("de"); + + // One PUT per artifact, with the measured content type. + expect(uploads).toHaveLength(4); + expect(uploads.every((u) => u.contentType === "image/png")).toBe(true); + }); + + it("preserves screenshot order", async () => { + const { uploader } = recordingUploader(); + const result = await resolveReleaseArtifacts({ + artifacts: { screenshot: [{ file: "./s2.png" }, { file: "./s1.png" }] }, + manifestDir: dir, + baseUrl: "https://cdn.example.com", + slug: "gallery", + version: "1.0.0", + upload: uploader, + }); + expect(result?.screenshots?.map((s) => s.url)).toEqual([ + "https://cdn.example.com/gallery/1.0.0/s2.png", + "https://cdn.example.com/gallery/1.0.0/s1.png", + ]); + }); + + it("rejects a file path that escapes the manifest directory", async () => { + await expect( + resolveReleaseArtifacts({ + artifacts: { icon: { file: "../secret.png" } }, + manifestDir: dir, + baseUrl: "https://cdn.example.com", + slug: "gallery", + version: "1.0.0", + upload: recordingUploader().uploader, + }), + ).rejects.toMatchObject({ name: "ArtifactUploadError", code: "ARTIFACT_PATH_ESCAPE" }); + }); + + it("surfaces an unreadable file as a typed error", async () => { + await expect( + resolveReleaseArtifacts({ + artifacts: { icon: { file: "./missing.png" } }, + manifestDir: dir, + baseUrl: "https://cdn.example.com", + slug: "gallery", + version: "1.0.0", + upload: recordingUploader().uploader, + }), + ).rejects.toMatchObject({ name: "ArtifactUploadError", code: "ARTIFACT_FILE_UNREADABLE" }); + }); + + it("surfaces an upload failure as a typed error", async () => { + const failing: ArtifactUploader = async () => { + throw new Error("503 from CDN"); + }; + await expect( + resolveReleaseArtifacts({ + artifacts: { icon: { file: "./icon.png" } }, + manifestDir: dir, + baseUrl: "https://cdn.example.com", + slug: "gallery", + version: "1.0.0", + upload: failing, + }), + ).rejects.toBeInstanceOf(ArtifactUploadError); + }); + + it("rejects a non-image file", async () => { + await writeFile(join(dir, "notimage.png"), new TextEncoder().encode("nope")); + await expect( + resolveReleaseArtifacts({ + artifacts: { icon: { file: "./notimage.png" } }, + manifestDir: dir, + baseUrl: "https://cdn.example.com", + slug: "gallery", + version: "1.0.0", + upload: recordingUploader().uploader, + }), + ).rejects.toBeInstanceOf(ArtifactUploadError); + }); +}); diff --git a/packages/plugin-cli/tests/publish.test.ts b/packages/plugin-cli/tests/publish.test.ts index 7451dd5df2..a8616a9256 100644 --- a/packages/plugin-cli/tests/publish.test.ts +++ b/packages/plugin-cli/tests/publish.test.ts @@ -577,4 +577,87 @@ describe("publishRelease", () => { expect("repo" in (release!.value as Record)).toBe(false); }); }); + + describe("release artifacts", () => { + const icon = { + url: "https://cdn.example.com/test-plugin/1.0.0/icon.png", + checksum: "bciqiconchecksum", + contentType: "image/png", + width: 256, + height: 256, + }; + const banner = { + url: "https://cdn.example.com/test-plugin/1.0.0/banner.png", + checksum: "bciqbannerchecksum", + contentType: "image/png", + width: 1280, + height: 320, + }; + + function readArtifacts(pds: MockPds): Record { + const release = pds.records.get(`at://${TEST_DID}/${NSID.packageRelease}/test-plugin:1.0.0`); + return (release!.value as { artifacts: Record }) + .artifacts; + } + + it("writes icon and banner artifacts into the release record", async () => { + const pds = new MockPds({ did: TEST_DID }); + await publishRelease(buildOptions(pds, { artifacts: { icon, banner } })); + const artifacts = readArtifacts(pds); + expect(artifacts.icon).toMatchObject({ + url: icon.url, + checksum: icon.checksum, + contentType: "image/png", + width: 256, + height: 256, + }); + expect(artifacts.banner).toMatchObject({ url: banner.url, width: 1280, height: 320 }); + }); + + it("writes a single screenshot into the lexicon screenshot slot", async () => { + const pds = new MockPds({ did: TEST_DID }); + const shot = { + url: "https://cdn.example.com/test-plugin/1.0.0/s1.png", + checksum: "bciqs1", + contentType: "image/png", + width: 800, + height: 600, + }; + await publishRelease(buildOptions(pds, { artifacts: { screenshots: [shot] } })); + const artifacts = readArtifacts(pds); + expect(artifacts.screenshot).toMatchObject({ url: shot.url, width: 800, height: 600 }); + expect("x-screenshot-2" in artifacts).toBe(false); + }); + + it("spills extra screenshots into x-screenshot-N custom keys", async () => { + const pds = new MockPds({ did: TEST_DID }); + const shots = [0, 1, 2].map((i) => ({ + url: `https://cdn.example.com/test-plugin/1.0.0/s${i}.png`, + checksum: `bciqs${i}`, + contentType: "image/png", + width: 800, + height: 600, + })); + await publishRelease(buildOptions(pds, { artifacts: { screenshots: shots } })); + const artifacts = readArtifacts(pds); + // First in the lexicon slot, the rest under x-screenshot-2.., 1-based. + expect(artifacts.screenshot?.url).toBe(shots[0]!.url); + expect(artifacts["x-screenshot-2"]?.url).toBe(shots[1]!.url); + expect(artifacts["x-screenshot-3"]?.url).toBe(shots[2]!.url); + }); + + it("keeps the package artifact when media artifacts are present", async () => { + const pds = new MockPds({ did: TEST_DID }); + await publishRelease(buildOptions(pds, { artifacts: { icon } })); + const artifacts = readArtifacts(pds); + expect(artifacts.package?.url).toBe("https://example.com/test-plugin-1.0.0.tar.gz"); + }); + + it("leaves the artifacts map at just the package when none are supplied", async () => { + const pds = new MockPds({ did: TEST_DID }); + await publishRelease(buildOptions(pds)); + const artifacts = readArtifacts(pds); + expect(Object.keys(artifacts)).toEqual(["package"]); + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64bc08ef11..d5cb3fcfd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,6 +219,9 @@ catalogs: chokidar: specifier: ^5.0.0 version: 5.0.0 + image-size: + specifier: ^2.0.2 + version: 2.0.2 jsonc-parser: specifier: ^3.3.1 version: 3.3.1 @@ -366,7 +369,7 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: 'catalog:' - version: 1.36.3(vite@8.0.11(@types/node@24.10.13)(esbuild@0.27.3)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260507.1)(wrangler@4.95.0) + version: 1.36.3(vite@8.0.11(@types/node@24.10.13)(esbuild@0.27.3)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260526.1)(wrangler@4.95.0) '@cloudflare/vitest-pool-workers': specifier: 'catalog:' version: 0.16.3(@vitest/runner@4.1.5)(@vitest/snapshot@4.1.5)(vitest@4.1.5) @@ -1726,7 +1729,7 @@ importers: specifier: ^3.4.2 version: 3.4.2 image-size: - specifier: ^2.0.2 + specifier: 'catalog:' version: 2.0.2 jsonc-parser: specifier: 'catalog:' @@ -13344,9 +13347,9 @@ snapshots: - utf-8-validate - workerd - '@cloudflare/vite-plugin@1.36.3(vite@8.0.11(@types/node@24.10.13)(esbuild@0.27.3)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260507.1)(wrangler@4.95.0)': + '@cloudflare/vite-plugin@1.36.3(vite@8.0.11(@types/node@24.10.13)(esbuild@0.27.3)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260526.1)(wrangler@4.95.0)': dependencies: - '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260507.1) + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260526.1) miniflare: 4.20260507.1 unenv: 2.0.0-rc.24 vite: 8.0.11(@types/node@24.10.13)(esbuild@0.27.3)(jiti@2.6.1)(yaml@2.9.0) @@ -16761,7 +16764,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/browser-playwright@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.14(@types/node@24.10.13)(esbuild@0.27.3)(jiti@2.6.1)(yaml@2.9.0)) + vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/browser-playwright@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.27.3)(jiti@2.6.1)(yaml@2.9.0)) '@vitest/utils@4.1.5': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 301d802b6f..d58411f9da 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -136,6 +136,7 @@ catalog: astro-iconset: ^0.0.4 better-sqlite3: ^12.8.0 chokidar: ^5.0.0 + image-size: ^2.0.2 jsonc-parser: ^3.3.1 kysely: ^0.29.0 publint: 0.3.17 From d1ce1583813274543c819f4168b51408f5f528dd Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 31 May 2026 12:10:24 +0100 Subject: [PATCH 2/5] fix(registry): collision-free artifact URLs and tighter publish guards Prefix each uploaded artifact URL with its role/index slot (icon-, banner-, screenshot-N-) so two refs that share a basename in different source directories no longer collapse to one upload target. Previously two screenshots named shot.png in light/ and dark/ both mapped to ///shot.png; the second PUT overwrote the first and both records pointed at the same URL. Also tighten the publish-side path-escape guard to reject the `..` segment precisely (plus absolute paths) instead of any relative path beginning with two dots, which false-positived filenames like `..config.png`. Drop image/avif from the proxy allowlist so the served content types match what the CLI can actually produce. Add tests: same-basename screenshots/icon get distinct URLs, a two-dot filename is accepted, and a streamed body with no content-length that exceeds the cap is rejected with 413 via readCapped. --- .../api/admin/plugins/registry/artifact.ts | 1 - .../unit/api/registry-artifact-proxy.test.ts | 26 +++++++ .../src/publish/upload-artifacts.ts | 41 ++++++++--- .../tests/publish-upload-artifacts.test.ts | 73 +++++++++++++++++-- 4 files changed, 122 insertions(+), 19 deletions(-) diff --git a/packages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts b/packages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts index 694df746a7..b9c41c5212 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts @@ -30,7 +30,6 @@ const ALLOWED_IMAGE_TYPES = new Set([ "image/webp", "image/gif", "image/svg+xml", - "image/avif", ]); /** Cap proxied images so a hostile host can't stream an unbounded body. */ diff --git a/packages/core/tests/unit/api/registry-artifact-proxy.test.ts b/packages/core/tests/unit/api/registry-artifact-proxy.test.ts index 18ae555688..6e351e6db0 100644 --- a/packages/core/tests/unit/api/registry-artifact-proxy.test.ts +++ b/packages/core/tests/unit/api/registry-artifact-proxy.test.ts @@ -181,4 +181,30 @@ describe("registry artifact proxy", () => { const res = await GET(makeContext("https://cdn.example.com/huge.png")); expect(res.status).toBe(413); }); + + it("rejects a streamed body that exceeds the cap with no content-length", async () => { + // No content-length header, so the declared-length guard can't fire. The + // body streams past MAX_IMAGE_BYTES (5MB); only readCapped's running tally + // catches it, cancels the reader, and returns null -> 413. + const chunk = new Uint8Array(1024 * 1024); + let emitted = 0; + const body = new ReadableStream({ + pull(controller) { + if (emitted >= 6) { + controller.close(); + return; + } + emitted++; + controller.enqueue(chunk); + }, + }); + const response = new Response(body, { + status: 200, + headers: { "content-type": "image/png" }, + }); + expect(response.headers.get("content-length")).toBeNull(); + globalThis.fetch = vi.fn(async () => response) as typeof globalThis.fetch; + const res = await GET(makeContext("https://cdn.example.com/streamed.png")); + expect(res.status).toBe(413); + }); }); diff --git a/packages/plugin-cli/src/publish/upload-artifacts.ts b/packages/plugin-cli/src/publish/upload-artifacts.ts index dbf886ac94..4f601fecf1 100644 --- a/packages/plugin-cli/src/publish/upload-artifacts.ts +++ b/packages/plugin-cli/src/publish/upload-artifacts.ts @@ -7,7 +7,7 @@ * 1. resolves each ref under the manifest directory (rejecting paths that * escape it), * 2. reads the bytes and measures content type + dimensions, - * 3. PUTs the bytes to `///`, + * 3. PUTs the bytes to `///-`, * 4. records `{ url, checksum, contentType, width, height, lang? }`. * * The hosting contract: the publisher's `--artifact-base-url` target must @@ -17,7 +17,7 @@ */ import { readFile } from "node:fs/promises"; -import { basename, relative, resolve, sep } from "node:path"; +import { basename, isAbsolute, relative, resolve, sep } from "node:path"; import type { ManifestArtifacts, ManifestArtifactFile } from "../manifest/schema.js"; import type { ReleaseArtifactInput, ReleaseArtifactsInput } from "./api.js"; @@ -87,15 +87,23 @@ export async function resolveReleaseArtifacts( const out: ReleaseArtifactsInput = {}; if (artifacts.icon) { - out.icon = await resolveOne(artifacts.icon, "icon", options, upload); + out.icon = await resolveOne(artifacts.icon, "icon", "icon", options, upload); } if (artifacts.banner) { - out.banner = await resolveOne(artifacts.banner, "banner", options, upload); + out.banner = await resolveOne(artifacts.banner, "banner", "banner", options, upload); } if (artifacts.screenshot && artifacts.screenshot.length > 0) { const screenshots: ReleaseArtifactInput[] = []; for (const [index, ref] of artifacts.screenshot.entries()) { - screenshots.push(await resolveOne(ref, `screenshot ${index + 1}`, options, upload)); + screenshots.push( + await resolveOne( + ref, + `screenshot ${index + 1}`, + `screenshot-${index + 1}`, + options, + upload, + ), + ); } out.screenshots = screenshots; } @@ -106,6 +114,7 @@ export async function resolveReleaseArtifacts( async function resolveOne( ref: ManifestArtifactFile, label: string, + slot: string, options: ResolveArtifactsOptions, upload: ArtifactUploader, ): Promise { @@ -130,7 +139,7 @@ async function resolveOne( try { record = buildArtifactRecord({ bytes, - url: artifactUrl(options.baseUrl, options.slug, options.version, ref.file), + url: artifactUrl(options.baseUrl, options.slug, options.version, slot, ref.file), lang: ref.lang, }); } catch (error) { @@ -162,7 +171,7 @@ async function resolveOne( function resolveWithinManifest(manifestDir: string, file: string, label: string): string { const absolute = resolve(manifestDir, file); const rel = relative(manifestDir, absolute); - if (rel === "" || rel.startsWith("..") || rel.startsWith(`..${sep}`)) { + if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(file)) { throw new ArtifactUploadError( "ARTIFACT_PATH_ESCAPE", `${label} artifact path ${file} resolves outside the manifest directory.`, @@ -172,13 +181,21 @@ function resolveWithinManifest(manifestDir: string, file: string, label: string) } /** - * Build the public URL for an artifact: `///`. - * Only the basename of the manifest ref is used so nested source paths don't - * leak into the published URL. + * Build the public URL for an artifact: + * `///-`. The basename of the manifest ref + * keeps nested source paths out of the published URL; the `slot` prefix + * (`icon`, `banner`, `screenshot-2`) keeps two refs with the same basename in + * different directories from colliding on the same upload target. */ -function artifactUrl(baseUrl: string, slug: string, version: string, file: string): string { +function artifactUrl( + baseUrl: string, + slug: string, + version: string, + slot: string, + file: string, +): string { const trimmed = baseUrl.replace(TRAILING_SLASHES, ""); - const name = basename(file); + const name = `${slot}-${basename(file)}`; return `${trimmed}/${encodeURIComponent(slug)}/${encodeURIComponent(version)}/${encodeURIComponent(name)}`; } diff --git a/packages/plugin-cli/tests/publish-upload-artifacts.test.ts b/packages/plugin-cli/tests/publish-upload-artifacts.test.ts index 1d55b50145..2de02dfc27 100644 --- a/packages/plugin-cli/tests/publish-upload-artifacts.test.ts +++ b/packages/plugin-cli/tests/publish-upload-artifacts.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -74,14 +74,16 @@ describe("resolveReleaseArtifacts", () => { }); expect(result?.icon).toMatchObject({ - url: "https://cdn.example.com/gallery/1.0.0/icon.png", + url: "https://cdn.example.com/gallery/1.0.0/icon-icon.png", contentType: "image/png", width: 1, height: 1, }); - expect(result?.banner?.url).toBe("https://cdn.example.com/gallery/1.0.0/banner.png"); + expect(result?.banner?.url).toBe("https://cdn.example.com/gallery/1.0.0/banner-banner.png"); expect(result?.screenshots).toHaveLength(2); - expect(result?.screenshots?.[0]?.url).toBe("https://cdn.example.com/gallery/1.0.0/s1.png"); + expect(result?.screenshots?.[0]?.url).toBe( + "https://cdn.example.com/gallery/1.0.0/screenshot-1-s1.png", + ); expect(result?.screenshots?.[1]?.lang).toBe("de"); // One PUT per artifact, with the measured content type. @@ -100,11 +102,70 @@ describe("resolveReleaseArtifacts", () => { upload: uploader, }); expect(result?.screenshots?.map((s) => s.url)).toEqual([ - "https://cdn.example.com/gallery/1.0.0/s2.png", - "https://cdn.example.com/gallery/1.0.0/s1.png", + "https://cdn.example.com/gallery/1.0.0/screenshot-1-s2.png", + "https://cdn.example.com/gallery/1.0.0/screenshot-2-s1.png", ]); }); + it("gives same-basename screenshots in different dirs distinct upload URLs", async () => { + await mkdir(join(dir, "light")); + await mkdir(join(dir, "dark")); + await writeFile(join(dir, "light", "shot.png"), PNG_1x1); + await writeFile(join(dir, "dark", "shot.png"), PNG_1x1); + + const { uploader, uploads } = recordingUploader(); + const result = await resolveReleaseArtifacts({ + artifacts: { screenshot: [{ file: "./light/shot.png" }, { file: "./dark/shot.png" }] }, + manifestDir: dir, + baseUrl: "https://cdn.example.com", + slug: "gallery", + version: "1.0.0", + upload: uploader, + }); + + const urls = result?.screenshots?.map((s) => s.url) ?? []; + expect(urls).toEqual([ + "https://cdn.example.com/gallery/1.0.0/screenshot-1-shot.png", + "https://cdn.example.com/gallery/1.0.0/screenshot-2-shot.png", + ]); + expect(new Set(urls).size).toBe(2); + expect(new Set(uploads.map((u) => u.url)).size).toBe(2); + }); + + it("gives an icon and a same-basename screenshot distinct upload URLs", async () => { + await writeFile(join(dir, "image.png"), PNG_1x1); + + const result = await resolveReleaseArtifacts({ + artifacts: { icon: { file: "./image.png" }, screenshot: [{ file: "./image.png" }] }, + manifestDir: dir, + baseUrl: "https://cdn.example.com", + slug: "gallery", + version: "1.0.0", + upload: recordingUploader().uploader, + }); + + expect(result?.icon?.url).toBe("https://cdn.example.com/gallery/1.0.0/icon-image.png"); + expect(result?.screenshots?.[0]?.url).toBe( + "https://cdn.example.com/gallery/1.0.0/screenshot-1-image.png", + ); + expect(result?.icon?.url).not.toBe(result?.screenshots?.[0]?.url); + }); + + it("accepts a filename that begins with two dots", async () => { + await writeFile(join(dir, "..config.png"), PNG_1x1); + + const result = await resolveReleaseArtifacts({ + artifacts: { icon: { file: "./..config.png" } }, + manifestDir: dir, + baseUrl: "https://cdn.example.com", + slug: "gallery", + version: "1.0.0", + upload: recordingUploader().uploader, + }); + + expect(result?.icon?.url).toBe("https://cdn.example.com/gallery/1.0.0/icon-..config.png"); + }); + it("rejects a file path that escapes the manifest directory", async () => { await expect( resolveReleaseArtifacts({ From 2f17a7b5dcb7bcca39c7984a3acdad5353510895 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 31 May 2026 12:51:00 +0100 Subject: [PATCH 3/5] refactor(registry): first-class screenshots array, drop x-screenshot-N The #1033 implementation stored extra screenshots under x-screenshot-N custom artifact keys, which is not FAIR-aligned. FAIR's artifacts map allows an artifact value to be a list of objects; model screenshots as a first-class array end to end. - lexicon: artifacts.screenshot (single ref) -> screenshots (array of #artifact, maxLength 8); regenerate atcute types - plugin-cli manifest schema: release.artifacts.screenshot -> screenshots; regenerate JSON schema - publish path: write artifacts.screenshots as an array, drop the x-screenshot-N spillover; keep the collision-free slot-prefixed upload URL scheme (screenshot-N-) - admin: read the screenshots array directly, drop x-screenshot-N collection and numeric ordering --- packages/admin/src/lib/api/registry.ts | 27 +++++--------- .../tests/lib/registry-artifacts.test.ts | 31 ++++++++-------- .../schemas/emdash-plugin.schema.json | 4 +-- packages/plugin-cli/src/manifest/schema.ts | 10 +++--- packages/plugin-cli/src/publish/api.ts | 35 ++++++------------- .../src/publish/upload-artifacts.ts | 6 ++-- .../plugin-cli/tests/manifest-schema.test.ts | 23 +++++++----- .../tests/publish-upload-artifacts.test.ts | 8 ++--- packages/plugin-cli/tests/publish.test.ts | 29 +++++++++------ .../experimental/package/release.json | 13 ++++--- .../emdashcms/experimental/package/release.ts | 12 +++++-- 11 files changed, 101 insertions(+), 97 deletions(-) diff --git a/packages/admin/src/lib/api/registry.ts b/packages/admin/src/lib/api/registry.ts index 8a295798dc..91f842a1bf 100644 --- a/packages/admin/src/lib/api/registry.ts +++ b/packages/admin/src/lib/api/registry.ts @@ -472,15 +472,13 @@ export interface MediaArtifacts { screenshots: MediaArtifact[]; } -const SCREENSHOT_OVERFLOW_KEY_RE = /^x-screenshot-(\d+)$/; - /** * Narrow one entry of a release's `artifacts` map to the fields we render. * Returns `null` when the value isn't an object with a string `url`. * * Records are lexicon-validated at the DiscoveryClient boundary, but - * `artifacts` is an open map (the `x-screenshot-N` overflow keys are - * unrecognised by the lexicon), so each entry still needs shape-narrowing. + * `artifacts` is an aggregator pass-through, so each entry still needs + * shape-narrowing before it reaches an ``. */ function asMediaArtifact(value: unknown): MediaArtifact | null { if (!value || typeof value !== "object") return null; @@ -495,9 +493,8 @@ function asMediaArtifact(value: unknown): MediaArtifact | null { /** * Pull icon, banner, and the screenshot gallery out of a release's `artifacts` - * map. The lexicon types `screenshot` as a single artifact, so the gallery is - * the lexicon `screenshot` plus any `x-screenshot-N` overflow keys (the CLI - * writes additional screenshots there), ordered by N. + * map. The lexicon types `screenshots` as an array of artifacts; entries + * without a usable `url` are dropped, and gallery order is preserved. */ export function extractMediaArtifacts(artifacts: unknown): MediaArtifacts { const result: MediaArtifacts = { screenshots: [] }; @@ -510,18 +507,12 @@ export function extractMediaArtifacts(artifacts: unknown): MediaArtifacts { const banner = asMediaArtifact(map.banner); if (banner) result.banner = banner; - const first = asMediaArtifact(map.screenshot); - const overflow: Array<{ index: number; artifact: MediaArtifact }> = []; - for (const [key, value] of Object.entries(map)) { - const match = SCREENSHOT_OVERFLOW_KEY_RE.exec(key); - if (!match) continue; - const artifact = asMediaArtifact(value); - if (artifact) overflow.push({ index: Number(match[1]), artifact }); + if (Array.isArray(map.screenshots)) { + for (const entry of map.screenshots) { + const artifact = asMediaArtifact(entry); + if (artifact) result.screenshots.push(artifact); + } } - overflow.sort((a, b) => a.index - b.index); - - if (first) result.screenshots.push(first); - for (const entry of overflow) result.screenshots.push(entry.artifact); return result; } diff --git a/packages/admin/tests/lib/registry-artifacts.test.ts b/packages/admin/tests/lib/registry-artifacts.test.ts index 904237ef63..8b54aaf362 100644 --- a/packages/admin/tests/lib/registry-artifacts.test.ts +++ b/packages/admin/tests/lib/registry-artifacts.test.ts @@ -45,33 +45,36 @@ describe("extractMediaArtifacts", () => { expect(result.screenshots).toEqual([]); }); - it("collects the screenshot slot plus x-screenshot-N overflow, in order", () => { + it("collects the screenshots array in order", () => { const result = extractMediaArtifacts({ package: { url: "https://x/a.tgz" }, - screenshot: s1, - "x-screenshot-2": s2, - "x-screenshot-3": s3, + screenshots: [s1, s2, s3], }); expect(result.screenshots.map((s) => s.url)).toEqual([s1.url, s2.url, s3.url]); }); - it("orders overflow keys numerically, not lexically", () => { - const result = extractMediaArtifacts({ - screenshot: s1, - "x-screenshot-10": { url: "https://x/s10.png" }, - "x-screenshot-2": s2, - }); - expect(result.screenshots.map((s) => s.url)).toEqual([s1.url, s2.url, "https://x/s10.png"]); + it("handles a single-element screenshots array", () => { + const result = extractMediaArtifacts({ screenshots: [s1] }); + expect(result.screenshots.map((s) => s.url)).toEqual([s1.url]); + }); + + it("ignores a non-array screenshots value", () => { + expect(extractMediaArtifacts({ screenshots: s1 }).screenshots).toEqual([]); + expect(extractMediaArtifacts({ screenshots: "nope" }).screenshots).toEqual([]); + }); + + it("ignores the legacy singular `screenshot` key", () => { + const result = extractMediaArtifacts({ screenshot: s1, "x-screenshot-2": s2 }); + expect(result.screenshots).toEqual([]); }); it("skips entries without a usable url", () => { const result = extractMediaArtifacts({ icon: { width: 10 }, - screenshot: { url: 123 }, - "x-screenshot-2": s2, + screenshots: [{ url: 123 }, s2], }); expect(result.icon).toBeUndefined(); - // The malformed `screenshot` is dropped; the valid overflow survives. + // The malformed first entry is dropped; the valid one survives. expect(result.screenshots.map((s) => s.url)).toEqual([s2.url]); }); }); diff --git a/packages/plugin-cli/schemas/emdash-plugin.schema.json b/packages/plugin-cli/schemas/emdash-plugin.schema.json index 8556ded8b4..b4f1c8d198 100644 --- a/packages/plugin-cli/schemas/emdash-plugin.schema.json +++ b/packages/plugin-cli/schemas/emdash-plugin.schema.json @@ -489,13 +489,13 @@ "banner": { "$ref": "#/$defs/__schema49" }, - "screenshot": { + "screenshots": { "$ref": "#/$defs/__schema50" } }, "additionalProperties": false, "title": "Artifacts", - "description": "Release media artifacts. `icon` and `banner` are single images; `screenshot` is a gallery array." + "description": "Release media artifacts. `icon` and `banner` are single images; `screenshots` is a gallery array." }, "__schema45": { "$ref": "#/$defs/__schema46" diff --git a/packages/plugin-cli/src/manifest/schema.ts b/packages/plugin-cli/src/manifest/schema.ts index cb3ee9d64e..cb207b8e98 100644 --- a/packages/plugin-cli/src/manifest/schema.ts +++ b/packages/plugin-cli/src/manifest/schema.ts @@ -571,7 +571,7 @@ export const ArtifactFileSchema = z }); /** - * Release media artifacts. `icon` and `banner` are single files; `screenshot` + * Release media artifacts. `icon` and `banner` are single files; `screenshots` * is an array (a plugin can ship a gallery). Mirrors `release.json#artifacts` * minus the `package` entry, which the CLI derives from the tarball. */ @@ -579,10 +579,10 @@ export const ArtifactsSchema = z .object({ icon: ArtifactFileSchema.optional(), banner: ArtifactFileSchema.optional(), - screenshot: z + screenshots: z .array(ArtifactFileSchema) - .min(1, "screenshot[] must have at least one entry when set") - .max(8, "screenshot[] must have <= 8 entries") + .min(1, "screenshots[] must have at least one entry when set") + .max(8, "screenshots[] must have <= 8 entries") .meta({ title: "Screenshots", description: "Screenshot gallery for the plugin's detail page (<= 8 entries).", @@ -593,7 +593,7 @@ export const ArtifactsSchema = z .meta({ title: "Artifacts", description: - "Release media artifacts. `icon` and `banner` are single images; `screenshot` is a gallery array.", + "Release media artifacts. `icon` and `banner` are single images; `screenshots` is a gallery array.", }); /** diff --git a/packages/plugin-cli/src/publish/api.ts b/packages/plugin-cli/src/publish/api.ts index e2e1c8ad96..fd8d00fa4a 100644 --- a/packages/plugin-cli/src/publish/api.ts +++ b/packages/plugin-cli/src/publish/api.ts @@ -148,9 +148,8 @@ export interface ReleaseArtifactInput { /** * Resolved release media artifacts. `icon` / `banner` are single images; - * `screenshots` is the ordered gallery. The first screenshot is written to the - * lexicon's `artifacts.screenshot` slot and the rest to `x-screenshot-N` - * custom keys. + * `screenshots` is the ordered gallery, written verbatim to the lexicon's + * `artifacts.screenshots` array. */ export interface ReleaseArtifactsInput { icon?: ReleaseArtifactInput; @@ -287,15 +286,8 @@ interface PackageReleaseRecordShape { }; icon?: ImageArtifact; banner?: ImageArtifact; - /** - * First screenshot. The lexicon's `artifacts` object types - * `screenshot` as a single `#artifact`, so additional screenshots - * ride along under `x-screenshot-N` custom keys (which the lexicon - * sanctions: "Custom types use 'x-' prefix and pass through as - * unrecognised fields"). - */ - screenshot?: ImageArtifact; - [extraScreenshot: `x-screenshot-${number}`]: ImageArtifact | undefined; + /** Ordered screenshot gallery (`artifacts.screenshots` in the lexicon). */ + screenshots?: ImageArtifact[]; }; /** Source-repository URL (`release.repo`). Omitted when not provided. */ repo?: string; @@ -611,11 +603,9 @@ function atUri(did: Did, collection: string, rkey: string): string { /** * Write resolved media artifacts into a release record's `artifacts` map. * - * `icon` and `banner` map to their lexicon slots directly. The lexicon types - * `screenshot` as a single `#artifact`, so the first screenshot goes there and - * any extras ride along under `x-screenshot-2`, `x-screenshot-3`, … custom - * keys (the lexicon documents `x-` prefixed entries as pass-through fields). - * Indices are 1-based and contiguous, matching the gallery order. + * `icon` and `banner` map to their single-`#artifact` lexicon slots directly; + * `screenshots` is written as the lexicon's `artifacts.screenshots` array, + * preserving gallery order. */ function applyArtifacts( record: PackageReleaseRecordShape, @@ -624,14 +614,9 @@ function applyArtifacts( if (!artifacts) return; if (artifacts.icon) record.artifacts.icon = { ...artifacts.icon }; if (artifacts.banner) record.artifacts.banner = { ...artifacts.banner }; - const screenshots = artifacts.screenshots ?? []; - screenshots.forEach((shot, index) => { - if (index === 0) { - record.artifacts.screenshot = { ...shot }; - } else { - record.artifacts[`x-screenshot-${index + 1}`] = { ...shot }; - } - }); + if (artifacts.screenshots && artifacts.screenshots.length > 0) { + record.artifacts.screenshots = artifacts.screenshots.map((shot) => ({ ...shot })); + } } /** diff --git a/packages/plugin-cli/src/publish/upload-artifacts.ts b/packages/plugin-cli/src/publish/upload-artifacts.ts index 4f601fecf1..4dd126d009 100644 --- a/packages/plugin-cli/src/publish/upload-artifacts.ts +++ b/packages/plugin-cli/src/publish/upload-artifacts.ts @@ -79,7 +79,7 @@ export async function resolveReleaseArtifacts( ): Promise { const { artifacts } = options; if (!artifacts) return undefined; - if (!artifacts.icon && !artifacts.banner && !(artifacts.screenshot?.length ?? 0)) { + if (!artifacts.icon && !artifacts.banner && !(artifacts.screenshots?.length ?? 0)) { return undefined; } @@ -92,9 +92,9 @@ export async function resolveReleaseArtifacts( if (artifacts.banner) { out.banner = await resolveOne(artifacts.banner, "banner", "banner", options, upload); } - if (artifacts.screenshot && artifacts.screenshot.length > 0) { + if (artifacts.screenshots && artifacts.screenshots.length > 0) { const screenshots: ReleaseArtifactInput[] = []; - for (const [index, ref] of artifacts.screenshot.entries()) { + for (const [index, ref] of artifacts.screenshots.entries()) { screenshots.push( await resolveOne( ref, diff --git a/packages/plugin-cli/tests/manifest-schema.test.ts b/packages/plugin-cli/tests/manifest-schema.test.ts index 8764f25a60..c0e8ad78eb 100644 --- a/packages/plugin-cli/tests/manifest-schema.test.ts +++ b/packages/plugin-cli/tests/manifest-schema.test.ts @@ -154,25 +154,30 @@ describe("ArtifactsSchema", () => { expect(result.success).toBe(true); }); - it("accepts screenshot as an array of file refs", () => { + it("accepts screenshots as an array of file refs", () => { const result = ArtifactsSchema.safeParse({ - screenshot: [{ file: "./s1.png" }, { file: "./s2.png", lang: "de" }], + screenshots: [{ file: "./s1.png" }, { file: "./s2.png", lang: "de" }], }); expect(result.success).toBe(true); }); - it("rejects a single (non-array) screenshot", () => { - const result = ArtifactsSchema.safeParse({ screenshot: { file: "./s1.png" } }); + it("rejects a single (non-array) screenshots value", () => { + const result = ArtifactsSchema.safeParse({ screenshots: { file: "./s1.png" } }); expect(result.success).toBe(false); }); - it("rejects an empty screenshot array", () => { - expect(ArtifactsSchema.safeParse({ screenshot: [] }).success).toBe(false); + it("rejects an empty screenshots array", () => { + expect(ArtifactsSchema.safeParse({ screenshots: [] }).success).toBe(false); }); it("rejects more than eight screenshots", () => { - const screenshot = Array.from({ length: 9 }, (_, i) => ({ file: `./s${i}.png` })); - expect(ArtifactsSchema.safeParse({ screenshot }).success).toBe(false); + const screenshots = Array.from({ length: 9 }, (_, i) => ({ file: `./s${i}.png` })); + expect(ArtifactsSchema.safeParse({ screenshots }).success).toBe(false); + }); + + it("rejects the legacy singular `screenshot` key", () => { + const result = ArtifactsSchema.safeParse({ screenshot: [{ file: "./s1.png" }] }); + expect(result.success).toBe(false); }); }); @@ -198,7 +203,7 @@ describe("ManifestSchema (full document)", () => { artifacts: { icon: { file: "./icon.png" }, banner: { file: "./banner.png" }, - screenshot: [{ file: "./s1.png" }, { file: "./s2.png" }], + screenshots: [{ file: "./s1.png" }, { file: "./s2.png" }], }, }, }); diff --git a/packages/plugin-cli/tests/publish-upload-artifacts.test.ts b/packages/plugin-cli/tests/publish-upload-artifacts.test.ts index 2de02dfc27..07fdf3be06 100644 --- a/packages/plugin-cli/tests/publish-upload-artifacts.test.ts +++ b/packages/plugin-cli/tests/publish-upload-artifacts.test.ts @@ -64,7 +64,7 @@ describe("resolveReleaseArtifacts", () => { artifacts: { icon: { file: "./icon.png" }, banner: { file: "./banner.png" }, - screenshot: [{ file: "./s1.png" }, { file: "./s2.png", lang: "de" }], + screenshots: [{ file: "./s1.png" }, { file: "./s2.png", lang: "de" }], }, manifestDir: dir, baseUrl: "https://cdn.example.com/", @@ -94,7 +94,7 @@ describe("resolveReleaseArtifacts", () => { it("preserves screenshot order", async () => { const { uploader } = recordingUploader(); const result = await resolveReleaseArtifacts({ - artifacts: { screenshot: [{ file: "./s2.png" }, { file: "./s1.png" }] }, + artifacts: { screenshots: [{ file: "./s2.png" }, { file: "./s1.png" }] }, manifestDir: dir, baseUrl: "https://cdn.example.com", slug: "gallery", @@ -115,7 +115,7 @@ describe("resolveReleaseArtifacts", () => { const { uploader, uploads } = recordingUploader(); const result = await resolveReleaseArtifacts({ - artifacts: { screenshot: [{ file: "./light/shot.png" }, { file: "./dark/shot.png" }] }, + artifacts: { screenshots: [{ file: "./light/shot.png" }, { file: "./dark/shot.png" }] }, manifestDir: dir, baseUrl: "https://cdn.example.com", slug: "gallery", @@ -136,7 +136,7 @@ describe("resolveReleaseArtifacts", () => { await writeFile(join(dir, "image.png"), PNG_1x1); const result = await resolveReleaseArtifacts({ - artifacts: { icon: { file: "./image.png" }, screenshot: [{ file: "./image.png" }] }, + artifacts: { icon: { file: "./image.png" }, screenshots: [{ file: "./image.png" }] }, manifestDir: dir, baseUrl: "https://cdn.example.com", slug: "gallery", diff --git a/packages/plugin-cli/tests/publish.test.ts b/packages/plugin-cli/tests/publish.test.ts index a8616a9256..044dccd3b4 100644 --- a/packages/plugin-cli/tests/publish.test.ts +++ b/packages/plugin-cli/tests/publish.test.ts @@ -594,10 +594,16 @@ describe("publishRelease", () => { height: 320, }; - function readArtifacts(pds: MockPds): Record { + interface ReleaseArtifactsMap { + package?: { url: string; checksum: string }; + icon?: { url: string; checksum: string; width?: number; height?: number }; + banner?: { url: string; checksum: string; width?: number; height?: number }; + screenshots?: Array<{ url: string; checksum: string; width?: number; height?: number }>; + } + + function readArtifacts(pds: MockPds): ReleaseArtifactsMap { const release = pds.records.get(`at://${TEST_DID}/${NSID.packageRelease}/test-plugin:1.0.0`); - return (release!.value as { artifacts: Record }) - .artifacts; + return (release!.value as { artifacts: ReleaseArtifactsMap }).artifacts; } it("writes icon and banner artifacts into the release record", async () => { @@ -614,7 +620,7 @@ describe("publishRelease", () => { expect(artifacts.banner).toMatchObject({ url: banner.url, width: 1280, height: 320 }); }); - it("writes a single screenshot into the lexicon screenshot slot", async () => { + it("writes a single screenshot as a one-element screenshots array", async () => { const pds = new MockPds({ did: TEST_DID }); const shot = { url: "https://cdn.example.com/test-plugin/1.0.0/s1.png", @@ -625,11 +631,15 @@ describe("publishRelease", () => { }; await publishRelease(buildOptions(pds, { artifacts: { screenshots: [shot] } })); const artifacts = readArtifacts(pds); - expect(artifacts.screenshot).toMatchObject({ url: shot.url, width: 800, height: 600 }); - expect("x-screenshot-2" in artifacts).toBe(false); + expect(artifacts.screenshots).toHaveLength(1); + expect(artifacts.screenshots?.[0]).toMatchObject({ + url: shot.url, + width: 800, + height: 600, + }); }); - it("spills extra screenshots into x-screenshot-N custom keys", async () => { + it("writes the full screenshot gallery as an ordered array", async () => { const pds = new MockPds({ did: TEST_DID }); const shots = [0, 1, 2].map((i) => ({ url: `https://cdn.example.com/test-plugin/1.0.0/s${i}.png`, @@ -640,10 +650,7 @@ describe("publishRelease", () => { })); await publishRelease(buildOptions(pds, { artifacts: { screenshots: shots } })); const artifacts = readArtifacts(pds); - // First in the lexicon slot, the rest under x-screenshot-2.., 1-based. - expect(artifacts.screenshot?.url).toBe(shots[0]!.url); - expect(artifacts["x-screenshot-2"]?.url).toBe(shots[1]!.url); - expect(artifacts["x-screenshot-3"]?.url).toBe(shots[2]!.url); + expect(artifacts.screenshots?.map((s) => s.url)).toEqual(shots.map((s) => s.url)); }); it("keeps the package artifact when media artifacts are present", async () => { diff --git a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/release.json b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/release.json index 182300a47d..6265128d3e 100644 --- a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/release.json +++ b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/release.json @@ -64,7 +64,7 @@ }, "artifacts": { "type": "object", - "description": "Map of artifact-type-name to artifact. Field names follow atproto's lowerCamelCase style; FAIR's HTTP transport's kebab-case names ('content-type', 'requires-auth', 'release-asset') translate to these by mechanical mapping at the aggregator boundary. Common artifact-type keys: 'package' (the installable bundle, REQUIRED), 'icon', 'screenshot', 'banner'. Custom types use 'x-' prefix and pass through as unrecognised fields.", + "description": "Map of artifact-type-name to artifact. Field names follow atproto's lowerCamelCase style; FAIR's HTTP transport's kebab-case names ('content-type', 'requires-auth', 'release-asset') translate to these by mechanical mapping at the aggregator boundary. Common artifact-type keys: 'package' (the installable bundle, REQUIRED), 'icon', 'screenshots' (a list), 'banner'. FAIR's artifacts map allows an artifact value to be a single object or a list of objects; 'screenshots' is the list form. Custom types use 'x-' prefix and pass through as unrecognised fields.", "required": ["package"], "properties": { "package": { @@ -76,9 +76,14 @@ "type": "ref", "ref": "#artifact" }, - "screenshot": { - "type": "ref", - "ref": "#artifact" + "screenshots": { + "type": "array", + "items": { + "type": "ref", + "ref": "#artifact" + }, + "maxLength": 8, + "description": "Ordered screenshot gallery for the plugin's detail page. FAIR's singular 'screenshot' alias is a transport-boundary concern and does not appear on the record." }, "banner": { "type": "ref", diff --git a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/release.ts b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/release.ts index 9d446db811..aacfb0e40d 100644 --- a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/release.ts +++ b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/release.ts @@ -100,8 +100,16 @@ const _artifactsSchema = /*#__PURE__*/ v.object({ get package() { return artifactSchema; }, - get screenshot() { - return /*#__PURE__*/ v.optional(artifactSchema); + /** + * Ordered screenshot gallery for the plugin's detail page. FAIR's singular 'screenshot' alias is a transport-boundary concern and does not appear on the record. + * @maxLength 8 + */ + get screenshots() { + return /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.array(artifactSchema), [ + /*#__PURE__*/ v.arrayLength(0, 8), + ]), + ); }, }); const _mainSchema = /*#__PURE__*/ v.record( From 5261f6e310f4398e6ec47a620b9d36445f6f3b8c Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 31 May 2026 13:00:02 +0100 Subject: [PATCH 4/5] chore(registry): align bundle screenshot cap (5 -> 8) with the screenshots array cap The bundle path's MAX_SCREENSHOTS governed a separate ingestion route from the lexicon/manifest screenshots array (capped at 8). Align them so all screenshot caps agree. --- packages/plugin-cli/src/bundle/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugin-cli/src/bundle/utils.ts b/packages/plugin-cli/src/bundle/utils.ts index c681240af0..519144f4a6 100644 --- a/packages/plugin-cli/src/bundle/utils.ts +++ b/packages/plugin-cli/src/bundle/utils.ts @@ -25,7 +25,7 @@ export const MAX_BUNDLE_SIZE = 256 * 1024; export const MAX_FILE_SIZE = 128 * 1024; export const MAX_FILE_COUNT = 20; -export const MAX_SCREENSHOTS = 5; +export const MAX_SCREENSHOTS = 8; export const MAX_SCREENSHOT_WIDTH = 1920; export const MAX_SCREENSHOT_HEIGHT = 1080; export const ICON_SIZE = 256; From 18f1b2113b73e029ddf85433782749c1a029c812 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 31 May 2026 14:51:43 +0100 Subject: [PATCH 5/5] refactor(registry): resolve artifact proxy URLs from the release record; forbid SVG, allow AVIF The artifact proxy no longer accepts a caller-supplied URL. The client addresses an artifact by coordinates (did, slug, version, kind, index); the server resolves the declared URL from the validated release record, so the proxy can only ever fetch a publisher-declared artifact. SSRF defences remain as a second layer on the resolved URL (incl. every redirect hop). SVG is dropped from both the proxy allowlist and the publish CLI (active content); AVIF is added end-to-end. --- .changeset/registry-image-artifacts.md | 2 +- .../src/components/RegistryPluginDetail.tsx | 40 ++- packages/admin/src/lib/api/registry.ts | 89 +++-- .../tests/lib/registry-artifacts.test.ts | 75 +++-- .../api/admin/plugins/registry/artifact.ts | 254 +++++++++++++-- .../unit/api/registry-artifact-proxy.test.ts | 306 +++++++++++++++--- .../schemas/emdash-plugin.schema.json | 2 +- packages/plugin-cli/src/manifest/schema.ts | 2 +- packages/plugin-cli/src/publish/artifacts.ts | 8 +- .../tests/publish-artifacts.test.ts | 54 +++- 10 files changed, 678 insertions(+), 154 deletions(-) diff --git a/.changeset/registry-image-artifacts.md b/.changeset/registry-image-artifacts.md index fb9cdd3bd8..01b30a0534 100644 --- a/.changeset/registry-image-artifacts.md +++ b/.changeset/registry-image-artifacts.md @@ -4,4 +4,4 @@ "@emdash-cms/admin": minor --- -Plugins published to the experimental registry can now ship icon, screenshot, and banner images. Declare them in `emdash-plugin.jsonc` under `release.artifacts` as file refs; `emdash-plugin publish --artifact-base-url ` measures each image's dimensions, uploads it, and records it in the release. The admin plugin detail page renders the icon, banner, and a screenshot gallery, fetched through a server-side image proxy that applies SSRF defences and an image content-type allowlist to the arbitrary publisher-supplied URLs. +Plugins published to the experimental registry can now ship icon, screenshot, and banner images. Declare them in `emdash-plugin.jsonc` under `release.artifacts` as file refs; `emdash-plugin publish --artifact-base-url ` measures each image's dimensions, uploads it, and records it in the release. The admin plugin detail page renders the icon, banner, and a screenshot gallery, fetched through a server-side image proxy. The proxy resolves each artifact's URL server-side from the validated release record (the client sends only the artifact's coordinates, never a URL), then applies SSRF defences and an image content-type allowlist before serving the bytes. Supported image types are PNG, JPEG, WebP, GIF, and AVIF; SVG is rejected at both publish and proxy because it is active content. diff --git a/packages/admin/src/components/RegistryPluginDetail.tsx b/packages/admin/src/components/RegistryPluginDetail.tsx index 55a613cec5..028359cef4 100644 --- a/packages/admin/src/components/RegistryPluginDetail.tsx +++ b/packages/admin/src/components/RegistryPluginDetail.tsx @@ -212,15 +212,33 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP const verified = (pkg?.labels ?? []).some((l: { val?: string }) => l.val === "verified"); // Media artifacts (icon / screenshot / banner) live on the release record's - // `artifacts` map. Each carries a publisher-supplied `url`; we never point an - // `` at it directly — every image goes through the server's SSRF-defended - // proxy, which also enforces an image content-type allowlist. + // `artifacts` map. The publisher-supplied URLs never reach the client — we + // address each image by its `(did, slug, version, kind, index)` coordinates, + // and the server resolves the declared URL from the release record before + // fetching it through its SSRF-defended, content-type-allowlisted proxy. const mediaArtifacts = extractMediaArtifacts(release?.release?.artifacts); - const iconSrc = artifactProxyUrl(mediaArtifacts.icon?.url); - const bannerSrc = artifactProxyUrl(mediaArtifacts.banner?.url); - const screenshots = mediaArtifacts.screenshots - .map((shot) => ({ ...shot, src: artifactProxyUrl(shot.url) })) - .filter((shot): shot is typeof shot & { src: string } => shot.src !== null); + const artifactDid = pkg?.did; + const artifactVersion = release?.version; + const iconSrc = + mediaArtifacts.icon && artifactDid + ? artifactProxyUrl({ did: artifactDid, slug, version: artifactVersion, kind: "icon" }) + : null; + const bannerSrc = + mediaArtifacts.banner && artifactDid + ? artifactProxyUrl({ did: artifactDid, slug, version: artifactVersion, kind: "banner" }) + : null; + const screenshots = artifactDid + ? mediaArtifacts.screenshots.map((shot) => ({ + ...shot, + src: artifactProxyUrl({ + did: artifactDid, + slug, + version: artifactVersion, + kind: "screenshot", + index: shot.index, + }), + })) + : []; const policyOk = release && pkg ? releasePassesPolicy(release, { did: pkg.did, slug }, config.policy) : true; @@ -474,11 +492,7 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP
    {screenshots.map((shot, i) => ( -
  • +
  • {t`Screenshot`. + * Empty `version` (latest) and `index` (non-screenshot kinds) are omitted. */ -export function artifactProxyUrl(value: unknown): string | null { - if (typeof value !== "string" || value.length === 0) return null; - let parsed: URL; - try { - parsed = new URL(value); - } catch { - return null; +export function artifactProxyUrl(coords: ArtifactCoords): string { + const params = new URLSearchParams(); + params.set("did", coords.did); + params.set("slug", coords.slug); + params.set("kind", coords.kind); + if (coords.version) params.set("version", coords.version); + if (coords.kind === "screenshot" && coords.index !== undefined) { + params.set("index", String(coords.index)); } - if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null; - // Forward the original value, not `parsed.href`: WHATWG normalisation can - // rewrite the path/query and 404 against byte-sensitive hosting. The server - // re-validates independently, so the scheme check here is the only gate. - return `${ARTIFACT_PROXY_ENDPOINT}?url=${encodeURIComponent(value)}`; + return `${ARTIFACT_PROXY_ENDPOINT}?${params.toString()}`; } -/** A single image artifact lifted off a release record. */ +/** + * A single image artifact lifted off a release record. Carries presentation + * dimensions only — the URL is resolved server-side, so the client never holds + * the publisher-supplied URL. + */ export interface MediaArtifact { - url: string; width?: number; height?: number; } +/** + * A screenshot artifact, carrying the index into the release's raw + * `screenshots` array. The proxy resolves by that index, so dropped (malformed) + * entries must not shift the indices of the surviving ones. + */ +export interface ScreenshotArtifact extends MediaArtifact { + index: number; +} + export interface MediaArtifacts { icon?: MediaArtifact; banner?: MediaArtifact; - screenshots: MediaArtifact[]; + screenshots: ScreenshotArtifact[]; } /** * Narrow one entry of a release's `artifacts` map to the fields we render. - * Returns `null` when the value isn't an object with a string `url`. + * Returns `null` when the value isn't an object carrying a usable `url` + * (presence gate), keeping only the dimensions for layout. * * Records are lexicon-validated at the DiscoveryClient boundary, but * `artifacts` is an aggregator pass-through, so each entry still needs - * shape-narrowing before it reaches an ``. + * shape-narrowing. */ function asMediaArtifact(value: unknown): MediaArtifact | null { if (!value || typeof value !== "object") return null; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- narrowed to non-null object above; field shapes checked below const v = value as Record; if (typeof v.url !== "string" || v.url.length === 0) return null; - const artifact: MediaArtifact = { url: v.url }; + const artifact: MediaArtifact = {}; if (typeof v.width === "number") artifact.width = v.width; if (typeof v.height === "number") artifact.height = v.height; return artifact; @@ -493,8 +521,9 @@ function asMediaArtifact(value: unknown): MediaArtifact | null { /** * Pull icon, banner, and the screenshot gallery out of a release's `artifacts` - * map. The lexicon types `screenshots` as an array of artifacts; entries - * without a usable `url` are dropped, and gallery order is preserved. + * map, keeping presence and dimensions only. The lexicon types `screenshots` + * as an array of artifacts; entries without a usable `url` are dropped, and + * gallery order is preserved so screenshot indices line up with the proxy's. */ export function extractMediaArtifacts(artifacts: unknown): MediaArtifacts { const result: MediaArtifacts = { screenshots: [] }; @@ -508,10 +537,10 @@ export function extractMediaArtifacts(artifacts: unknown): MediaArtifacts { if (banner) result.banner = banner; if (Array.isArray(map.screenshots)) { - for (const entry of map.screenshots) { + map.screenshots.forEach((entry, index) => { const artifact = asMediaArtifact(entry); - if (artifact) result.screenshots.push(artifact); - } + if (artifact) result.screenshots.push({ ...artifact, index }); + }); } return result; } diff --git a/packages/admin/tests/lib/registry-artifacts.test.ts b/packages/admin/tests/lib/registry-artifacts.test.ts index 8b54aaf362..e072c4ea9d 100644 --- a/packages/admin/tests/lib/registry-artifacts.test.ts +++ b/packages/admin/tests/lib/registry-artifacts.test.ts @@ -3,25 +3,49 @@ import { describe, expect, it } from "vitest"; import { artifactProxyUrl, extractMediaArtifacts } from "../../src/lib/api/registry"; describe("artifactProxyUrl", () => { - it("routes an https artifact URL through the server proxy", () => { - const url = artifactProxyUrl("https://cdn.example.com/gallery/1.0.0/icon.png"); - expect(url).toBe( - "/_emdash/api/admin/plugins/registry/artifact?url=https%3A%2F%2Fcdn.example.com%2Fgallery%2F1.0.0%2Ficon.png", - ); + it("builds a coordinate-based proxy URL for an icon", () => { + const url = artifactProxyUrl({ + did: "did:plc:abc123", + slug: "myplugin", + version: "1.0.0", + kind: "icon", + }); + const parsed = new URL(url, "https://site.test"); + expect(parsed.pathname).toBe("/_emdash/api/admin/plugins/registry/artifact"); + expect(parsed.searchParams.get("did")).toBe("did:plc:abc123"); + expect(parsed.searchParams.get("slug")).toBe("myplugin"); + expect(parsed.searchParams.get("version")).toBe("1.0.0"); + expect(parsed.searchParams.get("kind")).toBe("icon"); + expect(parsed.searchParams.get("index")).toBeNull(); + }); + + it("encodes coordinate values", () => { + const url = artifactProxyUrl({ did: "did:plc:a&b", slug: "my plugin", kind: "banner" }); + expect(url).toContain("did=did%3Aplc%3Aa%26b"); + expect(url).toContain("slug=my+plugin"); }); - it("returns null for a javascript: URL (lexicon uri permits it)", () => { - expect(artifactProxyUrl("javascript:alert(1)")).toBeNull(); + it("includes the index for a screenshot", () => { + const url = artifactProxyUrl({ + did: "did:plc:abc", + slug: "p", + version: "2.0.0", + kind: "screenshot", + index: 3, + }); + const parsed = new URL(url, "https://site.test"); + expect(parsed.searchParams.get("kind")).toBe("screenshot"); + expect(parsed.searchParams.get("index")).toBe("3"); }); - it("returns null for a relative URL", () => { - expect(artifactProxyUrl("/icon.png")).toBeNull(); + it("omits an empty version", () => { + const url = artifactProxyUrl({ did: "did:plc:abc", slug: "p", kind: "icon" }); + expect(new URL(url, "https://site.test").searchParams.has("version")).toBe(false); }); - it("returns null for a non-string / empty value", () => { - expect(artifactProxyUrl(undefined)).toBeNull(); - expect(artifactProxyUrl(42)).toBeNull(); - expect(artifactProxyUrl("")).toBeNull(); + it("omits the index for non-screenshot kinds", () => { + const url = artifactProxyUrl({ did: "did:plc:abc", slug: "p", kind: "icon", index: 5 }); + expect(new URL(url, "https://site.test").searchParams.has("index")).toBe(false); }); }); @@ -38,24 +62,27 @@ describe("extractMediaArtifacts", () => { expect(extractMediaArtifacts("nope")).toEqual({ screenshots: [] }); }); - it("extracts icon and banner", () => { + it("extracts icon and banner dims without the url", () => { const result = extractMediaArtifacts({ package: { url: "https://x/a.tgz" }, icon, banner }); - expect(result.icon).toEqual(icon); - expect(result.banner).toEqual(banner); + expect(result.icon).toEqual({ width: 256, height: 256 }); + expect(result.banner).toEqual({ width: 1280, height: 320 }); + expect(result.icon).not.toHaveProperty("url"); + expect(result.banner).not.toHaveProperty("url"); expect(result.screenshots).toEqual([]); }); - it("collects the screenshots array in order", () => { + it("collects the screenshots array in order with their raw index", () => { const result = extractMediaArtifacts({ package: { url: "https://x/a.tgz" }, screenshots: [s1, s2, s3], }); - expect(result.screenshots.map((s) => s.url)).toEqual([s1.url, s2.url, s3.url]); + expect(result.screenshots.map((s) => s.index)).toEqual([0, 1, 2]); + for (const shot of result.screenshots) expect(shot).not.toHaveProperty("url"); }); it("handles a single-element screenshots array", () => { const result = extractMediaArtifacts({ screenshots: [s1] }); - expect(result.screenshots.map((s) => s.url)).toEqual([s1.url]); + expect(result.screenshots).toEqual([{ index: 0 }]); }); it("ignores a non-array screenshots value", () => { @@ -68,13 +95,15 @@ describe("extractMediaArtifacts", () => { expect(result.screenshots).toEqual([]); }); - it("skips entries without a usable url", () => { + it("drops malformed entries but preserves the raw index of survivors", () => { const result = extractMediaArtifacts({ icon: { width: 10 }, - screenshots: [{ url: 123 }, s2], + screenshots: [{ url: 123 }, s2, { url: "" }, s3], }); + // `icon` has no usable url -> dropped entirely. expect(result.icon).toBeUndefined(); - // The malformed first entry is dropped; the valid one survives. - expect(result.screenshots.map((s) => s.url)).toEqual([s2.url]); + // Survivors keep their original array indices (1 and 3), so the proxy + // resolves the same entry the publisher declared. + expect(result.screenshots.map((s) => s.index)).toEqual([1, 3]); }); }); diff --git a/packages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts b/packages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts index b9c41c5212..ee03de704d 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts @@ -1,46 +1,139 @@ /** * Registry artifact proxy * - * GET /_emdash/api/admin/plugins/registry/artifact?url= + * GET /_emdash/api/admin/plugins/registry/artifact?did=&slug=&version=&kind=&index= * * Proxies an icon / screenshot / banner image referenced by a registry * release record so the admin UI can display it without cross-origin * requests to arbitrary publisher hosting. * - * Trust model (CRITICAL): unlike the marketplace icon proxy — which fetches - * a single, trusted, operator-configured origin — this proxy fetches an - * ARBITRARY, publisher-supplied URL taken from a registry record. It MUST - * therefore apply the SSRF defences (`assertSafeArtifactUrl`, which wraps - * `resolveAndValidateExternalUrl`) before every fetch, re-validating each - * redirect hop, and serve back only image content types. + * Trust model (CRITICAL): the proxy never accepts an artifact URL from the + * client. The caller addresses an artifact by its coordinates + * `(did, slug, version, kind, index)`; the server resolves the *declared* + * URL from the validated release record fetched from the configured + * aggregator. The proxy can therefore only ever fetch a URL the publisher + * declared in their signed release — not an arbitrary caller-supplied URL. + * + * The publisher-declared URL is still untrusted (an attacker who controls a + * publisher record, or the aggregator, can point it anywhere), so the + * resolved URL passes through the SSRF defences (`assertSafeArtifactUrl`, + * re-validated on every redirect hop) before any fetch, and only allowlisted + * image content types are served back. */ +import type { Did } from "@atcute/lexicons"; import type { APIRoute } from "astro"; import { requirePerm } from "#api/authorize.js"; import { apiError } from "#api/error.js"; import { assertSafeArtifactUrl } from "#api/index.js"; +import { coerceRegistryConfig, validateAggregatorUrl } from "../../../../../../registry/config.js"; + export const prerender = false; -/** Image content types the proxy will pass through. Anything else is rejected. */ +/** + * Image content types the proxy will pass through. Anything else is rejected. + * + * SVG is deliberately excluded: it is active content (an `` - // rendering — the only way the admin UI uses these — never runs that - // script, but the proxy URL is directly navigable. `Content-Disposition: - // attachment` forces a download instead of rendering, and the sandbox - // CSP neutralises script/plugins if a client renders it anyway. Both - // apply to every image type, not just SVG. + // SVG is not in the allowlist, so active-content bytes never reach + // here. `Content-Disposition: attachment`, the sandbox CSP, and + // `nosniff` remain as defence-in-depth: they force a download and + // neutralise script/plugins for any image type if a client navigates + // directly to the proxy URL. return new Response(bytes, { headers: { "Content-Type": contentType, @@ -144,6 +290,68 @@ export const GET: APIRoute = async ({ url, locals }) => { } }; +/** + * Resolve the declared artifact URL for `(did, slug, version, kind, index)` + * from the aggregator's release record. Mirrors the install handler's release + * lookup. Returns `null` when the package/release/artifact isn't found. + * + * Self-contained to this route: the install/update handlers are intentionally + * left untouched, so a small amount of resolution-pattern duplication is + * accepted here. + */ +async function resolveArtifactUrl( + registryConfig: { aggregatorUrl: string; acceptLabelers?: string }, + did: string, + slug: string, + version: string | undefined, + kind: string, + index: number, +): Promise { + // Lazy-load the discovery client so the `@atcute/client` dependency only + // loads when the registry path is exercised. + const { DiscoveryClient } = await import("@emdash-cms/registry-client/discovery"); + + const aggregatorDeadline = Date.now() + AGGREGATOR_TOTAL_BUDGET_MS; + const discovery = new DiscoveryClient({ + aggregatorUrl: registryConfig.aggregatorUrl, + acceptLabelers: registryConfig.acceptLabelers, + fetch: timedFetch(aggregatorDeadline), + }); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- DID shape validated by the route before this call + const publisherDid = did as Did; + + const releaseView = await (async () => { + if (!version) { + return discovery.getLatestRelease({ did: publisherDid, package: slug }); + } + let cursor: string | undefined; + const seenCursors = new Set(); + for (let page = 0; page < MAX_LIST_PAGES; page++) { + if (cursor !== undefined) { + if (seenCursors.has(cursor)) break; + seenCursors.add(cursor); + } + const result = await discovery.listReleases({ + did: publisherDid, + package: slug, + cursor, + limit: 50, + }); + for (const r of result.releases) { + if (r.version === version) return r; + } + if (!result.cursor) break; + cursor = result.cursor; + } + return undefined; + })(); + + if (!releaseView?.release) return null; + + return resolveDeclaredUrl(releaseView.release.artifacts, kind, index); +} + /** * Read a response body into memory, aborting once it exceeds `limit`. Returns * `null` when the cap is breached (the streamed body lied about / omitted diff --git a/packages/core/tests/unit/api/registry-artifact-proxy.test.ts b/packages/core/tests/unit/api/registry-artifact-proxy.test.ts index 6e351e6db0..a58c629736 100644 --- a/packages/core/tests/unit/api/registry-artifact-proxy.test.ts +++ b/packages/core/tests/unit/api/registry-artifact-proxy.test.ts @@ -1,20 +1,25 @@ /** * Registry artifact proxy route. * - * The proxy fetches ARBITRARY publisher-supplied URLs, so it must: - * - reject private / loopback / link-local hosts (SSRF defence), - * - reject non-image content types (allowlist), - * - pass image bytes through with a private, no-store cache header. + * The proxy never accepts an artifact URL from the client. The caller + * addresses an artifact by `(did, slug, version, kind, index)`; the server + * resolves the *declared* URL from the validated release record fetched from + * the aggregator, then fetches it. So the route must: + * - validate the coordinate params (400 on bad input), + * - resolve only the publisher-declared URL (never a caller-supplied one), + * - reject private / loopback / link-local hosts on the resolved URL (SSRF), + * - reject non-image content types, including SVG (allowlist), allow AVIF, + * - cap the body size, and serve image bytes with hardened headers. * - * We drive the route's `GET` directly with a fabricated context, stub - * `globalThis.fetch`, and inject a DNS resolver so hostnames resolve to - * controlled IPs without real network access. + * We drive the route's `GET` directly with a fabricated context, mock the + * `DiscoveryClient` so release resolution returns a controlled `artifacts` + * map, stub `globalThis.fetch` for the artifact fetch, and inject a DNS + * resolver so hostnames resolve to controlled IPs without real network. */ import type { APIContext } from "astro"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { GET } from "../../../src/astro/routes/api/admin/plugins/registry/artifact.js"; import { setDefaultDnsResolver } from "../../../src/security/ssrf.js"; const PNG_1x1 = Uint8Array.from( @@ -24,17 +29,65 @@ const PNG_1x1 = Uint8Array.from( ), ); +// The release record the mocked DiscoveryClient resolves. Tests mutate +// `mockArtifacts` per case to point the declared URL where they need it. +let mockArtifacts: unknown; +let mockReleaseVersion = "1.0.0"; + +const getPackage = vi.fn(async () => ({ profile: {} })); +const getLatestRelease = vi.fn(async () => ({ + version: mockReleaseVersion, + release: { version: mockReleaseVersion, artifacts: mockArtifacts }, +})); +const listReleases = vi.fn(async () => ({ + releases: [ + { + version: mockReleaseVersion, + release: { version: mockReleaseVersion, artifacts: mockArtifacts }, + }, + ], + cursor: undefined, +})); + +vi.mock("@emdash-cms/registry-client/discovery", () => ({ + DiscoveryClient: class { + getPackage = getPackage; + getLatestRelease = getLatestRelease; + listReleases = listReleases; + }, +})); + +// Imported after the mock is registered. +const { GET } = await import("../../../src/astro/routes/api/admin/plugins/registry/artifact.js"); + // Roles are numeric levels: SUBSCRIBER 10, EDITOR 40, ADMIN 50. `plugins:read` // requires EDITOR. const adminUser = { id: "u1", role: 50 }; const subscriberUser = { id: "v", role: 10 }; -function makeContext(target: string | null, user: unknown = adminUser): APIContext { +const AGGREGATOR_URL = "https://registry.example.com"; + +const DEFAULT_PARAMS: Record = { + did: "did:plc:abc123", + slug: "myplugin", + kind: "icon", +}; + +function makeContext( + params: Record = DEFAULT_PARAMS, + user: unknown = adminUser, + registry: unknown = AGGREGATOR_URL, +): APIContext { const u = new URL("https://site.test/_emdash/api/admin/plugins/registry/artifact"); - if (target !== null) u.searchParams.set("url", target); + for (const [key, value] of Object.entries(params)) { + if (value !== null) u.searchParams.set(key, value); + } return { url: u, - locals: { emdash: { db: {} }, user }, + locals: { + emdash: { db: {}, config: { experimental: { registry } } }, + user, + }, } as unknown as APIContext; } @@ -51,6 +104,18 @@ describe("registry artifact proxy", () => { beforeEach(() => { realFetch = globalThis.fetch; + mockArtifacts = { + icon: { url: "https://cdn.example.com/icon.png" }, + banner: { url: "https://cdn.example.com/banner.png" }, + screenshots: [ + { url: "https://cdn.example.com/s0.png" }, + { url: "https://cdn.example.com/s1.png" }, + ], + }; + mockReleaseVersion = "1.0.0"; + getPackage.mockClear(); + getLatestRelease.mockClear(); + listReleases.mockClear(); // Default: every hostname resolves to a public IP. Individual tests // override the resolver to exercise private-IP rejection. setDefaultDnsResolver(async () => ["93.184.216.34"]); @@ -62,44 +127,170 @@ describe("registry artifact proxy", () => { vi.restoreAllMocks(); }); + // ── auth ─────────────────────────────────────────────────────────── + it("requires authentication", async () => { - const res = await GET(makeContext("https://cdn.example.com/icon.png", null)); + const res = await GET(makeContext(DEFAULT_PARAMS, null)); expect(res.status).toBe(401); }); it("forbids users without plugins:read", async () => { - const res = await GET(makeContext("https://cdn.example.com/icon.png", subscriberUser)); - // subscriber lacks plugins:read (editor minimum), so 403. + const res = await GET(makeContext(DEFAULT_PARAMS, subscriberUser)); expect(res.status).toBe(403); }); - it("rejects a missing url param", async () => { - const res = await GET(makeContext(null)); + // ── param validation ─────────────────────────────────────────────── + + it("rejects a missing did/slug/kind", async () => { + expect((await GET(makeContext({ slug: "x", kind: "icon" }))).status).toBe(400); + expect((await GET(makeContext({ did: "did:plc:a", kind: "icon" }))).status).toBe(400); + expect((await GET(makeContext({ did: "did:plc:a", slug: "x" }))).status).toBe(400); + }); + + it("rejects a malformed did", async () => { + const res = await GET(makeContext({ did: "notadid", slug: "x", kind: "icon" })); + expect(res.status).toBe(400); + }); + + it("rejects an invalid slug", async () => { + const res = await GET(makeContext({ did: "did:plc:a", slug: "../etc", kind: "icon" })); + expect(res.status).toBe(400); + }); + + it("rejects an unknown kind", async () => { + const res = await GET(makeContext({ did: "did:plc:a", slug: "x", kind: "favicon" })); + expect(res.status).toBe(400); + }); + + it("rejects a screenshot without an index", async () => { + const res = await GET(makeContext({ did: "did:plc:a", slug: "x", kind: "screenshot" })); expect(res.status).toBe(400); }); - it("passes a happy-path image through with a private cache header", async () => { - globalThis.fetch = vi.fn(async () => imageResponse(PNG_1x1)) as typeof globalThis.fetch; - const res = await GET(makeContext("https://cdn.example.com/icon.png")); + it("rejects a non-integer / negative index", async () => { + expect( + (await GET(makeContext({ did: "did:plc:a", slug: "x", kind: "screenshot", index: "1.5" }))) + .status, + ).toBe(400); + expect( + (await GET(makeContext({ did: "did:plc:a", slug: "x", kind: "screenshot", index: "-1" }))) + .status, + ).toBe(400); + expect( + (await GET(makeContext({ did: "did:plc:a", slug: "x", kind: "screenshot", index: "abc" }))) + .status, + ).toBe(400); + }); + + // ── config ───────────────────────────────────────────────────────── + + it("returns 400 when the registry is not configured", async () => { + const u = new URL("https://site.test/_emdash/api/admin/plugins/registry/artifact"); + for (const [key, value] of Object.entries(DEFAULT_PARAMS)) u.searchParams.set(key, value); + const ctx = { + url: u, + locals: { emdash: { db: {}, config: { experimental: {} } }, user: adminUser }, + } as unknown as APIContext; + const res = await GET(ctx); + expect(res.status).toBe(400); + }); + + // ── resolution → declared URL is proxied ─────────────────────────── + + it("resolves the declared icon URL and proxies it", async () => { + const fetchMock = vi.fn(async () => imageResponse(PNG_1x1)) as typeof globalThis.fetch; + globalThis.fetch = fetchMock; + const res = await GET(makeContext(DEFAULT_PARAMS)); expect(res.status).toBe(200); + expect(getLatestRelease).toHaveBeenCalled(); + // The fetched URL is the publisher-DECLARED url, never a client param. + const fetched = (fetchMock as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[0]; + expect(fetched).toBe("https://cdn.example.com/icon.png"); expect(res.headers.get("content-type")).toBe("image/png"); expect(res.headers.get("cache-control")).toBe("private, no-store"); expect(res.headers.get("x-content-type-options")).toBe("nosniff"); - // Active-content (SVG) defence: force download + sandbox CSP so a direct - // navigation to the proxy URL can't execute script in the admin origin. expect(res.headers.get("content-disposition")).toBe("attachment"); expect(res.headers.get("content-security-policy")).toBe("default-src 'none'; sandbox"); const body = new Uint8Array(await res.arrayBuffer()); expect(body).toEqual(PNG_1x1); }); - it("normalises a content type with parameters", async () => { + it("resolves the declared banner URL", async () => { + const fetchMock = vi.fn(async () => imageResponse(PNG_1x1)) as typeof globalThis.fetch; + globalThis.fetch = fetchMock; + const res = await GET(makeContext({ did: "did:plc:abc123", slug: "myplugin", kind: "banner" })); + expect(res.status).toBe(200); + const fetched = (fetchMock as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[0]; + expect(fetched).toBe("https://cdn.example.com/banner.png"); + }); + + it("resolves the declared screenshot URL by index", async () => { + const fetchMock = vi.fn(async () => imageResponse(PNG_1x1)) as typeof globalThis.fetch; + globalThis.fetch = fetchMock; + const res = await GET( + makeContext({ did: "did:plc:abc123", slug: "myplugin", kind: "screenshot", index: "1" }), + ); + expect(res.status).toBe(200); + const fetched = (fetchMock as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[0]; + expect(fetched).toBe("https://cdn.example.com/s1.png"); + }); + + it("paginates listReleases for an explicit version", async () => { + mockReleaseVersion = "2.0.0"; + const fetchMock = vi.fn(async () => imageResponse(PNG_1x1)) as typeof globalThis.fetch; + globalThis.fetch = fetchMock; + const res = await GET( + makeContext({ did: "did:plc:abc123", slug: "myplugin", kind: "icon", version: "2.0.0" }), + ); + expect(res.status).toBe(200); + expect(listReleases).toHaveBeenCalled(); + expect(getLatestRelease).not.toHaveBeenCalled(); + }); + + // ── 404s ─────────────────────────────────────────────────────────── + + it("returns 404 when the requested artifact kind is absent", async () => { + mockArtifacts = { icon: { url: "https://cdn.example.com/icon.png" } }; + const res = await GET(makeContext({ did: "did:plc:abc123", slug: "myplugin", kind: "banner" })); + expect(res.status).toBe(404); + }); + + it("returns 404 when a screenshot index is out of range", async () => { + const res = await GET( + makeContext({ did: "did:plc:abc123", slug: "myplugin", kind: "screenshot", index: "9" }), + ); + expect(res.status).toBe(404); + }); + + it("returns 404 when the artifact entry has no usable url", async () => { + mockArtifacts = { icon: { width: 64 } }; + const res = await GET(makeContext(DEFAULT_PARAMS)); + expect(res.status).toBe(404); + }); + + it("returns 404 when no release is found", async () => { + getLatestRelease.mockResolvedValueOnce({ version: "1.0.0", release: null } as never); + const res = await GET(makeContext(DEFAULT_PARAMS)); + expect(res.status).toBe(404); + }); + + // ── content-type allowlist ───────────────────────────────────────── + + it("allows AVIF", async () => { globalThis.fetch = vi.fn(async () => - imageResponse(PNG_1x1, "image/png; charset=binary"), + imageResponse(PNG_1x1, "image/avif"), ) as typeof globalThis.fetch; - const res = await GET(makeContext("https://cdn.example.com/icon.png")); + const res = await GET(makeContext(DEFAULT_PARAMS)); expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toBe("image/png"); + expect(res.headers.get("content-type")).toBe("image/avif"); + }); + + it("rejects SVG (active content removed from the allowlist)", async () => { + globalThis.fetch = vi.fn(async () => + imageResponse(new TextEncoder().encode(""), "image/svg+xml"), + ) as typeof globalThis.fetch; + const res = await GET(makeContext(DEFAULT_PARAMS)); + expect(res.status).toBe(415); }); it("rejects a non-image content type", async () => { @@ -110,7 +301,7 @@ describe("registry artifact proxy", () => { headers: { "content-type": "text/html" }, }), ) as typeof globalThis.fetch; - const res = await GET(makeContext("https://cdn.example.com/icon.png")); + const res = await GET(makeContext(DEFAULT_PARAMS)); expect(res.status).toBe(415); }); @@ -118,39 +309,51 @@ describe("registry artifact proxy", () => { globalThis.fetch = vi.fn(async () => imageResponse(PNG_1x1, "application/octet-stream"), ) as typeof globalThis.fetch; - const res = await GET(makeContext("https://cdn.example.com/icon.png")); + const res = await GET(makeContext(DEFAULT_PARAMS)); expect(res.status).toBe(415); }); - it("rejects a non-http(s) scheme", async () => { - globalThis.fetch = vi.fn(async () => imageResponse(PNG_1x1)) as typeof globalThis.fetch; - const res = await GET(makeContext("file:///etc/passwd")); - expect(res.status).toBe(400); - expect(globalThis.fetch).not.toHaveBeenCalled(); + it("normalises a content type with parameters", async () => { + globalThis.fetch = vi.fn(async () => + imageResponse(PNG_1x1, "image/png; charset=binary"), + ) as typeof globalThis.fetch; + const res = await GET(makeContext(DEFAULT_PARAMS)); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("image/png"); }); - // Loopback / localhost are deliberately permitted under `import.meta.env.DEV` - // (the same dev escape hatch `assertSafeArtifactUrl` documents), so they are - // not asserted here — vitest runs in DEV. Production rejection of those is - // covered by `assertSafeArtifactUrl`'s own suite. The link-local, private, - // and DNS-rebinding cases below hold in every environment. + // ── SSRF on the RESOLVED url ──────────────────────────────────────── - it("rejects the cloud metadata IP", async () => { - globalThis.fetch = vi.fn(async () => imageResponse(PNG_1x1)) as typeof globalThis.fetch; - const res = await GET(makeContext("http://169.254.169.254/latest/meta-data/")); + it("rejects a declared non-http(s) scheme", async () => { + mockArtifacts = { icon: { url: "file:///etc/passwd" } }; + const fetchMock = vi.fn(async () => imageResponse(PNG_1x1)) as typeof globalThis.fetch; + globalThis.fetch = fetchMock; + const res = await GET(makeContext(DEFAULT_PARAMS)); expect(res.status).toBe(400); - expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); }); - it("rejects a hostname that resolves to a private IP (DNS rebinding)", async () => { + it("rejects a declared cloud metadata IP", async () => { + mockArtifacts = { icon: { url: "http://169.254.169.254/latest/meta-data/" } }; + const fetchMock = vi.fn(async () => imageResponse(PNG_1x1)) as typeof globalThis.fetch; + globalThis.fetch = fetchMock; + const res = await GET(makeContext(DEFAULT_PARAMS)); + expect(res.status).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects a declared hostname that resolves to a private IP (DNS rebinding)", async () => { + mockArtifacts = { icon: { url: "https://rebind.attacker.test/icon.png" } }; setDefaultDnsResolver(async () => ["10.0.0.5"]); - globalThis.fetch = vi.fn(async () => imageResponse(PNG_1x1)) as typeof globalThis.fetch; - const res = await GET(makeContext("https://rebind.attacker.test/icon.png")); + const fetchMock = vi.fn(async () => imageResponse(PNG_1x1)) as typeof globalThis.fetch; + globalThis.fetch = fetchMock; + const res = await GET(makeContext(DEFAULT_PARAMS)); expect(res.status).toBe(400); - expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); }); it("re-validates a redirect target and rejects a private hop", async () => { + mockArtifacts = { icon: { url: "https://cdn.example.com/redirect" } }; setDefaultDnsResolver(async (host) => host === "cdn.example.com" ? ["93.184.216.34"] : ["169.254.169.254"], ); @@ -161,16 +364,18 @@ describe("registry artifact proxy", () => { headers: { location: "http://internal.attacker.test/secret" }, }), ) as typeof globalThis.fetch; - const res = await GET(makeContext("https://cdn.example.com/redirect")); + const res = await GET(makeContext(DEFAULT_PARAMS)); expect(res.status).toBe(400); }); + // ── upstream + size ───────────────────────────────────────────────── + it("rejects an upstream error status", async () => { globalThis.fetch = vi.fn( async () => new Response("not found", { status: 404, headers: { "content-type": "text/plain" } }), ) as typeof globalThis.fetch; - const res = await GET(makeContext("https://cdn.example.com/missing.png")); + const res = await GET(makeContext(DEFAULT_PARAMS)); expect(res.status).toBe(502); }); @@ -178,14 +383,11 @@ describe("registry artifact proxy", () => { globalThis.fetch = vi.fn(async () => imageResponse(PNG_1x1, "image/png", { "content-length": String(10 * 1024 * 1024) }), ) as typeof globalThis.fetch; - const res = await GET(makeContext("https://cdn.example.com/huge.png")); + const res = await GET(makeContext(DEFAULT_PARAMS)); expect(res.status).toBe(413); }); it("rejects a streamed body that exceeds the cap with no content-length", async () => { - // No content-length header, so the declared-length guard can't fire. The - // body streams past MAX_IMAGE_BYTES (5MB); only readCapped's running tally - // catches it, cancels the reader, and returns null -> 413. const chunk = new Uint8Array(1024 * 1024); let emitted = 0; const body = new ReadableStream({ @@ -204,7 +406,7 @@ describe("registry artifact proxy", () => { }); expect(response.headers.get("content-length")).toBeNull(); globalThis.fetch = vi.fn(async () => response) as typeof globalThis.fetch; - const res = await GET(makeContext("https://cdn.example.com/streamed.png")); + const res = await GET(makeContext(DEFAULT_PARAMS)); expect(res.status).toBe(413); }); }); diff --git a/packages/plugin-cli/schemas/emdash-plugin.schema.json b/packages/plugin-cli/schemas/emdash-plugin.schema.json index b4f1c8d198..6b3e34e280 100644 --- a/packages/plugin-cli/schemas/emdash-plugin.schema.json +++ b/packages/plugin-cli/schemas/emdash-plugin.schema.json @@ -515,7 +515,7 @@ ], "additionalProperties": false, "title": "Artifact file reference", - "description": "A media file (PNG / JPEG / WebP / GIF / SVG) bundled into a release as an icon, screenshot, or banner." + "description": "A media file (PNG / JPEG / WebP / GIF / AVIF) bundled into a release as an icon, screenshot, or banner." }, "__schema47": { "type": "string", diff --git a/packages/plugin-cli/src/manifest/schema.ts b/packages/plugin-cli/src/manifest/schema.ts index cb207b8e98..ff163466bf 100644 --- a/packages/plugin-cli/src/manifest/schema.ts +++ b/packages/plugin-cli/src/manifest/schema.ts @@ -567,7 +567,7 @@ export const ArtifactFileSchema = z .meta({ title: "Artifact file reference", description: - "A media file (PNG / JPEG / WebP / GIF / SVG) bundled into a release as an icon, screenshot, or banner.", + "A media file (PNG / JPEG / WebP / GIF / AVIF) bundled into a release as an icon, screenshot, or banner.", }); /** diff --git a/packages/plugin-cli/src/publish/artifacts.ts b/packages/plugin-cli/src/publish/artifacts.ts index 2be0879c1f..d2adc8dd83 100644 --- a/packages/plugin-cli/src/publish/artifacts.ts +++ b/packages/plugin-cli/src/publish/artifacts.ts @@ -5,7 +5,7 @@ * the `#artifact` record the release embeds: the multibase-multihash checksum, * the MIME content type, and the pixel dimensions. Dimensions come from * `image-size`, which reads only the header bytes (no decode), so it's cheap - * and works for PNG / JPEG / WebP / GIF / SVG. + * and works for PNG / JPEG / WebP / GIF / AVIF. * * Kept filesystem- and network-free so it tests against raw byte fixtures. * The CLI command reads files and uploads them; this module turns bytes + @@ -39,7 +39,7 @@ const TYPE_TO_CONTENT_TYPE: Record = { jpg: "image/jpeg", gif: "image/gif", webp: "image/webp", - svg: "image/svg+xml", + avif: "image/avif", }; /** Thrown when an artifact file isn't a supported image. */ @@ -56,7 +56,7 @@ export class ArtifactError extends Error { /** * Sniff `bytes` as an image and return its content type and dimensions. Throws * `ArtifactError` when the bytes aren't a supported image or carry no usable - * dimensions (e.g. a width-less SVG that `image-size` can't measure). + * dimensions. */ export function measureImage(bytes: Uint8Array): { contentType: string; @@ -76,7 +76,7 @@ export function measureImage(bytes: Uint8Array): { if (type === undefined || !(type in TYPE_TO_CONTENT_TYPE)) { throw new ArtifactError( "ARTIFACT_UNSUPPORTED", - `Artifact image format ${type ? `"${type}"` : "(unknown)"} is not supported. Use PNG, JPEG, WebP, GIF, or SVG.`, + `Artifact image format ${type ? `"${type}"` : "(unknown)"} is not supported. Use PNG, JPEG, WebP, GIF, or AVIF.`, ); } const { width, height } = result; diff --git a/packages/plugin-cli/tests/publish-artifacts.test.ts b/packages/plugin-cli/tests/publish-artifacts.test.ts index f0efb82790..09cd1e5cb5 100644 --- a/packages/plugin-cli/tests/publish-artifacts.test.ts +++ b/packages/plugin-cli/tests/publish-artifacts.test.ts @@ -16,7 +16,39 @@ const PNG_1x1 = Uint8Array.from( /** A 3x5 GIF87a. The logical-screen descriptor at bytes 6-9 carries the size. */ const GIF_3x5 = Uint8Array.from(Buffer.from("4749463837610300050080000000000000ffffff", "hex")); -/** A minimal SVG with explicit width/height attributes. */ +/** + * A minimal ISOBMFF/AVIF header (`ftyp` brand `avif` + `meta>iprp>ipco>ispe`). + * `image-size` reads the brand as the type and the `ispe` box as the + * dimensions (64x48); no full image data is needed. + */ +function box(name: string, payload: Buffer): Buffer { + const buf = Buffer.alloc(8 + payload.length); + buf.writeUInt32BE(buf.length, 0); + buf.write(name, 4, "ascii"); + payload.copy(buf, 8); + return buf; +} +const AVIF_64x48 = (() => { + const ftyp = box( + "ftyp", + Buffer.concat([ + Buffer.from("avif", "ascii"), + Buffer.from([0, 0, 0, 0]), + Buffer.from("avifmif1", "ascii"), + ]), + ); + const ispePayload = Buffer.alloc(12); + ispePayload.writeUInt32BE(64, 4); + ispePayload.writeUInt32BE(48, 8); + const ispe = box("ispe", ispePayload); + const meta = box( + "meta", + Buffer.concat([Buffer.from([0, 0, 0, 0]), box("iprp", box("ipco", ispe))]), + ); + return new Uint8Array(Buffer.concat([ftyp, meta])); +})(); + +/** A minimal SVG with explicit width/height attributes — no longer an accepted type. */ const SVG_12x8 = new TextEncoder().encode( '', ); @@ -38,14 +70,24 @@ describe("measureImage", () => { }); }); - it("reads SVG dimensions and maps to image/svg+xml", () => { - expect(measureImage(SVG_12x8)).toEqual({ - contentType: "image/svg+xml", - width: 12, - height: 8, + it("reads AVIF dimensions and maps to image/avif", () => { + expect(measureImage(AVIF_64x48)).toEqual({ + contentType: "image/avif", + width: 64, + height: 48, }); }); + it("rejects SVG (removed from the allowlist)", () => { + try { + measureImage(SVG_12x8); + throw new Error("expected measureImage to throw"); + } catch (error) { + expect(error).toBeInstanceOf(ArtifactError); + expect((error as ArtifactError).code).toBe("ARTIFACT_UNSUPPORTED"); + } + }); + it("rejects bytes that are not a recognised image", () => { const garbage = new TextEncoder().encode("this is not an image"); expect(() => measureImage(garbage)).toThrow(ArtifactError);