From 7b38f91ed833ad05bcecedb82f6bcfafd7bcfbc2 Mon Sep 17 00:00:00 2001 From: Mason James <297610+masonjames@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:45:06 -0400 Subject: [PATCH 1/3] fix(admin): convert HEIC uploads for browser display --- .changeset/bright-otters-convert.md | 5 + packages/admin/package.json | 1 + packages/admin/src/lib/api/media.ts | 3 + packages/admin/src/lib/media-upload.ts | 31 ++++++ packages/admin/tests/lib/media-upload.test.ts | 104 ++++++++++++++++++ pnpm-lock.yaml | 8 ++ 6 files changed, 152 insertions(+) create mode 100644 .changeset/bright-otters-convert.md create mode 100644 packages/admin/src/lib/media-upload.ts create mode 100644 packages/admin/tests/lib/media-upload.test.ts diff --git a/.changeset/bright-otters-convert.md b/.changeset/bright-otters-convert.md new file mode 100644 index 0000000000..7ba3b8ec69 --- /dev/null +++ b/.changeset/bright-otters-convert.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +Fixes broken thumbnails and public images for new HEIC and HEIF uploads by converting them to JPEG before saving them to the media library. diff --git a/packages/admin/package.json b/packages/admin/package.json index 7c1bbeea1b..5964e472ec 100644 --- a/packages/admin/package.json +++ b/packages/admin/package.json @@ -72,6 +72,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dompurify": "^3.3.2", + "heic-to": "^1.5.2", "marked": "^17.0.3", "react-hotkeys-hook": "^5.2.4", "tailwind-merge": "^3.3.0", diff --git a/packages/admin/src/lib/api/media.ts b/packages/admin/src/lib/api/media.ts index 9e0ccbdf10..4175184f8b 100644 --- a/packages/admin/src/lib/api/media.ts +++ b/packages/admin/src/lib/api/media.ts @@ -5,6 +5,7 @@ import { i18n } from "@lingui/core"; import { msg } from "@lingui/core/macro"; +import { prepareMediaUploadFile } from "../media-upload.js"; import { API_BASE, apiFetch, @@ -227,6 +228,8 @@ async function uploadMediaDirect(file: File, opts?: { fieldId?: string }): Promi * (for local storage) if signed URLs are not supported. */ export async function uploadMedia(file: File, opts?: { fieldId?: string }): Promise { + file = await prepareMediaUploadFile(file); + // Try to get a signed upload URL const uploadInfo = await getUploadUrl(file, opts); diff --git a/packages/admin/src/lib/media-upload.ts b/packages/admin/src/lib/media-upload.ts new file mode 100644 index 0000000000..bfc9ade2a1 --- /dev/null +++ b/packages/admin/src/lib/media-upload.ts @@ -0,0 +1,31 @@ +const HEIC_MIME_TYPES = new Set(["image/heic", "image/heif"]); +const HEIC_FILENAME_PATTERN = /\.(?:heic|heif)$/i; + +export type HeicConverter = (file: File) => Promise; + +async function convertHeicToJpeg(file: File): Promise { + const { heicTo } = await import("heic-to/csp"); + return heicTo({ blob: file, type: "image/jpeg", quality: 0.9 }); +} + +function isHeicFile(file: File): boolean { + return HEIC_MIME_TYPES.has(file.type.toLowerCase()) || HEIC_FILENAME_PATTERN.test(file.name); +} + +function jpegFilename(filename: string): string { + const stem = filename.replace(HEIC_FILENAME_PATTERN, ""); + return `${stem || "image"}.jpg`; +} + +export async function prepareMediaUploadFile( + file: File, + convert: HeicConverter = convertHeicToJpeg, +): Promise { + if (!isHeicFile(file)) return file; + + const jpeg = await convert(file); + return new File([jpeg], jpegFilename(file.name), { + type: "image/jpeg", + lastModified: file.lastModified, + }); +} diff --git a/packages/admin/tests/lib/media-upload.test.ts b/packages/admin/tests/lib/media-upload.test.ts new file mode 100644 index 0000000000..1563243449 --- /dev/null +++ b/packages/admin/tests/lib/media-upload.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from "vitest"; + +import { uploadMedia } from "../../src/lib/api/media.js"; +import { prepareMediaUploadFile } from "../../src/lib/media-upload.js"; + +const HEIC_64X64 = + "AAAAJGZ0eXBoZWljAAAAAG1pZjFNaVBybWlhZk1pSEJoZWljAAABwm1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAHBpY3QAAAAAAAAAAAAAAAAAAAAAJGRpbmYAAAAcZHJlZgAAAAAAAAABAAAADHVybCAAAAABAAAADnBpdG0AAAAAAAEAAAA4aWluZgAAAAAAAgAAABVpbmZlAgAAAAABAABodmMxAAAAABVpbmZlAgAAAQACAABFeGlmAAAAABppcmVmAAAAAAAAAA5jZHNjAAIAAQABAAAA5WlwcnAAAADEaXBjbwAAABNjb2xybmNseAACAAIABoAAAAAMY2xsaQDLAEAAAAAUaXNwZQAAAAAAAABAAAAAQAAAAAlpcm90AAAAABBwaXhpAAAAAAMICAgAAABwaHZjQwEDcAAAALAAAAAAAB7wAPz9+PgAAAsDoAABABdAAQwB//8DcAAAAwCwAAADAAADAB5wJKEAAQAiQgEBA3AAAAMAsAAAAwAAAwAeoBQgQcGPiHuRZVNwICBgCKIAAQAJRAHAYXLIRFNkAAAAGWlwbWEAAAAAAAAAAQABBoECAwWGhAAAACxpbG9jAAAAAEQAAAIAAQAAAAEAAAJ4AAAG3wACAAAAAQAAAfYAAACCAAAAAW1kYXQAAAAAAAAHcQAAAAZFeGlmAABNTQAqAAAACAAEARoABQAAAAEAAAA+ARsABQAAAAEAAABGASgAAwAAAAEAAgAAh2kABAAAAAEAAABOAAAAAAAAAEgAAAABAAAASAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAQKADAAQAAAABAAAAQAAAAAAAAAbbKAGvoVoEGmEGzreDTcjLVZMZrYn0F53VkrS3Ir6TEzH4X1FmD91nfFjufVFWnahMOhU67kqrjf2DGfGNfbgrbLa8QRPm4amao3/7yVWAH1EmU2eK0OvP0VTgfm7KyH8O7C/mawVhRt/3R0oZ4Z5IB90zCYbP/JosBBE9p6inkubTFi0MWGFB22ChAzpXzXFwEmJIVLHnjXl6il9K2wRiBEuDSZh6azMAgFj3VjvEAsjibtAWB8TAH5LOa8F2AYoevx5z+dON8TVqRwN9B87hyXBZuMPiY7z8bs11ZCe3K9XKyjVC2JGlIkX5Bdph37LRKSh7r041jT2YvmC+qiflGF1H/NwsU0KOCeDjziCxI3+zPrtQzJ/56xOscalVdidLuEyT1AoXM40pnizSX84OqazVokrlt74FksUGPQ3WZKCF8TZWBC7xV+dsBIw/+XTMB4QlrQbFK7YD9aVo+RckHie4cQ6KK3Q93Qejou+liuLMCRWi7QHS3bKoBjI5Xdo7yW/M3L01wgTYCu7RkkypNN8+wMpxCvVR3LHmV9pzKo53H95S1Wq/B9x61jv466HZkDSvbM61bb2GFBLywj7Laowv0Gl5PDhpl1tC5vOGmGr184DfjHfqZHkF+/Oqwrp7xCHW4APgJJ3AIbgjwe0n/1dlBVGmSroC7681W/ZLgu/kjF/NiREqlCxt6W2hROjCQWbkyR+nKv1Zh9KWPjkeDO3C54cVVNrlx85afMCE1wFwPfochP2+HGlrv1y0qWV2giQUs////bYI/8VOmWcbtmcu/MdXQ+I8Dxb3QBzOer/NP//1uF//+G+2WbnpsbBdTpESA5PH4W76jZxkRprOxZdQi0CizqjSQZ0b+Z6/9iP4iynqsk5L6Qhlcwsm2T3CeLNKX05ADJ6tihMyeL/rVcvkKW8aF+NDl7/iLG5xtZ8llf4YRdNuNb7EXidon8f3mF+6Bqy658O9zeRwL+/oq6vMc2evtGmD1kNMNHgRKUWy0KdG5s06yH+tE7D8K++G/VMGSnbmVp4qHZ0FgjPtr+V8f/agMJnzq4oY/lBT7/0Xjuo02/87nbaABBUoOLsTzASrXE98c37y3w3f/0Uf//IfpOXk+Ggjx7CZluFPg5kO5/bo+OFrf1HEZO4dEFzRmY5FD1dcxe5fKJnRDcHLFYn+Xa6atQKZ5YgZvLr51nxWYbQ9Pcw++ujiUlgGHkwYace4feJ4IiC8uBqEUYXSptD3I+GzY0pKY+YBSxvPfmZZMoQ7uxXoTbFE3/LSj8/5+6x7oSPFIV7N/DW5Vh/wFG48KZf9KovX2T9+NmNUPoq3jX4VmbpVASD6IAc0a2R58Qr7hTT3ut4jniKbWJ/O+mr3eS3liSg/g5OF8HBIFLcqaLChK8JJYFpie18bpXuoBp3smItwwaBNWm+f8o0wpKOYmedYBGRGLMWpbiaSfXPRknjXNNaLYBog1mcKOyQQY0b7pnn/vw7UWHruoFsdx3B4ofYBY39psk0nlxYfAMqV1iVBMIudtccTGR32iezA1GtRpWMh0AM5m6fUjkbDkC+6xuD2VqCOfiBy58a/4NJVZ1IY5Vv3nsGUfmIslWKKwzfLdfRJ9FZsEhyoMXjSy8u0Huq0QozuVm0A+MYt5yWvBhqBYSy1gdfRyaJKkSeyh/A1YG1I0VZWexqpUV1itSk4FZvUGHp53mbV4ahj9wsqjQ1tqmFdVQYeeWofllD6ZhBc7mv1HH8Ysw0w2O6l3F1a10GjanOzxdSOu8JzrXvFZdEmcStST1Xu1e7gvXUJznRpqIsRG9Bonu8UXv2/M3kJVZougtPuzDnyBCu8ChkYsDnt7E2hXZvZb5PnwgfKY4qDtHT1hgKjEmXTLkE2caILsN5LJLX+iMB0vncJHao7ROWcfXn1vFwT0py1E70WeXfREP+5muOjWytWpaUBYMwwafS9/uP9sUYJka6tDJj0wkNLiYFX9DtcBlvrxEVzORDW7Ny4n5nQczPRVDNPlPKv///wt5shz7PXZfpWBZG0w9c4RGpx75PIkME2Ds1/w3EJ9t/02uhrPHXZJKlFaLlB8u0gCLABsqzIxtz0225xCG861lNro7pz1M5VS/SA0Of+IgOYAGdnT+b1/YZkpRtZpiBlhD36hQPZVx+g+Y0YmQJw9SW7IYPV17RktIpWlYRpz3gOc/V6ksU3AC4rD6K+WBpB8+HWvKNaN3/iMUQggOCZqZGQ7aWamdfbU3YXV9oyyA1CyQVoB+Sv1iU7UVeTZ7pYUutzFRYZvk9WotXdqkdR14rT11nM+M/LIwwj/kC5OM7sNi/Pb0Km+6g/iTa3ab6bP/cv4X/+"; + +function heicFile(): File { + const bytes = Uint8Array.from(atob(HEIC_64X64), (character) => character.charCodeAt(0)); + return new File([bytes], "fixture.heic", { type: "image/heic" }); +} + +describe("prepareMediaUploadFile", () => { + it("converts HEIC uploads to a browser-displayable JPEG", async () => { + const source = new File([new Uint8Array([1, 2, 3])], "photo.heic", { + type: "image/heic", + lastModified: 123, + }); + const convert = vi.fn( + async () => new Blob([new Uint8Array([4, 5, 6])], { type: "image/jpeg" }), + ); + + const prepared = await prepareMediaUploadFile(source, convert); + + expect(convert).toHaveBeenCalledWith(source); + expect(prepared.name).toBe("photo.jpg"); + expect(prepared.type).toBe("image/jpeg"); + expect(prepared.lastModified).toBe(source.lastModified); + expect(new Uint8Array(await prepared.arrayBuffer())).toEqual(new Uint8Array([4, 5, 6])); + }); + + it("produces a JPEG that the browser can display", async () => { + const prepared = await prepareMediaUploadFile(heicFile()); + const dimensions = await new Promise<{ width: number; height: number }>((resolve, reject) => { + const image = new Image(); + image.onload = () => { + URL.revokeObjectURL(image.src); + resolve({ width: image.naturalWidth, height: image.naturalHeight }); + }; + image.onerror = () => { + URL.revokeObjectURL(image.src); + reject(new Error("Converted JPEG could not be displayed")); + }; + image.src = URL.createObjectURL(prepared); + }); + + expect(prepared.type).toBe("image/jpeg"); + expect(dimensions).toEqual({ width: 64, height: 64 }); + }); + + it("uploads the converted JPEG through the media API", async () => { + const originalFetch = globalThis.fetch; + let uploadedFile: File | null = null; + globalThis.fetch = vi.fn(async (input, init) => { + if (String(input).endsWith("/media/upload-url")) { + return new Response(null, { status: 501 }); + } + + const formData = init?.body; + if (!(formData instanceof FormData)) throw new Error("Expected a media upload form"); + const file = formData.get("file"); + if (!(file instanceof File)) throw new Error("Expected an uploaded file"); + uploadedFile = file; + + return new Response( + JSON.stringify({ + data: { + item: { + id: "media-1", + filename: file.name, + mimeType: file.type, + url: "/_emdash/api/media/file/media-1.jpg", + size: file.size, + createdAt: "2026-07-21T00:00:00.000Z", + }, + }, + }), + { status: 201, headers: { "Content-Type": "application/json" } }, + ); + }); + + try { + const item = await uploadMedia(heicFile()); + expect(item.mimeType).toBe("image/jpeg"); + expect(item.filename).toBe("fixture.jpg"); + expect(uploadedFile).toMatchObject({ name: "fixture.jpg", type: "image/jpeg" }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("leaves other uploads unchanged", async () => { + const source = new File([new Uint8Array([1, 2, 3])], "photo.jpg", { + type: "image/jpeg", + }); + const convert = vi.fn(); + + const prepared = await prepareMediaUploadFile(source, convert); + + expect(prepared).toBe(source); + expect(convert).not.toHaveBeenCalled(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d556eb31dc..e47a3bcd38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1230,6 +1230,9 @@ importers: dompurify: specifier: ^3.3.2 version: 3.3.2 + heic-to: + specifier: ^1.5.2 + version: 1.5.2 marked: specifier: ^17.0.3 version: 17.0.3 @@ -9154,6 +9157,9 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + heic-to@1.5.2: + resolution: {integrity: sha512-8Fns+lZHAWmz5U5IUxDeXKwIf3foBoKNPLxxFY4B0MkLjNuomEIHCoDbDE+x/llFK3NCEO1cu4+n3iUKY+Svmw==} + highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} @@ -20145,6 +20151,8 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 + heic-to@1.5.2: {} + highlight.js@10.7.3: {} hono-openapi@1.3.0(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.1.38)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.1.38)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(typebox@1.1.38)(valibot@1.4.1(typescript@6.0.3))(zod@4.4.1))(@types/json-schema@7.0.15)(hono@4.12.27)(openapi-types@12.1.3): From d9fad54c234e15b1caff38176e8e324cc603cbd2 Mon Sep 17 00:00:00 2001 From: Mason James <297610+masonjames@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:24:01 -0400 Subject: [PATCH 2/3] fix(admin): cover HEIC provider uploads --- packages/admin/src/lib/api/media.ts | 2 + packages/admin/src/lib/media-upload.ts | 9 ++- packages/admin/tests/lib/media-upload.test.ts | 55 ++++++++++++++++++- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/admin/src/lib/api/media.ts b/packages/admin/src/lib/api/media.ts index 4175184f8b..841f20a11a 100644 --- a/packages/admin/src/lib/api/media.ts +++ b/packages/admin/src/lib/api/media.ts @@ -367,6 +367,8 @@ export async function uploadToProvider( file: File, alt?: string, ): Promise { + file = await prepareMediaUploadFile(file); + const formData = new FormData(); formData.append("file", file); if (alt) formData.append("alt", alt); diff --git a/packages/admin/src/lib/media-upload.ts b/packages/admin/src/lib/media-upload.ts index bfc9ade2a1..c45c154258 100644 --- a/packages/admin/src/lib/media-upload.ts +++ b/packages/admin/src/lib/media-upload.ts @@ -1,5 +1,10 @@ -const HEIC_MIME_TYPES = new Set(["image/heic", "image/heif"]); -const HEIC_FILENAME_PATTERN = /\.(?:heic|heif)$/i; +const HEIC_MIME_TYPES = new Set([ + "image/heic", + "image/heif", + "image/heic-sequence", + "image/heif-sequence", +]); +const HEIC_FILENAME_PATTERN = /\.(?:heic|heif|heics|heifs|hif)$/i; export type HeicConverter = (file: File) => Promise; diff --git a/packages/admin/tests/lib/media-upload.test.ts b/packages/admin/tests/lib/media-upload.test.ts index 1563243449..d160b3998b 100644 --- a/packages/admin/tests/lib/media-upload.test.ts +++ b/packages/admin/tests/lib/media-upload.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { uploadMedia } from "../../src/lib/api/media.js"; +import { uploadMedia, uploadToProvider } from "../../src/lib/api/media.js"; import { prepareMediaUploadFile } from "../../src/lib/media-upload.js"; const HEIC_64X64 = @@ -49,6 +49,24 @@ describe("prepareMediaUploadFile", () => { expect(dimensions).toEqual({ width: 64, height: 64 }); }); + it.each([ + ["heic-sequence", "image/heic-sequence", "heic-sequence.jpg"], + ["heif-sequence", "image/heif-sequence", "heif-sequence.jpg"], + ["photo.heics", "application/octet-stream", "photo.jpg"], + ["photo.heifs", "application/octet-stream", "photo.jpg"], + ["photo.hif", "application/octet-stream", "photo.jpg"], + ])("converts Apple HEIC variant %s", async (name, type, expectedName) => { + const source = new File([new Uint8Array([1, 2, 3])], name, { type }); + const convert = vi.fn( + async () => new Blob([new Uint8Array([4, 5, 6])], { type: "image/jpeg" }), + ); + + const prepared = await prepareMediaUploadFile(source, convert); + + expect(convert).toHaveBeenCalledWith(source); + expect(prepared).toMatchObject({ name: expectedName, type: "image/jpeg" }); + }); + it("uploads the converted JPEG through the media API", async () => { const originalFetch = globalThis.fetch; let uploadedFile: File | null = null; @@ -90,6 +108,41 @@ describe("prepareMediaUploadFile", () => { } }); + it("uploads the converted JPEG through a media provider", async () => { + const originalFetch = globalThis.fetch; + let uploadedFile: File | null = null; + globalThis.fetch = vi.fn(async (_input, init) => { + const formData = init?.body; + if (!(formData instanceof FormData)) throw new Error("Expected a provider upload form"); + const file = formData.get("file"); + if (!(file instanceof File)) throw new Error("Expected an uploaded file"); + uploadedFile = file; + + return new Response( + JSON.stringify({ + data: { + item: { + id: "provider-media-1", + filename: file.name, + mimeType: file.type, + url: "https://example.com/fixture.jpg", + }, + }, + }), + { status: 201, headers: { "Content-Type": "application/json" } }, + ); + }); + + try { + const item = await uploadToProvider("provider-1", heicFile()); + expect(item.mimeType).toBe("image/jpeg"); + expect(item.filename).toBe("fixture.jpg"); + expect(uploadedFile).toMatchObject({ name: "fixture.jpg", type: "image/jpeg" }); + } finally { + globalThis.fetch = originalFetch; + } + }); + it("leaves other uploads unchanged", async () => { const source = new File([new Uint8Array([1, 2, 3])], "photo.jpg", { type: "image/jpeg", From ca7907c0c94740d65dbb40a1d31899f587c136e5 Mon Sep 17 00:00:00 2001 From: Mason James <297610+masonjames@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:50:44 -0400 Subject: [PATCH 3/3] fix(core): delegate HEIC to image services --- .changeset/bright-otters-convert.md | 5 +- packages/admin/package.json | 1 - .../admin/src/components/MediaLibrary.tsx | 9 +- .../admin/src/components/MediaPickerModal.tsx | 9 +- packages/admin/src/lib/api/media.ts | 5 - packages/admin/src/lib/media-upload.ts | 36 ---- packages/admin/src/lib/media-utils.ts | 15 +- .../admin/tests/lib/media-thumbnail.test.ts | 12 ++ packages/admin/tests/lib/media-upload.test.ts | 157 ------------------ packages/cloudflare/src/image-endpoint.ts | 13 ++ .../core/src/api/handlers/media-upload.ts | 9 + packages/core/src/astro/image-endpoint.ts | 55 +++++- packages/core/src/astro/image-service.ts | 107 ++++++++++++ packages/core/src/astro/integration/index.ts | 18 +- .../core/src/astro/integration/runtime.ts | 11 +- packages/core/src/astro/middleware.ts | 4 +- packages/core/src/astro/routes/api/media.ts | 12 ++ .../astro/routes/api/media/[id]/confirm.ts | 21 +++ .../src/astro/routes/api/media/upload-url.ts | 13 ++ packages/core/src/mcp/server.ts | 6 + packages/core/src/media/image-endpoint.ts | 94 ++++++++++- .../astro/media-confirm-placeholder.test.ts | 31 ++++ .../unit/api/handlers/media-upload.test.ts | 26 +++ .../unit/astro/image-endpoint-route.test.ts | 100 +++++++++++ .../tests/unit/astro/image-service.test.ts | 108 ++++++++++++ .../astro/media-heic-upload-routes.test.ts | 77 +++++++++ .../tests/unit/media/image-endpoint.test.ts | 70 ++++++++ pnpm-lock.yaml | 8 - 28 files changed, 787 insertions(+), 245 deletions(-) delete mode 100644 packages/admin/src/lib/media-upload.ts delete mode 100644 packages/admin/tests/lib/media-upload.test.ts create mode 100644 packages/core/src/astro/image-service.ts create mode 100644 packages/core/tests/unit/astro/image-endpoint-route.test.ts create mode 100644 packages/core/tests/unit/astro/image-service.test.ts create mode 100644 packages/core/tests/unit/astro/media-heic-upload-routes.test.ts diff --git a/.changeset/bright-otters-convert.md b/.changeset/bright-otters-convert.md index 7ba3b8ec69..e35c0608c4 100644 --- a/.changeset/bright-otters-convert.md +++ b/.changeset/bright-otters-convert.md @@ -1,5 +1,6 @@ --- -"@emdash-cms/admin": patch +"@emdash-cms/cloudflare": patch +"emdash": patch --- -Fixes broken thumbnails and public images for new HEIC and HEIF uploads by converting them to JPEG before saving them to the media library. +Fixes HEIC media handling by using the configured image service for browser-ready renditions and rejecting uploads when that service cannot support HEIC input. diff --git a/packages/admin/package.json b/packages/admin/package.json index 5964e472ec..7c1bbeea1b 100644 --- a/packages/admin/package.json +++ b/packages/admin/package.json @@ -72,7 +72,6 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dompurify": "^3.3.2", - "heic-to": "^1.5.2", "marked": "^17.0.3", "react-hotkeys-hook": "^5.2.4", "tailwind-merge": "^3.3.0", diff --git a/packages/admin/src/components/MediaLibrary.tsx b/packages/admin/src/components/MediaLibrary.tsx index ee01231531..b508e99c5c 100644 --- a/packages/admin/src/components/MediaLibrary.tsx +++ b/packages/admin/src/components/MediaLibrary.tsx @@ -720,7 +720,12 @@ function MediaGridItem({ item, selected, onClick }: MediaGridItemProps) {
{isImage ? ( {item.alt fallbackToOriginalThumbnail(e.currentTarget, item.url)} @@ -813,7 +818,7 @@ function MediaListItem({ item, selected, onClick }: MediaListItemProps) {
{isImage ? ( {item.alt 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/api/media.ts b/packages/admin/src/lib/api/media.ts index 841f20a11a..9e0ccbdf10 100644 --- a/packages/admin/src/lib/api/media.ts +++ b/packages/admin/src/lib/api/media.ts @@ -5,7 +5,6 @@ import { i18n } from "@lingui/core"; import { msg } from "@lingui/core/macro"; -import { prepareMediaUploadFile } from "../media-upload.js"; import { API_BASE, apiFetch, @@ -228,8 +227,6 @@ async function uploadMediaDirect(file: File, opts?: { fieldId?: string }): Promi * (for local storage) if signed URLs are not supported. */ export async function uploadMedia(file: File, opts?: { fieldId?: string }): Promise { - file = await prepareMediaUploadFile(file); - // Try to get a signed upload URL const uploadInfo = await getUploadUrl(file, opts); @@ -367,8 +364,6 @@ export async function uploadToProvider( file: File, alt?: string, ): Promise { - file = await prepareMediaUploadFile(file); - const formData = new FormData(); formData.append("file", file); if (alt) formData.append("alt", alt); diff --git a/packages/admin/src/lib/media-upload.ts b/packages/admin/src/lib/media-upload.ts deleted file mode 100644 index c45c154258..0000000000 --- a/packages/admin/src/lib/media-upload.ts +++ /dev/null @@ -1,36 +0,0 @@ -const HEIC_MIME_TYPES = new Set([ - "image/heic", - "image/heif", - "image/heic-sequence", - "image/heif-sequence", -]); -const HEIC_FILENAME_PATTERN = /\.(?:heic|heif|heics|heifs|hif)$/i; - -export type HeicConverter = (file: File) => Promise; - -async function convertHeicToJpeg(file: File): Promise { - const { heicTo } = await import("heic-to/csp"); - return heicTo({ blob: file, type: "image/jpeg", quality: 0.9 }); -} - -function isHeicFile(file: File): boolean { - return HEIC_MIME_TYPES.has(file.type.toLowerCase()) || HEIC_FILENAME_PATTERN.test(file.name); -} - -function jpegFilename(filename: string): string { - const stem = filename.replace(HEIC_FILENAME_PATTERN, ""); - return `${stem || "image"}.jpg`; -} - -export async function prepareMediaUploadFile( - file: File, - convert: HeicConverter = convertHeicToJpeg, -): Promise { - if (!isHeicFile(file)) return file; - - const jpeg = await convert(file); - return new File([jpeg], jpegFilename(file.name), { - type: "image/jpeg", - lastModified: file.lastModified, - }); -} 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/admin/tests/lib/media-upload.test.ts b/packages/admin/tests/lib/media-upload.test.ts deleted file mode 100644 index d160b3998b..0000000000 --- a/packages/admin/tests/lib/media-upload.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { uploadMedia, uploadToProvider } from "../../src/lib/api/media.js"; -import { prepareMediaUploadFile } from "../../src/lib/media-upload.js"; - -const HEIC_64X64 = - "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"; - -function heicFile(): File { - const bytes = Uint8Array.from(atob(HEIC_64X64), (character) => character.charCodeAt(0)); - return new File([bytes], "fixture.heic", { type: "image/heic" }); -} - -describe("prepareMediaUploadFile", () => { - it("converts HEIC uploads to a browser-displayable JPEG", async () => { - const source = new File([new Uint8Array([1, 2, 3])], "photo.heic", { - type: "image/heic", - lastModified: 123, - }); - const convert = vi.fn( - async () => new Blob([new Uint8Array([4, 5, 6])], { type: "image/jpeg" }), - ); - - const prepared = await prepareMediaUploadFile(source, convert); - - expect(convert).toHaveBeenCalledWith(source); - expect(prepared.name).toBe("photo.jpg"); - expect(prepared.type).toBe("image/jpeg"); - expect(prepared.lastModified).toBe(source.lastModified); - expect(new Uint8Array(await prepared.arrayBuffer())).toEqual(new Uint8Array([4, 5, 6])); - }); - - it("produces a JPEG that the browser can display", async () => { - const prepared = await prepareMediaUploadFile(heicFile()); - const dimensions = await new Promise<{ width: number; height: number }>((resolve, reject) => { - const image = new Image(); - image.onload = () => { - URL.revokeObjectURL(image.src); - resolve({ width: image.naturalWidth, height: image.naturalHeight }); - }; - image.onerror = () => { - URL.revokeObjectURL(image.src); - reject(new Error("Converted JPEG could not be displayed")); - }; - image.src = URL.createObjectURL(prepared); - }); - - expect(prepared.type).toBe("image/jpeg"); - expect(dimensions).toEqual({ width: 64, height: 64 }); - }); - - it.each([ - ["heic-sequence", "image/heic-sequence", "heic-sequence.jpg"], - ["heif-sequence", "image/heif-sequence", "heif-sequence.jpg"], - ["photo.heics", "application/octet-stream", "photo.jpg"], - ["photo.heifs", "application/octet-stream", "photo.jpg"], - ["photo.hif", "application/octet-stream", "photo.jpg"], - ])("converts Apple HEIC variant %s", async (name, type, expectedName) => { - const source = new File([new Uint8Array([1, 2, 3])], name, { type }); - const convert = vi.fn( - async () => new Blob([new Uint8Array([4, 5, 6])], { type: "image/jpeg" }), - ); - - const prepared = await prepareMediaUploadFile(source, convert); - - expect(convert).toHaveBeenCalledWith(source); - expect(prepared).toMatchObject({ name: expectedName, type: "image/jpeg" }); - }); - - it("uploads the converted JPEG through the media API", async () => { - const originalFetch = globalThis.fetch; - let uploadedFile: File | null = null; - globalThis.fetch = vi.fn(async (input, init) => { - if (String(input).endsWith("/media/upload-url")) { - return new Response(null, { status: 501 }); - } - - const formData = init?.body; - if (!(formData instanceof FormData)) throw new Error("Expected a media upload form"); - const file = formData.get("file"); - if (!(file instanceof File)) throw new Error("Expected an uploaded file"); - uploadedFile = file; - - return new Response( - JSON.stringify({ - data: { - item: { - id: "media-1", - filename: file.name, - mimeType: file.type, - url: "/_emdash/api/media/file/media-1.jpg", - size: file.size, - createdAt: "2026-07-21T00:00:00.000Z", - }, - }, - }), - { status: 201, headers: { "Content-Type": "application/json" } }, - ); - }); - - try { - const item = await uploadMedia(heicFile()); - expect(item.mimeType).toBe("image/jpeg"); - expect(item.filename).toBe("fixture.jpg"); - expect(uploadedFile).toMatchObject({ name: "fixture.jpg", type: "image/jpeg" }); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("uploads the converted JPEG through a media provider", async () => { - const originalFetch = globalThis.fetch; - let uploadedFile: File | null = null; - globalThis.fetch = vi.fn(async (_input, init) => { - const formData = init?.body; - if (!(formData instanceof FormData)) throw new Error("Expected a provider upload form"); - const file = formData.get("file"); - if (!(file instanceof File)) throw new Error("Expected an uploaded file"); - uploadedFile = file; - - return new Response( - JSON.stringify({ - data: { - item: { - id: "provider-media-1", - filename: file.name, - mimeType: file.type, - url: "https://example.com/fixture.jpg", - }, - }, - }), - { status: 201, headers: { "Content-Type": "application/json" } }, - ); - }); - - try { - const item = await uploadToProvider("provider-1", heicFile()); - expect(item.mimeType).toBe("image/jpeg"); - expect(item.filename).toBe("fixture.jpg"); - expect(uploadedFile).toMatchObject({ name: "fixture.jpg", type: "image/jpeg" }); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("leaves other uploads unchanged", async () => { - const source = new File([new Uint8Array([1, 2, 3])], "photo.jpg", { - type: "image/jpeg", - }); - const convert = vi.fn(); - - const prepared = await prepareMediaUploadFile(source, convert); - - expect(prepared).toBe(source); - expect(convert).not.toHaveBeenCalled(); - }); -}); 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"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e47a3bcd38..d556eb31dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1230,9 +1230,6 @@ importers: dompurify: specifier: ^3.3.2 version: 3.3.2 - heic-to: - specifier: ^1.5.2 - version: 1.5.2 marked: specifier: ^17.0.3 version: 17.0.3 @@ -9157,9 +9154,6 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} - heic-to@1.5.2: - resolution: {integrity: sha512-8Fns+lZHAWmz5U5IUxDeXKwIf3foBoKNPLxxFY4B0MkLjNuomEIHCoDbDE+x/llFK3NCEO1cu4+n3iUKY+Svmw==} - highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} @@ -20151,8 +20145,6 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 - heic-to@1.5.2: {} - highlight.js@10.7.3: {} hono-openapi@1.3.0(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.1.38)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.1.38)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(typebox@1.1.38)(valibot@1.4.1(typescript@6.0.3))(zod@4.4.1))(@types/json-schema@7.0.15)(hono@4.12.27)(openapi-types@12.1.3):