diff --git a/.changeset/registry-image-artifacts.md b/.changeset/registry-image-artifacts.md new file mode 100644 index 0000000000..01b30a0534 --- /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. 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 f0cd9260af..028359cef4 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,35 @@ 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. 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 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; // Handle resolution affects display only -- installs are addressed @@ -300,10 +331,29 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP
+ {/* Banner */} + {bannerSrc ? ( + {t`${displayName + ) : null} + {/* Header */}
-
- +
+ {iconSrc ? ( + {t`${displayName + ) : ( + + )}
@@ -437,6 +487,26 @@ 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..fc2468c433 100644 --- a/packages/admin/src/lib/api/registry.ts +++ b/packages/admin/src/lib/api/registry.ts @@ -428,6 +428,123 @@ export async function resolveDidToHandle(did: string): Promise; + if (typeof v.url !== "string" || v.url.length === 0) return null; + const artifact: MediaArtifact = {}; + 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, 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: [] }; + 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; + + if (Array.isArray(map.screenshots)) { + map.screenshots.forEach((entry, index) => { + const artifact = asMediaArtifact(entry); + if (artifact) result.screenshots.push({ ...artifact, index }); + }); + } + 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..e072c4ea9d --- /dev/null +++ b/packages/admin/tests/lib/registry-artifacts.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; + +import { artifactProxyUrl, extractMediaArtifacts } from "../../src/lib/api/registry"; + +describe("artifactProxyUrl", () => { + 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("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("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("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); + }); +}); + +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 dims without the url", () => { + const result = extractMediaArtifacts({ package: { url: "https://x/a.tgz" }, icon, 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 with their raw index", () => { + const result = extractMediaArtifacts({ + package: { url: "https://x/a.tgz" }, + screenshots: [s1, s2, s3], + }); + 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).toEqual([{ index: 0 }]); + }); + + 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("drops malformed entries but preserves the raw index of survivors", () => { + const result = extractMediaArtifacts({ + icon: { width: 10 }, + screenshots: [{ url: 123 }, s2, { url: "" }, s3], + }); + // `icon` has no usable url -> dropped entirely. + expect(result.icon).toBeUndefined(); + // 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/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..ee03de704d --- /dev/null +++ b/packages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts @@ -0,0 +1,388 @@ +/** + * Registry artifact proxy + * + * 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): 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. + * + * SVG is deliberately excluded: it is active content (an `