/**
* 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, isAbsolute, 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.screenshots?.length ?? 0)) {
return undefined;
}
const upload = options.upload ?? httpPutUploader;
const out: ReleaseArtifactsInput = {};
if (artifacts.icon) {
out.icon = await resolveOne(artifacts.icon, "icon", "icon", options, upload);
}
if (artifacts.banner) {
out.banner = await resolveOne(artifacts.banner, "banner", "banner", options, upload);
}
if (artifacts.screenshots && artifacts.screenshots.length > 0) {
const screenshots: ReleaseArtifactInput[] = [];
for (const [index, ref] of artifacts.screenshots.entries()) {
screenshots.push(
await resolveOne(
ref,
`screenshot ${index + 1}`,
`screenshot-${index + 1}`,
options,
upload,
),
);
}
out.screenshots = screenshots;
}
return out;
}
async function resolveOne(
ref: ManifestArtifactFile,
label: string,
slot: 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, slot, 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 === ".." || rel.startsWith(`..${sep}`) || isAbsolute(file)) {
throw new ArtifactUploadError(
"ARTIFACT_PATH_ESCAPE",
`${label} artifact path ${file} resolves outside the manifest directory.`,
);
}
return absolute;
}
/**
* 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,
slot: string,
file: string,
): string {
const trimmed = baseUrl.replace(TRAILING_SLASHES, "");
const name = `${slot}-${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}`);
}
};