Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/registry-image-artifacts.md
Original file line number Diff line number Diff line change
@@ -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 <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.
74 changes: 72 additions & 2 deletions packages/admin/src/components/RegistryPluginDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import { Link } from "@tanstack/react-router";
import * as React from "react";

import {
artifactProxyUrl,
canonicalCapabilitiesForDriftCheck,
extractMediaArtifacts,
getRegistryPackage,
installRegistryPlugin,
listRegistryReleases,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -300,10 +331,29 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP
<div className="space-y-6">
<BackLink />

{/* Banner */}
{bannerSrc ? (
<img
src={bannerSrc}
alt={t`${displayName ?? slug} banner`}
className="h-40 w-full rounded-xl object-cover"
loading="lazy"
/>
) : null}

{/* Header */}
<div className="flex flex-wrap items-start gap-4">
<div className="rounded-xl bg-kumo-subtle p-3 text-kumo-subtle">
<span aria-hidden className="block h-10 w-10" />
<div className="overflow-hidden rounded-xl bg-kumo-subtle text-kumo-subtle">
{iconSrc ? (
<img
src={iconSrc}
alt={t`${displayName ?? slug} icon`}
className="block h-16 w-16 object-cover"
loading="lazy"
/>
) : (
<span aria-hidden className="block h-16 w-16" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
Expand Down Expand Up @@ -437,6 +487,26 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP
{/* Description */}
{description ? <p className="text-base text-kumo-default">{description}</p> : null}

{/* Screenshot gallery */}
{screenshots.length > 0 ? (
<section aria-label={t`Screenshots`}>
<ul className="flex snap-x gap-3 overflow-x-auto pb-2">
{screenshots.map((shot, i) => (
<li key={shot.index} className="shrink-0 snap-start">
<img
src={shot.src}
alt={t`Screenshot ${i + 1}`}
width={shot.width}
height={shot.height}
className="h-48 w-auto rounded-lg border border-kumo-default object-cover"
loading="lazy"
/>
</li>
))}
</ul>
</section>
) : null}

{/* License / keywords / repository */}
{licenseText || repoHref || keywordList.length > 0 ? (
<section className="flex flex-wrap items-center gap-2">
Expand Down
117 changes: 117 additions & 0 deletions packages/admin/src/lib/api/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,123 @@ export async function resolveDidToHandle(did: string): Promise<DidHandleResoluti
return result;
}

// ---------------------------------------------------------------------------
// Artifact proxy (server GET)
// ---------------------------------------------------------------------------

const ARTIFACT_PROXY_ENDPOINT = `${API_BASE}/admin/plugins/registry/artifact`;

/** Artifact kinds the server proxy can resolve from a release record. */
export type ArtifactKind = "icon" | "banner" | "screenshot";

/**
* Coordinates identifying one image artifact on a release record. The browser
* sends these to the server proxy, which resolves the publisher-declared URL
* server-side from the validated release record — the raw publisher URL never
* leaves the server, so the client cannot coerce the proxy into fetching an
* undeclared URL.
*/
export interface ArtifactCoords {
did: string;
slug: string;
version?: string;
kind: ArtifactKind;
/** Required for `kind: "screenshot"`; ignored otherwise. */
index?: number;
}

/**
* Build the URL of the server-side artifact proxy for an artifact addressed by
* its `(did, slug, version, kind, index)` coordinates. The browser never sends
* the publisher's URL — the proxy resolves the *declared* URL from the release
* record, applies SSRF defences, enforces an image content-type allowlist, and
* serves the bytes back same-origin.
*
* Empty `version` (latest) and `index` (non-screenshot kinds) are omitted.
*/
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));
}
return `${ARTIFACT_PROXY_ENDPOINT}?${params.toString()}`;
}

/**
* 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 {
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: ScreenshotArtifact[];
}

/**
* Narrow one entry of a release's `artifacts` map to the fields we render.
* 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.
*/
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<string, unknown>;
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<string, unknown>;

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)
// ---------------------------------------------------------------------------
Expand Down
109 changes: 109 additions & 0 deletions packages/admin/tests/lib/registry-artifacts.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
1 change: 1 addition & 0 deletions packages/core/src/api/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ export {

// Registry handlers (experimental)
export {
assertSafeArtifactUrl,
handleRegistryInstall,
handleRegistryUninstall,
handleRegistryUpdate,
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/astro/integration/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading
Loading