Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/bright-otters-convert.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@emdash-cms/cloudflare": patch
"emdash": patch
---

Fixes HEIC media handling by using the configured image service for browser-ready renditions and rejecting uploads when that service cannot support HEIC input.
9 changes: 7 additions & 2 deletions packages/admin/src/components/MediaLibrary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -720,7 +720,12 @@ function MediaGridItem({ item, selected, onClick }: MediaGridItemProps) {
<div className="aspect-square">
{isImage ? (
<img
src={getMediaThumbnailUrl(item.url, item.mimeType, MEDIA_THUMBNAIL_WIDTH)}
src={getMediaThumbnailUrl(
item.url,
item.mimeType,
MEDIA_THUMBNAIL_WIDTH,
item.storageKey,
)}
alt={item.alt || item.filename}
className="h-full w-full object-cover"
onError={(e) => fallbackToOriginalThumbnail(e.currentTarget, item.url)}
Expand Down Expand Up @@ -813,7 +818,7 @@ function MediaListItem({ item, selected, onClick }: MediaListItemProps) {
<div className="h-10 w-10 overflow-hidden rounded">
{isImage ? (
<img
src={getMediaThumbnailUrl(item.url, item.mimeType, 80)}
src={getMediaThumbnailUrl(item.url, item.mimeType, 80, item.storageKey)}
alt={item.alt || item.filename}
className="h-full w-full object-cover"
onError={(e) => fallbackToOriginalThumbnail(e.currentTarget, item.url)}
Expand Down
9 changes: 6 additions & 3 deletions packages/admin/src/components/MediaPickerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<HTMLImageElement>) => {
Expand Down
15 changes: 11 additions & 4 deletions packages/admin/src/lib/media-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
});
Expand Down
12 changes: 12 additions & 0 deletions packages/admin/tests/lib/media-thumbnail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
13 changes: 13 additions & 0 deletions packages/cloudflare/src/image-endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { env } from "cloudflare:workers";
import type { Storage } from "emdash";
import {
IMMUTABLE_IMAGE_CACHE,
isHeicMedia,
matchInternalMediaKey,
originalMediaHeaders,
parseTransformParams,
Expand Down Expand Up @@ -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/")) {
Expand All @@ -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);
}

Expand Down Expand Up @@ -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 });
}
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/api/handlers/media-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<{
Expand Down Expand Up @@ -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);
Expand Down
55 changes: 49 additions & 6 deletions packages/core/src/astro/image-endpoint.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;

Expand All @@ -42,6 +46,10 @@ function streamOriginal(body: ReadableStream<Uint8Array>, 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"));
Expand All @@ -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/")) {
Expand All @@ -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 });
}
Expand Down
107 changes: 107 additions & 0 deletions packages/core/src/astro/image-service.ts
Original file line number Diff line number Diff line change
@@ -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<Storage, "getPublicUrl">,
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<Storage, "getPublicUrl">,
requestUrl?: string | URL,
): Promise<boolean> {
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;
}
}
Loading
Loading