fallbackToOriginalThumbnail(e.currentTarget, item.url)}
@@ -813,7 +818,7 @@ function MediaListItem({ item, selected, onClick }: MediaListItemProps) {
{isImage ? (

fallbackToOriginalThumbnail(e.currentTarget, item.url)}
diff --git a/packages/admin/src/components/MediaPickerModal.tsx b/packages/admin/src/components/MediaPickerModal.tsx
index 66f4b8bbf4..b2378e46dc 100644
--- a/packages/admin/src/components/MediaPickerModal.tsx
+++ b/packages/admin/src/components/MediaPickerModal.tsx
@@ -27,10 +27,11 @@ import {
} from "../lib/api";
import { useDebouncedValue } from "../lib/hooks.js";
import {
- providerItemToMediaItem,
+ MEDIA_THUMBNAIL_WIDTH,
+ fallbackToOriginalThumbnail,
getFileIcon,
getMediaThumbnailUrl,
- fallbackToOriginalThumbnail,
+ providerItemToMediaItem,
} from "../lib/media-utils";
import { matchesMimeAllowlist, mimeFromUrl } from "../lib/mime-utils.js";
import { cn } from "../lib/utils";
@@ -839,7 +840,9 @@ function MediaPickerItem({
// known. When they're missing we display the original so `onLoad` can read
// the true `naturalWidth`/`naturalHeight` to backfill them — a resized
// rendition would report the thumbnail's dimensions and corrupt the record.
- const displayUrl = needsDimensions ? item.url : getMediaThumbnailUrl(item.url, item.mimeType);
+ const displayUrl = needsDimensions
+ ? item.url
+ : getMediaThumbnailUrl(item.url, item.mimeType, MEDIA_THUMBNAIL_WIDTH, item.storageKey);
const handleImageLoad = React.useCallback(
(e: React.SyntheticEvent
) => {
diff --git a/packages/admin/src/lib/media-utils.ts b/packages/admin/src/lib/media-utils.ts
index d96e5f7e21..0db0013f29 100644
--- a/packages/admin/src/lib/media-utils.ts
+++ b/packages/admin/src/lib/media-utils.ts
@@ -59,16 +59,23 @@ export const MEDIA_THUMBNAIL_WIDTH = 400;
*
* Returns the URL unchanged for non-raster media (an icon renders instead),
* SVGs (vector — nothing to downscale, and some services reject them), and
- * anything not served from the local media route (external/provider URLs are
- * already remote renditions, not same-origin originals).
+ * external/provider URLs that are not backed by EmDash storage (they are
+ * already remote renditions, not same-origin originals). Storage-backed items
+ * with a configured public URL still use the internal source path here so the
+ * endpoint can identify the storage key and delegate it to the configured
+ * image service.
*/
export function getMediaThumbnailUrl(
originalUrl: string,
mimeType: string,
width: number = MEDIA_THUMBNAIL_WIDTH,
+ storageKey?: string,
): string {
if (!mimeType.startsWith("image/") || mimeType === "image/svg+xml") return originalUrl;
- if (!originalUrl.startsWith(INTERNAL_MEDIA_PREFIX)) return originalUrl;
+ const sourceUrl = storageKey
+ ? `${INTERNAL_MEDIA_PREFIX}${encodeURIComponent(storageKey)}`
+ : originalUrl;
+ if (!sourceUrl.startsWith(INTERNAL_MEDIA_PREFIX)) return originalUrl;
// Astro authorizes the media route by absolute origin (see the
// `image.remotePatterns` entry the EmDash integration registers), so the
@@ -78,7 +85,7 @@ export function getMediaThumbnailUrl(
if (!origin) return originalUrl;
const params = new URLSearchParams({
- href: `${origin}${originalUrl}`,
+ href: `${origin}${sourceUrl}`,
w: String(width),
f: "webp",
});
diff --git a/packages/admin/tests/lib/media-thumbnail.test.ts b/packages/admin/tests/lib/media-thumbnail.test.ts
index c34211f910..b4267d0ca7 100644
--- a/packages/admin/tests/lib/media-thumbnail.test.ts
+++ b/packages/admin/tests/lib/media-thumbnail.test.ts
@@ -40,6 +40,18 @@ describe("getMediaThumbnailUrl", () => {
const external = "https://images.example.com/photo.jpg";
expect(getMediaThumbnailUrl(external, "image/jpeg")).toBe(external);
});
+
+ it("routes storage-backed public URLs through /_image using their storage key", () => {
+ const publicUrl = "https://media.example.com/01ABC.heic";
+ const result = getMediaThumbnailUrl(publicUrl, "image/heic", 400, "01ABC.heic");
+ const url = new URL(result, window.location.origin);
+
+ expect(url.pathname).toBe("/_image");
+ expect(url.searchParams.get("href")).toBe(
+ `${window.location.origin}/_emdash/api/media/file/01ABC.heic`,
+ );
+ expect(url.searchParams.get("f")).toBe("webp");
+ });
});
describe("fallbackToOriginalThumbnail", () => {
diff --git a/packages/cloudflare/src/image-endpoint.ts b/packages/cloudflare/src/image-endpoint.ts
index c11ea91887..e6a5b5f966 100644
--- a/packages/cloudflare/src/image-endpoint.ts
+++ b/packages/cloudflare/src/image-endpoint.ts
@@ -17,6 +17,7 @@ import { env } from "cloudflare:workers";
import type { Storage } from "emdash";
import {
IMMUTABLE_IMAGE_CACHE,
+ isHeicMedia,
matchInternalMediaKey,
originalMediaHeaders,
parseTransformParams,
@@ -65,9 +66,11 @@ export const GET: APIRoute = async (ctx) => {
// Not EmDash media, or storage unavailable: let the adapter's endpoint handle
// it (bundled assets via ASSETS, allowed remote via fetch).
if (!key || !storage) return adapterGET(ctx);
+ let transformingHeic = false;
try {
const source = await storage.download(key);
+ transformingHeic = isHeicMedia(source.contentType, key);
// Only raster images are transformable; serve anything else unchanged.
if (!source.contentType.startsWith("image/")) {
@@ -79,6 +82,11 @@ export const GET: APIRoute = async (ctx) => {
// No binding or unparseable params: serve the original so the URL resolves.
if (!images || !parsed.ok) {
+ if (transformingHeic) {
+ return new Response("HEIC is not supported by the configured image service", {
+ status: 415,
+ });
+ }
return streamOriginal(source.body, source.contentType);
}
@@ -110,6 +118,11 @@ export const GET: APIRoute = async (ctx) => {
});
} catch (error) {
if (isNotFound(error)) return new Response("Not Found", { status: 404 });
+ if (transformingHeic) {
+ return new Response("HEIC is not supported by the configured image service", {
+ status: 415,
+ });
+ }
console.error("[emdash] image transform failed:", error);
return new Response("Internal Server Error", { status: 500 });
}
diff --git a/packages/core/src/api/handlers/media-upload.ts b/packages/core/src/api/handlers/media-upload.ts
index 8d128eb300..f1d9d054b0 100644
--- a/packages/core/src/api/handlers/media-upload.ts
+++ b/packages/core/src/api/handlers/media-upload.ts
@@ -15,6 +15,7 @@ import { ulid } from "ulidx";
import { MediaRepository, type MediaItem } from "../../database/repositories/media.js";
import type { Database } from "../../database/types.js";
import { enrichImageMetadata } from "../../media/enrich.js";
+import { isHeicMedia } from "../../media/image-endpoint.js";
import { matchesMimeAllowlist, normalizeMime } from "../../media/mime.js";
import { SsrfError, ssrfSafeFetch } from "../../security/ssrf.js";
import type { Storage } from "../../storage/types.js";
@@ -41,6 +42,8 @@ export interface MediaUploadInput {
authorId?: string;
/** Upload size limit in bytes (defaults to DEFAULT_MAX_UPLOAD_SIZE). */
maxUploadSize?: number;
+ /** Whether the configured runtime image service accepts HEIC-family input. */
+ heicSupported?: boolean;
}
export type MediaUploadResult = ApiResult<{
@@ -166,6 +169,12 @@ export async function handleMediaUpload(
if (bytes.byteLength > rawMax) {
return fail("PAYLOAD_TOO_LARGE", `File exceeds maximum size of ${formatFileSize(rawMax)}`);
}
+ if (isHeicMedia(mimeType, input.filename) && input.heicSupported !== true) {
+ return fail(
+ "UNSUPPORTED_IMAGE_FORMAT",
+ "HEIC images require a configured HEIC-capable image service",
+ );
+ }
try {
const contentHash = await computeContentHash(bytes);
diff --git a/packages/core/src/astro/image-endpoint.ts b/packages/core/src/astro/image-endpoint.ts
index 21763c800e..6dad7b5cbd 100644
--- a/packages/core/src/astro/image-endpoint.ts
+++ b/packages/core/src/astro/image-endpoint.ts
@@ -1,11 +1,11 @@
/**
* Node image endpoint -- the `image.endpoint` EmDash installs on non-Cloudflare
- * platforms whose image service is local (sharp).
+ * platforms.
*
- * It wraps Astro's generic endpoint: for an EmDash media URL it loads the source
- * bytes straight from the storage adapter (no HTTP, so it works behind any auth
- * gate) and runs the configured image service's `transform`; every other image
- * is delegated to the stock endpoint unchanged.
+ * It wraps Astro's generic endpoint: local services transform bytes loaded
+ * directly from the storage adapter, while external services receive the
+ * storage adapter's public URL through Astro's `validateOptions`/`getURL`
+ * contract. Every non-EmDash image is delegated to the stock endpoint.
*/
import type { APIRoute } from "astro";
@@ -16,9 +16,13 @@ import { getConfiguredImageService, imageConfig } from "astro:assets";
import {
IMMUTABLE_IMAGE_CACHE,
+ isHeicMedia,
matchInternalMediaKey,
originalMediaHeaders,
+ parseTransformParams,
+ resolveExternalImageServiceUrl,
} from "../media/image-endpoint.js";
+import { imageServiceConfigSupportsHeic, resolveStorageImageSource } from "./image-service.js";
export const prerender = false;
@@ -42,6 +46,10 @@ function streamOriginal(body: ReadableStream, contentType: string):
return new Response(body, { status: 200, headers: originalMediaHeaders(contentType) });
}
+function unsupportedHeic(): Response {
+ return new Response("HEIC is not supported by the configured image service", { status: 415 });
+}
+
export const GET: APIRoute = async (ctx) => {
const url = new URL(ctx.request.url);
const key = matchInternalMediaKey(url.searchParams.get("href"));
@@ -52,10 +60,44 @@ export const GET: APIRoute = async (ctx) => {
if (!key || !storage) return genericGET(ctx);
const service = await getConfiguredImageService();
- if (!("transform" in service)) return genericGET(ctx);
+ let transformingHeic = false;
try {
+ if (!("transform" in service)) {
+ const parsed = parseTransformParams(url.searchParams);
+ if (!parsed.ok) return new Response(parsed.message, { status: 400 });
+ if (isHeicMedia("", key) && !imageServiceConfigSupportsHeic(imageConfig.service.config)) {
+ return unsupportedHeic();
+ }
+
+ const sourceUrl = resolveStorageImageSource(storage, key, url);
+ const externalUrl = sourceUrl
+ ? await resolveExternalImageServiceUrl(
+ service,
+ imageConfig,
+ sourceUrl,
+ parsed.options,
+ url.origin,
+ )
+ : null;
+ if (externalUrl) {
+ return new Response(null, {
+ status: 302,
+ headers: {
+ Location: externalUrl,
+ "Cache-Control": IMMUTABLE_IMAGE_CACHE,
+ "X-Content-Type-Options": "nosniff",
+ },
+ });
+ }
+ if (isHeicMedia("", key)) return unsupportedHeic();
+
+ const source = await storage.download(key);
+ return streamOriginal(source.body, source.contentType);
+ }
+
const source = await storage.download(key);
+ transformingHeic = isHeicMedia(source.contentType, key);
// Only raster images are transformable; serve anything else unchanged.
if (!source.contentType.startsWith("image/")) {
@@ -78,6 +120,7 @@ export const GET: APIRoute = async (ctx) => {
});
} catch (error) {
if (isNotFound(error)) return new Response("Not Found", { status: 404 });
+ if (transformingHeic) return unsupportedHeic();
console.error("[emdash] image transform failed:", error);
return new Response("Internal Server Error", { status: 500 });
}
diff --git a/packages/core/src/astro/image-service.ts b/packages/core/src/astro/image-service.ts
new file mode 100644
index 0000000000..73edca4a0c
--- /dev/null
+++ b/packages/core/src/astro/image-service.ts
@@ -0,0 +1,107 @@
+/**
+ * Runtime helpers for deciding whether Astro's configured image service can
+ * produce browser-renderable HEIC renditions.
+ */
+
+import {
+ resolveExternalImageServiceUrl,
+ type ImageTransformOptions,
+} from "../media/image-endpoint.js";
+import { INTERNAL_MEDIA_PREFIX } from "../media/normalize.js";
+import type { Storage } from "../storage/types.js";
+
+const CLOUDFLARE_BINDING_SERVICE = "@astrojs/cloudflare/image-service-workerd";
+const CLOUDFLARE_BINDING_ENDPOINTS = new Set([
+ "@astrojs/cloudflare/image-transform-endpoint",
+ "@emdash-cms/cloudflare/image-endpoint",
+]);
+const HEIC_PROBE_KEY = "emdash-heic-support-probe.heic";
+const HEIC_PROBE_TRANSFORM: ImageTransformOptions = { width: 1, format: "webp" };
+const HEIC_INPUT_FORMATS = new Set(["heic", "heif", "image/heic", "image/heif"]);
+
+export function imageServiceConfigSupportsHeic(config: unknown): boolean {
+ if (!config || typeof config !== "object") return false;
+ const formats = (config as { supportedInputFormats?: unknown }).supportedInputFormats;
+ return (
+ Array.isArray(formats) &&
+ formats.some(
+ (format) => typeof format === "string" && HEIC_INPUT_FORMATS.has(format.toLowerCase()),
+ )
+ );
+}
+
+/** Resolve a storage public URL against the current site origin. */
+export function resolveStorageImageSource(
+ storage: Pick,
+ key: string,
+ requestUrl?: string | URL,
+): string | null {
+ const publicUrl = storage.getPublicUrl(key);
+ let base: URL;
+ try {
+ base = requestUrl ? new URL(requestUrl) : new URL(publicUrl);
+ } catch {
+ return null;
+ }
+
+ try {
+ const source = new URL(publicUrl, base.origin);
+ if (source.protocol !== "http:" && source.protocol !== "https:") return null;
+ if (source.origin === base.origin && source.pathname.startsWith(INTERNAL_MEDIA_PREFIX)) {
+ return null;
+ }
+ return source.href;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Check HEIC support without uploading a file.
+ *
+ * Cloudflare's workerd image-service module is intentionally a local
+ * passthrough stub; the EmDash Cloudflare endpoint performs the real transform
+ * with the Images binding, which accepts HEIC. On Node, local services such as
+ * Sharp are treated as unsupported because their HEIC codec availability is
+ * build-dependent. External services opt in by declaring HEIC in
+ * `image.service.config.supportedInputFormats` and returning a distinct
+ * transform URL for a `.heic` storage source. Requiring both prevents an
+ * arbitrary URL-rewriting service from accepting uploads it cannot decode.
+ */
+export async function configuredImageServiceSupportsHeic(
+ storage: Pick,
+ requestUrl?: string | URL,
+): Promise {
+ try {
+ // Keep the Astro virtual module lazy so importing ordinary media routes in
+ // Node/Vitest does not require an active Astro build context.
+ // @ts-ignore - astro:assets is resolved by the consumer's Astro build
+ const { getConfiguredImageService, imageConfig } = await import("astro:assets");
+
+ if (imageConfig.service.entrypoint === CLOUDFLARE_BINDING_SERVICE) {
+ return (
+ typeof imageConfig.endpoint.entrypoint === "string" &&
+ CLOUDFLARE_BINDING_ENDPOINTS.has(imageConfig.endpoint.entrypoint)
+ );
+ }
+
+ const service = await getConfiguredImageService();
+ if ("transform" in service) return false;
+ if (!imageServiceConfigSupportsHeic(imageConfig.service.config)) return false;
+
+ const sourceUrl = resolveStorageImageSource(storage, HEIC_PROBE_KEY, requestUrl);
+ if (!sourceUrl) return false;
+ const requestOrigin = requestUrl ? new URL(requestUrl).origin : new URL(sourceUrl).origin;
+ return (
+ (await resolveExternalImageServiceUrl(
+ service,
+ imageConfig,
+ sourceUrl,
+ HEIC_PROBE_TRANSFORM,
+ requestOrigin,
+ )) !== null
+ );
+ } catch {
+ return false;
+ }
+}
diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts
index 8e392735ae..59acbe8360 100644
--- a/packages/core/src/astro/integration/index.ts
+++ b/packages/core/src/astro/integration/index.ts
@@ -74,12 +74,14 @@ interface ImageRemotePattern {
* `image.domains` / `image.remotePatterns` (relative URLs are never optimized —
* see `isRemoteAllowed`). We authorize the media sources automatically:
*
- * 1. The storage adapter's public URL host (R2 custom domain, S3/CDN).
+ * 1. The storage adapter's public URL host (R2 custom domain, S3/CDN), used
+ * as the source when delegating to an external image service.
* 2. The site's own origin, scoped to the media proxy route
* (`/_emdash/api/media/file/**`), so same-origin proxied media is optimized.
* The components absolutize the media URL against this origin; EmDash's
- * wrapped image endpoint then serves the bytes from storage (so the absolute
- * URL is never fetched). Only registered when `siteUrl` is known at build.
+ * wrapped endpoint gives local services the bytes from storage (so the
+ * absolute URL is never fetched). Only registered when `siteUrl` is known
+ * at build.
* 3. In `astro dev` the dev-server origin isn't known at build time, so we
* register a host-agnostic pattern scoped to the media route. Dev-only.
*
@@ -164,8 +166,8 @@ const PASSTHROUGH_IMAGE_ENDPOINTS = new Set(["@astrojs/cloudflare/image-passthro
/**
* Decide which image endpoint to install (if any). EmDash wraps Astro's image
- * endpoint so EmDash media bytes load from storage; the wrapper delegates other
- * images back to the platform's stock endpoint.
+ * endpoint so EmDash media resolves through storage; the wrapper delegates
+ * other images back to the platform's stock endpoint.
*
* Returns `{ entrypoint }` to install, `{ warn }` to skip with a warning (a
* custom endpoint we can't delegate to), or `{}` to skip silently (opted out).
@@ -494,9 +496,9 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
command,
);
- // Wrap Astro's image endpoint so EmDash media bytes load straight from
- // storage (Access-safe) instead of over HTTP. Skip when the user opts
- // out or has a custom endpoint we can't delegate back to.
+ // Wrap Astro's image endpoint so EmDash media can load from storage or
+ // delegate its public URL to an external service. Skip when the user
+ // opts out or has a custom endpoint we can't delegate back to.
const { entrypoint: imageEndpoint, warn: imageEndpointWarning } = resolveImageEndpoint({
imagesDisabled: resolvedConfig.images === false,
currentEntrypoint: astroConfig.image?.endpoint?.entrypoint,
diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts
index ebaf360ac6..d3940aabaf 100644
--- a/packages/core/src/astro/integration/runtime.ts
+++ b/packages/core/src/astro/integration/runtime.ts
@@ -214,11 +214,12 @@ export interface EmDashConfig {
* Image optimization.
*
* By default EmDash wraps Astro's image endpoint so media served from
- * storage is optimized through the normal `` / `getImage` pipeline,
- * loading source bytes directly from the storage adapter (works behind
- * Cloudflare Access). Set to `false` to leave Astro's image endpoint
- * untouched -- media then renders as a plain `
` unless your image
- * service can fetch it over HTTP.
+ * storage is optimized through the normal `` / `getImage` pipeline.
+ * Local services and the Cloudflare Images binding receive storage bytes
+ * directly; external Node services receive the storage adapter's public URL.
+ * To accept HEIC uploads with an external service, include `"heic"` or
+ * `"heif"` in `image.service.config.supportedInputFormats`. Set this to
+ * `false` to leave Astro's image endpoint untouched.
*/
images?: boolean;
/**
diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts
index 07337359c4..14100cde31 100644
--- a/packages/core/src/astro/middleware.ts
+++ b/packages/core/src/astro/middleware.ts
@@ -623,8 +623,8 @@ export const onRequest = defineMiddleware(async (context, next) => {
collectPageFragments: runtime.collectPageFragments.bind(runtime),
getPublicMediaUrl: createPublicMediaUrlResolver(runtime.storage),
// Exposed so the wrapped image endpoint (`/_image`) can read media
- // bytes from storage on the anonymous fast path -- public `
`
- // requests carry no session.
+ // bytes locally or resolve a public source for an external service.
+ // Public `
` requests carry no session.
storage: runtime.storage,
} as EmDashHandlers;
} catch (error) {
diff --git a/packages/core/src/astro/routes/api/media.ts b/packages/core/src/astro/routes/api/media.ts
index 9dadf3f588..72a294d757 100644
--- a/packages/core/src/astro/routes/api/media.ts
+++ b/packages/core/src/astro/routes/api/media.ts
@@ -18,9 +18,11 @@ import { isParseError, parseQuery } from "#api/parse.js";
import { DEFAULT_MAX_UPLOAD_SIZE, formatFileSize, mediaListQuery } from "#api/schemas.js";
import { MediaRepository } from "#db/repositories/media.js";
import { enrichImageMetadata } from "#media/enrich.js";
+import { isHeicMedia } from "#media/image-endpoint.js";
import { matchesMimeAllowlist, normalizeMime } from "#media/mime.js";
import { computeContentHash } from "#utils/hash.js";
+import { configuredImageServiceSupportsHeic } from "../../image-service.js";
import type { MediaItem } from "../../types.js";
export const prerender = false;
@@ -139,6 +141,16 @@ export const POST: APIRoute = async ({ request, locals }) => {
if (!matchesMimeAllowlist(file.type, allowlist)) {
return apiError("INVALID_TYPE", "File type not allowed", 400);
}
+ if (
+ isHeicMedia(file.type, file.name) &&
+ !(await configuredImageServiceSupportsHeic(emdash.storage, request.url))
+ ) {
+ return apiError(
+ "UNSUPPORTED_IMAGE_FORMAT",
+ "HEIC images require a configured HEIC-capable image service",
+ 415,
+ );
+ }
// Check file size before buffering
if (file.size > maxUploadSize) {
diff --git a/packages/core/src/astro/routes/api/media/[id]/confirm.ts b/packages/core/src/astro/routes/api/media/[id]/confirm.ts
index 944dabf10f..6c6fb517ce 100644
--- a/packages/core/src/astro/routes/api/media/[id]/confirm.ts
+++ b/packages/core/src/astro/routes/api/media/[id]/confirm.ts
@@ -15,8 +15,11 @@ import { apiError, apiSuccess, handleError } from "#api/error.js";
import { isParseError, parseOptionalBody } from "#api/parse.js";
import { mediaConfirmBody } from "#api/schemas.js";
import { enrichImageMetadata } from "#media/enrich.js";
+import { isHeicMedia } from "#media/image-endpoint.js";
import type { MediaItem } from "#types";
+import { configuredImageServiceSupportsHeic } from "../../../../image-service.js";
+
export const prerender = false;
/**
@@ -81,6 +84,24 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
);
if (ownerDenied) return ownerDenied;
+ if (
+ emdash.storage &&
+ isHeicMedia(existing.mimeType, existing.filename) &&
+ !(await configuredImageServiceSupportsHeic(emdash.storage, request.url))
+ ) {
+ try {
+ await emdash.storage.delete(existing.storageKey);
+ } catch {
+ // Best-effort cleanup; the failed row remains hidden from the library.
+ }
+ await repo.markFailed(id);
+ return apiError(
+ "UNSUPPORTED_IMAGE_FORMAT",
+ "HEIC images require a configured HEIC-capable image service",
+ 415,
+ );
+ }
+
// Optionally verify the file exists in storage
if (emdash.storage) {
const exists = await emdash.storage.exists(existing.storageKey);
diff --git a/packages/core/src/astro/routes/api/media/upload-url.ts b/packages/core/src/astro/routes/api/media/upload-url.ts
index edfa95ff2e..1dc7233179 100644
--- a/packages/core/src/astro/routes/api/media/upload-url.ts
+++ b/packages/core/src/astro/routes/api/media/upload-url.ts
@@ -20,6 +20,9 @@ import { isParseError, parseBody } from "#api/parse.js";
import { DEFAULT_MAX_UPLOAD_SIZE, mediaUploadUrlBody } from "#api/schemas.js";
import { matchesMimeAllowlist, normalizeMime } from "#media/mime.js";
+import { isHeicMedia } from "../../../../media/image-endpoint.js";
+import { configuredImageServiceSupportsHeic } from "../../../image-service.js";
+
export const prerender = false;
interface UploadUrlResponse {
@@ -81,6 +84,16 @@ export const POST: APIRoute = async ({ request, locals }) => {
if (!matchesMimeAllowlist(body.contentType, allowlist)) {
return apiError("INVALID_TYPE", "File type not allowed", 400);
}
+ if (
+ isHeicMedia(body.contentType, body.filename) &&
+ !(await configuredImageServiceSupportsHeic(emdash.storage, request.url))
+ ) {
+ return apiError(
+ "UNSUPPORTED_IMAGE_FORMAT",
+ "HEIC images require a configured HEIC-capable image service",
+ 415,
+ );
+ }
const repo = new MediaRepository(emdash.db);
diff --git a/packages/core/src/mcp/server.ts b/packages/core/src/mcp/server.ts
index e25338de24..79c23a728f 100644
--- a/packages/core/src/mcp/server.ts
+++ b/packages/core/src/mcp/server.ts
@@ -1997,6 +1997,11 @@ export function createMcpServer(
}
try {
const { handleMediaUpload } = await import("../api/handlers/media-upload.js");
+ const { configuredImageServiceSupportsHeic } = await import("../astro/image-service.js");
+ const heicSupported = await configuredImageServiceSupportsHeic(
+ emdash.storage,
+ emdash.config.siteUrl,
+ );
return unwrap(
await handleMediaUpload(emdash.db, emdash.storage, {
filename: args.filename,
@@ -2006,6 +2011,7 @@ export function createMcpServer(
alt: args.alt,
authorId: userId,
maxUploadSize: emdash.config.maxUploadSize,
+ heicSupported,
}),
);
} catch (error) {
diff --git a/packages/core/src/media/image-endpoint.ts b/packages/core/src/media/image-endpoint.ts
index a524fc5eb8..e408c8525a 100644
--- a/packages/core/src/media/image-endpoint.ts
+++ b/packages/core/src/media/image-endpoint.ts
@@ -1,12 +1,11 @@
/**
* Portable helpers shared by the platform image-endpoint modules.
*
- * EmDash wraps Astro's image endpoint (`image.endpoint`) so that source bytes
- * for EmDash media are read straight from the storage adapter instead of being
- * fetched over HTTP. The platform endpoint modules (Node: sharp via
- * `astro:assets`; Cloudflare: the `IMAGES` binding) do the actual transform;
- * this module holds the platform-agnostic bits they share: recognizing an
- * EmDash media URL and validating transform query params.
+ * EmDash wraps Astro's image endpoint (`image.endpoint`) so storage-backed
+ * media can be transformed without exposing an authenticated source route.
+ * Node delegates to Astro's configured local or external image service;
+ * Cloudflare uses the `IMAGES` binding. This module holds the portable pieces
+ * they share: recognizing EmDash media URLs and validating transform params.
*
* Kept free of `astro:*` / `virtual:emdash/*` imports so it stays in the
* precompiled package and can be unit-tested directly.
@@ -50,6 +49,89 @@ export interface ImageTransformOptions {
quality?: number;
}
+/** HEIC-family MIME types browsers generally cannot render directly. */
+const HEIC_MIME_TYPES = new Set([
+ "image/heic",
+ "image/heif",
+ "image/heic-sequence",
+ "image/heif-sequence",
+]);
+
+/** HEIC-family extensions used when a client supplies a generic MIME type. */
+const HEIC_FILENAME_PATTERN = /\.(?:heic|heif|heics|heifs|hif)$/i;
+
+/** Whether a MIME type or filename identifies a HEIC-family image. */
+export function isHeicMedia(mimeType: string, filename?: string): boolean {
+ const normalizedMime = (mimeType.split(";")[0] ?? "").trim().toLowerCase();
+ return HEIC_MIME_TYPES.has(normalizedMime) || HEIC_FILENAME_PATTERN.test(filename ?? "");
+}
+
+/** Transform subset needed when delegating storage-backed media to an external service. */
+export interface ExternalImageMetadata {
+ src: string;
+ width: number;
+ height: number;
+ format: string;
+ orientation?: number;
+}
+
+export interface ExternalImageTransform {
+ src: string | ExternalImageMetadata;
+ width?: number;
+ height?: number;
+ format?: string;
+ quality?: string | number;
+ [key: string]: unknown;
+}
+
+/** Structural subset of Astro's external image-service contract. */
+export interface ExternalImageServiceLike {
+ getURL(options: ExternalImageTransform, imageConfig: TConfig): string | Promise;
+ validateOptions?(
+ options: ExternalImageTransform,
+ imageConfig: TConfig,
+ ): ExternalImageTransform | Promise;
+}
+
+/**
+ * Ask an external Astro image service for a rendition URL.
+ *
+ * Returns an absolute http(s) URL, or `null` when the service passes the source
+ * through unchanged or produces a URL a browser must not follow. Keeping this
+ * portable lets the endpoint and upload-capability check share exactly the same
+ * delegation behavior.
+ */
+export async function resolveExternalImageServiceUrl(
+ service: ExternalImageServiceLike,
+ imageConfig: TConfig,
+ sourceUrl: string,
+ options: ImageTransformOptions,
+ requestOrigin: string,
+): Promise {
+ const transform: ExternalImageTransform = {
+ src: sourceUrl,
+ width: options.width,
+ height: options.height,
+ format: options.format,
+ };
+ if (options.quality !== undefined) transform.quality = options.quality;
+
+ const validated = service.validateOptions
+ ? await service.validateOptions(transform, imageConfig)
+ : transform;
+ const generated = await service.getURL(validated, imageConfig);
+
+ try {
+ const source = new URL(sourceUrl, requestOrigin);
+ const destination = new URL(generated, requestOrigin);
+ if (destination.protocol !== "http:" && destination.protocol !== "https:") return null;
+ if (destination.href === source.href) return null;
+ return destination.href;
+ } catch {
+ return null;
+ }
+}
+
/** Long-lived immutable cache -- transform output is deterministic per key+params. */
export const IMMUTABLE_IMAGE_CACHE = "public, max-age=31536000, immutable";
diff --git a/packages/core/tests/integration/astro/media-confirm-placeholder.test.ts b/packages/core/tests/integration/astro/media-confirm-placeholder.test.ts
index bcfc9d345c..12dbeee23c 100644
--- a/packages/core/tests/integration/astro/media-confirm-placeholder.test.ts
+++ b/packages/core/tests/integration/astro/media-confirm-placeholder.test.ts
@@ -8,6 +8,10 @@ import type { Database } from "../../../src/database/types.js";
import { JPEG_4x4 } from "../../utils/image-fixtures.js";
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
+const configuredImageServiceSupportsHeic = vi.hoisted(() => vi.fn(async () => false));
+
+vi.mock("../../../src/astro/image-service.js", () => ({ configuredImageServiceSupportsHeic }));
+
/** Storage stub matching the real interface: download returns a ReadableStream. */
function storageWith(bytes: Uint8Array) {
return {
@@ -129,4 +133,31 @@ describe("POST /media/:id/confirm — placeholder read-back", () => {
expect(row?.blurhash).toBeNull();
expect(row?.dominantColor).toBeNull();
});
+
+ it("fails and removes a signed HEIC upload when the image service is not capable", async () => {
+ const repo = new MediaRepository(db);
+ const pending = await repo.createPending({
+ filename: "photo.heic",
+ mimeType: "image/heic",
+ storageKey: "photo.heic",
+ authorId: "user-1",
+ });
+ const storage = {
+ exists: vi.fn(async () => true),
+ delete: vi.fn(async () => undefined),
+ };
+
+ const res = await postConfirm(
+ buildContext({
+ db,
+ id: pending.id,
+ storage,
+ body: { size: 1024 },
+ }),
+ );
+
+ expect(res.status).toBe(415);
+ expect(storage.delete).toHaveBeenCalledWith("photo.heic");
+ expect((await repo.findById(pending.id))?.status).toBe("failed");
+ });
});
diff --git a/packages/core/tests/unit/api/handlers/media-upload.test.ts b/packages/core/tests/unit/api/handlers/media-upload.test.ts
index 4bca1afd3e..3db9d9edc9 100644
--- a/packages/core/tests/unit/api/handlers/media-upload.test.ts
+++ b/packages/core/tests/unit/api/handlers/media-upload.test.ts
@@ -185,6 +185,32 @@ describe("handleMediaUpload (#620)", () => {
expect(storage.uploads.size).toBe(0);
});
+ it("rejects HEIC before storage when the configured image service is not capable", async () => {
+ const result = await handleMediaUpload(db, storage, {
+ filename: "photo.heic",
+ base64: PNG_BASE64,
+ contentType: "image/heic",
+ heicSupported: false,
+ });
+
+ expect(result.success).toBe(false);
+ if (!result.success) expect(result.error.code).toBe("UNSUPPORTED_IMAGE_FORMAT");
+ expect(storage.uploads.size).toBe(0);
+ });
+
+ it("allows HEIC when the configured image service declares support", async () => {
+ const result = await handleMediaUpload(db, storage, {
+ filename: "photo.heic",
+ base64: PNG_BASE64,
+ contentType: "image/heic",
+ heicSupported: true,
+ });
+
+ expect(result.success).toBe(true);
+ if (result.success) expect(result.data.item.mimeType).toBe("image/heic");
+ expect(storage.uploads.size).toBe(1);
+ });
+
it("rejects payloads over the size limit", async () => {
const result = await handleMediaUpload(db, storage, {
filename: "big.png",
diff --git a/packages/core/tests/unit/astro/image-endpoint-route.test.ts b/packages/core/tests/unit/astro/image-endpoint-route.test.ts
new file mode 100644
index 0000000000..72af15064f
--- /dev/null
+++ b/packages/core/tests/unit/astro/image-endpoint-route.test.ts
@@ -0,0 +1,100 @@
+import type { APIContext } from "astro";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const assets = vi.hoisted(() => ({
+ service: {} as Record,
+ imageConfig: {
+ service: { entrypoint: "custom-external", config: {} },
+ endpoint: { route: "/_image" },
+ },
+ genericGET: vi.fn(),
+}));
+
+vi.mock(
+ "astro:assets",
+ () => ({
+ getConfiguredImageService: async () => assets.service,
+ imageConfig: assets.imageConfig,
+ }),
+ { virtual: true },
+);
+vi.mock("astro/assets/endpoint/generic", () => ({ GET: assets.genericGET }), { virtual: true });
+
+import { GET } from "../../../src/astro/image-endpoint.js";
+
+function context(key: string, storage: Record): APIContext {
+ const href = `https://site.example.com/_emdash/api/media/file/${key}`;
+ const request = new Request(
+ `https://site.example.com/_image?href=${encodeURIComponent(href)}&w=400&f=webp`,
+ );
+ return {
+ request,
+ url: new URL(request.url),
+ params: {},
+ locals: { emdash: { storage } },
+ // eslint-disable-next-line typescript/no-unsafe-type-assertion -- minimal route context
+ } as unknown as APIContext;
+}
+
+describe("storage-backed Node image endpoint", () => {
+ beforeEach(() => {
+ assets.genericGET.mockReset();
+ assets.imageConfig.service.entrypoint = "custom-external";
+ assets.imageConfig.service.config = {};
+ });
+
+ it("redirects storage media through the configured external image service", async () => {
+ assets.imageConfig.service.config = { supportedInputFormats: ["heic"] };
+ assets.service = {
+ getURL: async ({ src, width }: { src: string; width: number }) =>
+ `https://images.example.com/w_${width}/${src}`,
+ };
+ const download = vi.fn();
+
+ const response = await GET(
+ context("photo.heic", {
+ getPublicUrl: (key: string) => `https://media.example.com/${key}`,
+ download,
+ }),
+ );
+
+ expect(response.status).toBe(302);
+ expect(response.headers.get("Location")).toBe(
+ "https://images.example.com/w_400/https://media.example.com/photo.heic",
+ );
+ expect(download).not.toHaveBeenCalled();
+ expect(assets.genericGET).not.toHaveBeenCalled();
+ });
+
+ it("reports HEIC as unsupported when an external service rewrites without declaring support", async () => {
+ assets.service = {
+ getURL: async ({ src }: { src: string }) => `https://images.example.com/${src}`,
+ };
+ const download = vi.fn();
+
+ const response = await GET(
+ context("photo.heic", {
+ getPublicUrl: (key: string) => `https://media.example.com/${key}`,
+ download,
+ }),
+ );
+
+ expect(response.status).toBe(415);
+ expect(download).not.toHaveBeenCalled();
+ });
+
+ it("reports HEIC as unsupported when an external service passes it through", async () => {
+ assets.service = { getURL: async ({ src }: { src: string }) => src };
+ const download = vi.fn();
+
+ const response = await GET(
+ context("photo.heic", {
+ getPublicUrl: (key: string) => `https://media.example.com/${key}`,
+ download,
+ }),
+ );
+
+ expect(response.status).toBe(415);
+ expect(download).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/core/tests/unit/astro/image-service.test.ts b/packages/core/tests/unit/astro/image-service.test.ts
new file mode 100644
index 0000000000..890a638614
--- /dev/null
+++ b/packages/core/tests/unit/astro/image-service.test.ts
@@ -0,0 +1,108 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { Storage } from "../../../src/storage/types.js";
+
+const astroAssets = vi.hoisted(() => ({
+ service: {} as Record,
+ imageConfig: {
+ service: { entrypoint: "astro/assets/services/sharp", config: {} },
+ endpoint: { route: "/_image", entrypoint: "emdash/image-endpoint" },
+ },
+}));
+
+vi.mock(
+ "astro:assets",
+ () => ({
+ getConfiguredImageService: async () => astroAssets.service,
+ imageConfig: astroAssets.imageConfig,
+ }),
+ { virtual: true },
+);
+
+import {
+ configuredImageServiceSupportsHeic,
+ resolveStorageImageSource,
+} from "../../../src/astro/image-service.js";
+
+const storage = {
+ getPublicUrl: (key: string) => `https://media.example.com/${key}`,
+} as Storage;
+
+describe("resolveStorageImageSource", () => {
+ it("rejects the authenticated media fallback as an external-service source", () => {
+ expect(
+ resolveStorageImageSource(
+ {
+ getPublicUrl: (key: string) => `/_emdash/api/media/file/${key}`,
+ },
+ "photo.heic",
+ "https://site.example.com/_image",
+ ),
+ ).toBeNull();
+ });
+});
+
+describe("configuredImageServiceSupportsHeic", () => {
+ beforeEach(() => {
+ astroAssets.imageConfig.service.entrypoint = "astro/assets/services/sharp";
+ astroAssets.imageConfig.service.config = {};
+ astroAssets.imageConfig.endpoint.entrypoint = "emdash/image-endpoint";
+ astroAssets.service = {};
+ });
+
+ it("accepts an external service that declares HEIC input and rewrites the storage URL", async () => {
+ astroAssets.imageConfig.service.entrypoint = "cloudinary-astro/service";
+ astroAssets.imageConfig.service.config = { supportedInputFormats: ["heic"] };
+ astroAssets.service = {
+ getURL: async ({ src }: { src: string }) => `https://res.cloudinary.com/demo/${src}`,
+ };
+
+ await expect(
+ configuredImageServiceSupportsHeic(storage, "https://site.example.com/_emdash/admin"),
+ ).resolves.toBe(true);
+ });
+
+ it("rejects an external service that rewrites HEIC without declaring input support", async () => {
+ astroAssets.imageConfig.service.entrypoint = "custom-external";
+ astroAssets.service = {
+ getURL: async ({ src }: { src: string }) => `https://images.example.com/${src}`,
+ };
+
+ await expect(
+ configuredImageServiceSupportsHeic(storage, "https://site.example.com/_emdash/admin"),
+ ).resolves.toBe(false);
+ });
+
+ it("rejects a local service and an external passthrough service", async () => {
+ astroAssets.service = { transform: async () => ({ data: new Uint8Array(), format: "webp" }) };
+ await expect(
+ configuredImageServiceSupportsHeic(storage, "https://site.example.com"),
+ ).resolves.toBe(false);
+
+ astroAssets.imageConfig.service.entrypoint = "custom-external";
+ astroAssets.service = { getURL: async ({ src }: { src: string }) => src };
+ await expect(
+ configuredImageServiceSupportsHeic(storage, "https://site.example.com"),
+ ).resolves.toBe(false);
+ });
+
+ it("keeps the Cloudflare Images binding capable despite its local service stub", async () => {
+ astroAssets.imageConfig.service.entrypoint = "@astrojs/cloudflare/image-service-workerd";
+ astroAssets.imageConfig.endpoint.entrypoint = "@emdash-cms/cloudflare/image-endpoint";
+ astroAssets.service = { transform: async () => ({ data: new Uint8Array(), format: "webp" }) };
+
+ await expect(
+ configuredImageServiceSupportsHeic(storage, "https://site.example.com"),
+ ).resolves.toBe(true);
+ });
+
+ it("rejects the Cloudflare workerd stub when the runtime endpoint is passthrough", async () => {
+ astroAssets.imageConfig.service.entrypoint = "@astrojs/cloudflare/image-service-workerd";
+ astroAssets.imageConfig.endpoint.entrypoint = "@astrojs/cloudflare/image-passthrough-endpoint";
+ astroAssets.service = { transform: async () => ({ data: new Uint8Array(), format: "webp" }) };
+
+ await expect(
+ configuredImageServiceSupportsHeic(storage, "https://site.example.com"),
+ ).resolves.toBe(false);
+ });
+});
diff --git a/packages/core/tests/unit/astro/media-heic-upload-routes.test.ts b/packages/core/tests/unit/astro/media-heic-upload-routes.test.ts
new file mode 100644
index 0000000000..bcf36e21ea
--- /dev/null
+++ b/packages/core/tests/unit/astro/media-heic-upload-routes.test.ts
@@ -0,0 +1,77 @@
+import type { APIContext } from "astro";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const configuredImageServiceSupportsHeic = vi.hoisted(() => vi.fn(async () => false));
+
+vi.mock("../../../src/astro/image-service.js", () => ({ configuredImageServiceSupportsHeic }));
+
+import { POST as directUpload } from "../../../src/astro/routes/api/media.js";
+import { POST as signedUpload } from "../../../src/astro/routes/api/media/upload-url.js";
+
+function context(request: Request, emdash: Record): APIContext {
+ return {
+ request,
+ url: new URL(request.url),
+ params: {},
+ locals: {
+ emdash,
+ user: { id: "user-1", email: "t@example.com", name: "T", role: 50 as const },
+ },
+ // eslint-disable-next-line typescript/no-unsafe-type-assertion -- minimal route context
+ } as unknown as APIContext;
+}
+
+describe("HEIC upload capability gate", () => {
+ beforeEach(() => {
+ configuredImageServiceSupportsHeic.mockResolvedValue(false);
+ });
+
+ it("rejects direct uploads before writing to storage", async () => {
+ const upload = vi.fn();
+ const form = new FormData();
+ form.append(
+ "file",
+ new File([new Uint8Array([1, 2, 3])], "photo.heic", { type: "image/heic" }),
+ );
+ const request = new Request("https://site.example.com/_emdash/api/media", {
+ method: "POST",
+ body: form,
+ });
+
+ const response = await directUpload(
+ context(request, {
+ db: {},
+ config: {},
+ handleMediaCreate: vi.fn(),
+ storage: { upload },
+ }),
+ );
+
+ expect(response.status).toBe(415);
+ expect(upload).not.toHaveBeenCalled();
+ });
+
+ it("rejects signed uploads before creating a pending object", async () => {
+ const getSignedUploadUrl = vi.fn();
+ const request = new Request("https://site.example.com/_emdash/api/media/upload-url", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ filename: "photo.heic",
+ contentType: "image/heic",
+ size: 1024,
+ }),
+ });
+
+ const response = await signedUpload(
+ context(request, {
+ db: {},
+ config: {},
+ storage: { getSignedUploadUrl },
+ }),
+ );
+
+ expect(response.status).toBe(415);
+ expect(getSignedUploadUrl).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/core/tests/unit/media/image-endpoint.test.ts b/packages/core/tests/unit/media/image-endpoint.test.ts
index f6d567d38b..79ef79482a 100644
--- a/packages/core/tests/unit/media/image-endpoint.test.ts
+++ b/packages/core/tests/unit/media/image-endpoint.test.ts
@@ -2,7 +2,9 @@ import { describe, it, expect } from "vitest";
import {
matchInternalMediaKey,
+ resolveExternalImageServiceUrl,
isSafeTransformKey,
+ isHeicMedia,
parseTransformParams,
resolveTransformQuality,
isTransformFormat,
@@ -11,6 +13,74 @@ import {
MAX_TRANSFORM_DIMENSION,
} from "../../../src/media/image-endpoint.js";
+describe("isHeicMedia", () => {
+ it("recognizes HEIC-family MIME types and filename extensions", () => {
+ expect(isHeicMedia("image/heic")).toBe(true);
+ expect(isHeicMedia("image/heif-sequence")).toBe(true);
+ expect(isHeicMedia("application/octet-stream", "photo.HEIC")).toBe(true);
+ expect(isHeicMedia("image/jpeg", "photo.jpg")).toBe(false);
+ });
+});
+
+describe("resolveExternalImageServiceUrl", () => {
+ it("delegates a storage URL and validated transform to an external service", async () => {
+ const service = {
+ validateOptions: vi.fn(async (options: Record) => ({
+ ...options,
+ quality: 72,
+ })),
+ getURL: vi.fn(async (options: Record) => {
+ return `https://images.example.com/w_${String(options.width)},q_${String(options.quality)}/${String(options.src)}`;
+ }),
+ };
+ const imageConfig = { service: { config: { account: "example" } } };
+
+ const result = await resolveExternalImageServiceUrl(
+ service,
+ imageConfig,
+ "https://media.example.com/photo.heic",
+ { width: 400, format: "webp", quality: undefined },
+ "https://site.example.com",
+ );
+
+ expect(result).toBe(
+ "https://images.example.com/w_400,q_72/https://media.example.com/photo.heic",
+ );
+ expect(service.validateOptions).toHaveBeenCalledWith(
+ {
+ src: "https://media.example.com/photo.heic",
+ width: 400,
+ format: "webp",
+ },
+ imageConfig,
+ );
+ });
+
+ it("rejects passthrough and non-http service URLs", async () => {
+ const source = "https://media.example.com/photo.heic";
+ const options = { width: 400, format: "webp" as const, quality: undefined };
+
+ await expect(
+ resolveExternalImageServiceUrl(
+ { getURL: async () => source },
+ {},
+ source,
+ options,
+ "https://site.example.com",
+ ),
+ ).resolves.toBeNull();
+ await expect(
+ resolveExternalImageServiceUrl(
+ { getURL: async () => "javascript:alert(1)" },
+ {},
+ source,
+ options,
+ "https://site.example.com",
+ ),
+ ).resolves.toBeNull();
+ });
+});
+
describe("matchInternalMediaKey", () => {
it("extracts the key from a relative internal media URL", () => {
expect(matchInternalMediaKey("/_emdash/api/media/file/01J5ABC.webp")).toBe("01J5ABC.webp");