From 70ae1e491e47a36def1ef0e2e9c832fa7e03197b Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:00:39 +0300 Subject: [PATCH 1/2] fix(media): generate and render LQIP placeholders for production media Image dimensions and LQIP placeholders (blurhash, dominant color) were only generated for direct local-storage uploads. Signed-URL uploads, plugin ctx.media.upload(), and WordPress import produced records without them, so production (R2/S3) media had no placeholders. Route every server-side media-creation path through a shared enrichImageMetadata helper, cache the placeholders on the stored media value of content fields and on rich-text images, and render them in the component and portable-text image blocks. --- .changeset/media-metadata-enrichment.md | 7 + .../src/components/ImageFieldRenderer.tsx | 9 ++ .../src/components/PortableTextEditor.tsx | 36 ++++- .../components/editor/ImageDetailPanel.tsx | 6 + .../admin/src/components/editor/ImageNode.tsx | 14 ++ packages/admin/src/lib/api/media.ts | 8 ++ packages/admin/src/lib/media-utils.ts | 12 ++ .../admin/tests/editor/image-lqip.test.ts | 81 +++++++++++ .../routes/api/import/wordpress/media.ts | 12 +- packages/core/src/astro/routes/api/media.ts | 36 +++-- .../astro/routes/api/media/[id]/confirm.ts | 64 ++++++++- .../core/src/components/EmDashImage.astro | 11 +- packages/core/src/components/Image.astro | 18 ++- .../components/InlinePortableTextEditor.tsx | 49 ++++++- .../core/src/database/repositories/media.ts | 10 +- packages/core/src/media/enrich.ts | 66 +++++++++ packages/core/src/media/index.ts | 1 + packages/core/src/media/local-runtime.ts | 19 +++ packages/core/src/media/normalize.ts | 21 ++- packages/core/src/media/placeholder.ts | 38 +++-- packages/core/src/media/types.ts | 14 ++ packages/core/src/plugins/context.ts | 8 ++ .../astro/media-confirm-placeholder.test.ts | 132 ++++++++++++++++++ .../astro/media-upload-placeholder.test.ts | 113 +++++++++++++++ .../runtime/plugin-media-enrich.test.ts | 85 +++++++++++ .../wordpress-import/media-enrich.test.ts | 61 ++++++++ .../inline-portable-text-image.test.ts | 119 ++++++++++++++++ .../repositories/media-confirm.test.ts | 41 ++++++ packages/core/tests/unit/media/enrich.test.ts | 67 +++++++++ .../core/tests/unit/media/media-value.test.ts | 78 +++++++++++ .../core/tests/unit/media/normalize.test.ts | 125 ++++++++++++++++- .../core/tests/unit/media/placeholder.test.ts | 29 ++++ packages/core/tests/utils/image-fixtures.ts | 20 +++ 33 files changed, 1360 insertions(+), 50 deletions(-) create mode 100644 .changeset/media-metadata-enrichment.md create mode 100644 packages/admin/tests/editor/image-lqip.test.ts create mode 100644 packages/core/src/media/enrich.ts create mode 100644 packages/core/tests/integration/astro/media-confirm-placeholder.test.ts create mode 100644 packages/core/tests/integration/astro/media-upload-placeholder.test.ts create mode 100644 packages/core/tests/integration/runtime/plugin-media-enrich.test.ts create mode 100644 packages/core/tests/integration/wordpress-import/media-enrich.test.ts create mode 100644 packages/core/tests/unit/components/inline-portable-text-image.test.ts create mode 100644 packages/core/tests/unit/database/repositories/media-confirm.test.ts create mode 100644 packages/core/tests/unit/media/enrich.test.ts create mode 100644 packages/core/tests/unit/media/media-value.test.ts create mode 100644 packages/core/tests/utils/image-fixtures.ts diff --git a/.changeset/media-metadata-enrichment.md b/.changeset/media-metadata-enrichment.md new file mode 100644 index 0000000000..e770d686b3 --- /dev/null +++ b/.changeset/media-metadata-enrichment.md @@ -0,0 +1,7 @@ +--- +"emdash": patch +--- + +Fixes missing image dimensions and LQIP placeholders (blurhash, dominant color) on media created through signed-URL uploads, plugin `ctx.media.upload()`, and WordPress import. These were only generated for direct (local-storage) uploads, so production (R2/S3) media had no placeholders. + +LQIP placeholders are now cached on the stored media value of content fields (alongside `width`/`height`) and on images inserted into rich text, so the `` component and portable-text image blocks render a blur/color placeholder before the image loads without a runtime lookup. diff --git a/packages/admin/src/components/ImageFieldRenderer.tsx b/packages/admin/src/components/ImageFieldRenderer.tsx index 463634a6d8..892323b13a 100644 --- a/packages/admin/src/components/ImageFieldRenderer.tsx +++ b/packages/admin/src/components/ImageFieldRenderer.tsx @@ -14,6 +14,7 @@ import { Image as ImageIcon, ImageBroken, X } from "@phosphor-icons/react"; import * as React from "react"; import type { MediaItem } from "../lib/api"; +import { metaString } from "../lib/media-utils"; import { MediaPickerModal } from "./MediaPickerModal"; /** @@ -30,6 +31,10 @@ export interface ImageFieldValue { alt?: string; width?: number; height?: number; + /** LQIP blurhash placeholder (images only) */ + blurhash?: string; + /** LQIP dominant-color placeholder, as a CSS color (images only) */ + dominantColor?: string; /** Provider-specific metadata */ meta?: Record; } @@ -85,6 +90,10 @@ export function ImageFieldRenderer({ alt: item.alt || "", width: item.width, height: item.height, + // Cache LQIP alongside dimensions so embeds render a placeholder without a + // runtime lookup. Fall back to `meta` for providers that stash it there. + blurhash: item.blurhash ?? metaString(item.meta, "blurhash"), + dominantColor: item.dominantColor ?? metaString(item.meta, "dominantColor"), meta: isLocalProvider ? { ...item.meta, storageKey: item.storageKey } : item.meta, }); }; diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index a0caa14ce9..9b7e680ad8 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -136,11 +136,15 @@ interface PortableTextTextBlock { interface PortableTextImageBlock { _type: "image"; _key: string; - asset: { _ref: string; url?: string }; + asset: { _ref: string; url?: string; meta?: Record }; alt?: string; caption?: string; width?: number; height?: number; + /** LQIP blurhash — first-class field (legacy snapshots store it in `asset.meta`). */ + blurhash?: string; + /** LQIP dominant color — first-class field (legacy snapshots store it in `asset.meta`). */ + dominantColor?: string; displayWidth?: number; displayHeight?: number; alignment?: "left" | "center" | "right" | "wide" | "full"; @@ -306,6 +310,13 @@ function convertPMNode(node: { case "image": { const attrs = node.attrs ?? {}; const provider = attrStr(attrs.provider); + const blurhash = attrStr(attrs.blurhash); + const dominantColor = attrStr(attrs.dominantColor); + // Persist LQIP as first-class block fields, matching the image-field + // path (MediaValue.blurhash/dominantColor) so read sites and normalize + // don't need a `asset.meta` dual-shape. `asset.meta` is left to carry + // only provider-specific data (we don't reconstruct it here, so any + // non-LQIP meta keys are never silently dropped on editor round-trip). return { _type: "image", _key: generateKey(), @@ -318,6 +329,8 @@ function convertPMNode(node: { caption: attrStr(attrs.caption) ?? attrStr(attrs.title), width: attrNum(attrs.width), height: attrNum(attrs.height), + ...(blurhash ? { blurhash } : {}), + ...(dominantColor ? { dominantColor } : {}), displayWidth: attrNum(attrs.displayWidth), displayHeight: attrNum(attrs.displayHeight), alignment: attrStr(attrs.alignment) as PortableTextImageBlock["alignment"], @@ -652,6 +665,21 @@ function convertPTBlock(block: PortableTextBlock): unknown { case "image": { if (!isImageBlock(block)) return null; const imageBlock = block; + const meta = imageBlock.asset.meta; + // Prefer first-class LQIP fields; fall back to `asset.meta` for legacy + // snapshots persisted before LQIP was promoted out of the provider meta bag. + const blurhash = + typeof imageBlock.blurhash === "string" + ? imageBlock.blurhash + : typeof meta?.blurhash === "string" + ? meta.blurhash + : null; + const dominantColor = + typeof imageBlock.dominantColor === "string" + ? imageBlock.dominantColor + : typeof meta?.dominantColor === "string" + ? meta.dominantColor + : null; return { type: "image", attrs: { @@ -662,6 +690,8 @@ function convertPTBlock(block: PortableTextBlock): unknown { mediaId: imageBlock.asset._ref, width: imageBlock.width, height: imageBlock.height, + blurhash, + dominantColor, displayWidth: imageBlock.displayWidth, displayHeight: imageBlock.displayHeight, alignment: imageBlock.alignment, @@ -2379,6 +2409,8 @@ export function PortableTextEditor({ provider: item.provider || "local", width: item.width, height: item.height, + blurhash: item.blurhash, + dominantColor: item.dominantColor, }) .run(); } @@ -2882,6 +2914,8 @@ function EditorToolbar({ mediaId: item.id, width: item.width, height: item.height, + blurhash: item.blurhash, + dominantColor: item.dominantColor, }) .run(); }, diff --git a/packages/admin/src/components/editor/ImageDetailPanel.tsx b/packages/admin/src/components/editor/ImageDetailPanel.tsx index 4d204eec4f..621bc635b3 100644 --- a/packages/admin/src/components/editor/ImageDetailPanel.tsx +++ b/packages/admin/src/components/editor/ImageDetailPanel.tsx @@ -33,6 +33,10 @@ export interface ImageAttributes { width?: number; /** Original image height */ height?: number; + /** LQIP blurhash placeholder */ + blurhash?: string; + /** LQIP dominant-color placeholder */ + dominantColor?: string; /** Display width for this instance (defaults to original) */ displayWidth?: number; /** Display height for this instance (defaults to original) */ @@ -115,6 +119,8 @@ export function ImageDetailPanel({ mediaId: item.id, width: item.width, height: item.height, + blurhash: item.blurhash, + dominantColor: item.dominantColor, // Clear caption/title since it's a new image caption: undefined, title: undefined, diff --git a/packages/admin/src/components/editor/ImageNode.tsx b/packages/admin/src/components/editor/ImageNode.tsx index 3b2cf76e2b..a860db880b 100644 --- a/packages/admin/src/components/editor/ImageNode.tsx +++ b/packages/admin/src/components/editor/ImageNode.tsx @@ -34,6 +34,10 @@ declare module "@tiptap/react" { provider?: string; width?: number; height?: number; + /** LQIP blurhash placeholder */ + blurhash?: string; + /** LQIP dominant-color placeholder */ + dominantColor?: string; displayWidth?: number; displayHeight?: number; alignment?: "left" | "center" | "right" | "wide" | "full"; @@ -79,6 +83,8 @@ function ImageNodeView({ node, updateAttributes, selected, deleteNode, editor }: mediaId: node.attrs.mediaId, width: node.attrs.width, height: node.attrs.height, + blurhash: node.attrs.blurhash, + dominantColor: node.attrs.dominantColor, displayWidth: node.attrs.displayWidth, displayHeight: node.attrs.displayHeight, alignment: node.attrs.alignment, @@ -342,6 +348,12 @@ export const ImageExtension = Node.create({ height: { default: null, }, + blurhash: { + default: null, + }, + dominantColor: { + default: null, + }, displayWidth: { default: null, }, @@ -382,6 +394,8 @@ export const ImageExtension = Node.create({ provider?: string; width?: number; height?: number; + blurhash?: string; + dominantColor?: string; displayWidth?: number; displayHeight?: number; alignment?: "left" | "center" | "right" | "wide" | "full"; diff --git a/packages/admin/src/lib/api/media.ts b/packages/admin/src/lib/api/media.ts index 29c699fbcd..9e0ccbdf10 100644 --- a/packages/admin/src/lib/api/media.ts +++ b/packages/admin/src/lib/api/media.ts @@ -34,6 +34,10 @@ export interface MediaItem { size: number; width?: number; height?: number; + /** LQIP blurhash placeholder (images only) */ + blurhash?: string; + /** LQIP dominant-color placeholder, as a CSS color (images only) */ + dominantColor?: string; alt?: string; caption?: string; createdAt: string; @@ -302,6 +306,10 @@ export interface MediaProviderItem { size?: number; width?: number; height?: number; + /** LQIP blurhash placeholder (images only) */ + blurhash?: string; + /** LQIP dominant-color placeholder, as a CSS color (images only) */ + dominantColor?: string; alt?: string; previewUrl?: string; meta?: Record; diff --git a/packages/admin/src/lib/media-utils.ts b/packages/admin/src/lib/media-utils.ts index cf3d3e933d..8bed87e48b 100644 --- a/packages/admin/src/lib/media-utils.ts +++ b/packages/admin/src/lib/media-utils.ts @@ -1,5 +1,14 @@ import type { MediaItem, MediaProviderItem } from "./api/media.js"; +/** Read a string value from an untyped `meta` bag, or undefined. */ +export function metaString( + meta: Record | undefined, + key: string, +): string | undefined { + const value = meta?.[key]; + return typeof value === "string" ? value : undefined; +} + export function providerItemToMediaItem( providerId: string, item: MediaProviderItem, @@ -12,6 +21,9 @@ export function providerItemToMediaItem( size: item.size || 0, width: item.width, height: item.height, + // Prefer first-class fields; some providers stash LQIP in `meta`. + blurhash: item.blurhash ?? metaString(item.meta, "blurhash"), + dominantColor: item.dominantColor ?? metaString(item.meta, "dominantColor"), alt: item.alt, createdAt: new Date().toISOString(), provider: providerId, diff --git a/packages/admin/tests/editor/image-lqip.test.ts b/packages/admin/tests/editor/image-lqip.test.ts new file mode 100644 index 0000000000..da7247188f --- /dev/null +++ b/packages/admin/tests/editor/image-lqip.test.ts @@ -0,0 +1,81 @@ +/** + * Admin editor image LQIP round-trip. + * + * blurhash/dominantColor must survive Portable Text ↔ ProseMirror conversion so + * author-inserted images keep their placeholders. LQIP is persisted as + * first-class block fields (matching the image-field path); `asset.meta` is + * only a read fallback for legacy snapshots. + */ + +import { describe, it, expect } from "vitest"; + +import { + _portableTextToProsemirror as portableTextToProsemirror, + _prosemirrorToPortableText as prosemirrorToPortableText, +} from "../../src/components/PortableTextEditor"; + +type ImagePMNode = { type: string; attrs?: Record }; +type ImagePTBlock = { + _type: string; + asset?: { meta?: { blurhash?: string; dominantColor?: string } }; + blurhash?: string; + dominantColor?: string; +}; + +describe("admin editor image LQIP round-trip", () => { + it("preserves blurhash and dominantColor through PT → PM → PT (promotes legacy asset.meta)", () => { + const block = { + _type: "image" as const, + _key: "img1", + asset: { + _ref: "01ABC", + url: "/_emdash/api/media/file/01ABC.jpg", + meta: { blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", dominantColor: "#aabbcc" }, + }, + alt: "A photo", + width: 1200, + height: 800, + }; + + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- test fixture + const pm = portableTextToProsemirror([block as never]); + const node = pm.content?.[0] as ImagePMNode; + expect(node.type).toBe("image"); + expect(node.attrs?.blurhash).toBe("LEHV6nWB2yk8pyo0adR*.7kCMdnj"); + expect(node.attrs?.dominantColor).toBe("#aabbcc"); + + const pt = prosemirrorToPortableText({ type: "doc", content: pm.content }); + const restored = pt[0] as ImagePTBlock; + expect(restored._type).toBe("image"); + // Promoted to first-class fields; asset.meta no longer carries them. + expect(restored.blurhash).toBe("LEHV6nWB2yk8pyo0adR*.7kCMdnj"); + expect(restored.dominantColor).toBe("#aabbcc"); + expect(restored.asset?.meta?.blurhash).toBeUndefined(); + expect(restored.asset?.meta?.dominantColor).toBeUndefined(); + }); + + it("preserves first-class LQIP through PT → PM → PT", () => { + const block = { + _type: "image" as const, + _key: "img1b", + asset: { _ref: "01AB2", url: "/_emdash/api/media/file/01AB2.jpg" }, + alt: "A photo", + width: 1200, + height: 800, + blurhash: "L6PZfSi_.AyE_3t7t7R**0o#DgR4", + dominantColor: "#112233", + }; + + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- test fixture + const pm = portableTextToProsemirror([block as never]); + const node = pm.content?.[0] as ImagePMNode; + expect(node.attrs?.blurhash).toBe("L6PZfSi_.AyE_3t7t7R**0o#DgR4"); + expect(node.attrs?.dominantColor).toBe("#112233"); + + const pt = prosemirrorToPortableText({ type: "doc", content: pm.content }); + const restored = pt[0] as ImagePTBlock; + expect(restored.blurhash).toBe("L6PZfSi_.AyE_3t7t7R**0o#DgR4"); + expect(restored.dominantColor).toBe("#112233"); + expect(restored.asset?.meta?.blurhash).toBeUndefined(); + }); +}); diff --git a/packages/core/src/astro/routes/api/import/wordpress/media.ts b/packages/core/src/astro/routes/api/import/wordpress/media.ts index fef14a49c8..8116a1895f 100644 --- a/packages/core/src/astro/routes/api/import/wordpress/media.ts +++ b/packages/core/src/astro/routes/api/import/wordpress/media.ts @@ -20,6 +20,7 @@ import { apiError, apiSuccess, handleError } from "#api/error.js"; import { isParseError, parseBody } from "#api/parse.js"; import { wpMediaImportBody } from "#api/schemas.js"; import { validateExternalUrl, ssrfSafeFetch, SsrfError } from "#import/ssrf.js"; +import { enrichImageMetadata } from "#media/enrich.js"; import type { EmDashHandlers } from "#types"; import type { AttachmentInfo } from "./analyze.js"; @@ -125,7 +126,7 @@ export const POST: APIRoute = async ({ request, locals }) => { } }; -async function importMediaWithProgress( +export async function importMediaWithProgress( attachments: AttachmentInfo[], db: NonNullable, storage: NonNullable, @@ -273,6 +274,9 @@ async function importMediaWithProgress( contentType, }); + // Derive dimensions + LQIP placeholders (no-op for non-images). + const enriched = await enrichImageMetadata(new Uint8Array(buffer), contentType); + // Create media record with content hash const mediaItem = await repo.create({ filename: attachment.filename || `media-${attachment.id}${ext}`, @@ -280,8 +284,10 @@ async function importMediaWithProgress( size, storageKey, contentHash, - width: undefined, - height: undefined, + width: enriched.width, + height: enriched.height, + blurhash: enriched.blurhash, + dominantColor: enriched.dominantColor, }); // Build the new URL diff --git a/packages/core/src/astro/routes/api/media.ts b/packages/core/src/astro/routes/api/media.ts index 9fa7644432..b1bb4e7b92 100644 --- a/packages/core/src/astro/routes/api/media.ts +++ b/packages/core/src/astro/routes/api/media.ts @@ -16,8 +16,8 @@ import { GLOBAL_UPLOAD_ALLOWLIST, resolveFieldAllowlist } from "#api/handlers/me 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 { matchesMimeAllowlist, normalizeMime } from "#media/mime.js"; -import { generatePlaceholder } from "#media/placeholder.js"; import { computeContentHash } from "#utils/hash.js"; import type { MediaItem } from "../../types.js"; @@ -163,34 +163,32 @@ export const POST: APIRoute = async ({ request, locals }) => { const width = widthStr ? parseInt(widthStr, 10) : undefined; const height = heightStr ? parseInt(heightStr, 10) : undefined; - // Generate placeholder data for images. - // If the client sent a thumbnail (small pre-resized image), use that - // instead of the full buffer to avoid OOM on memory-constrained runtimes. + // Derive dimensions + LQIP placeholders via the shared helper. + // If the client sent a downscaled thumbnail, decode that for the blurhash + // (avoids OOM on large originals on memory-constrained runtimes). const thumbnailEntry = formData.get("thumbnail"); const thumbnail = thumbnailEntry instanceof File ? thumbnailEntry : null; - - let placeholder: Awaited> = null; - if (file.type.startsWith("image/")) { - if (thumbnail) { - const thumbBuffer = new Uint8Array(await thumbnail.arrayBuffer()); - placeholder = await generatePlaceholder(thumbBuffer, thumbnail.type); - } else { - const clientDims = width && height ? { width, height } : undefined; - placeholder = await generatePlaceholder(buffer, file.type, clientDims); - } - } + const enriched = await enrichImageMetadata(buffer, file.type, { + knownDimensions: width != null && height != null ? { width, height } : undefined, + placeholder: thumbnail + ? { bytes: new Uint8Array(await thumbnail.arrayBuffer()), contentType: thumbnail.type } + : undefined, + }); // Create media record const result = await emdash.handleMediaCreate({ filename: file.name, mimeType: normalizeMime(file.type), size: file.size, - width, - height, + // Client dimensions win over server header dimensions: the browser's + // naturalWidth/Height apply EXIF orientation, while image-size reports + // raw (pre-orientation) header dims — swapped for 90°/270° JPEGs. + width: width ?? enriched.width, + height: height ?? enriched.height, storageKey, contentHash, - blurhash: placeholder?.blurhash, - dominantColor: placeholder?.dominantColor, + blurhash: enriched.blurhash, + dominantColor: enriched.dominantColor, authorId: user?.id, }); 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 bc081c50c1..944dabf10f 100644 --- a/packages/core/src/astro/routes/api/media/[id]/confirm.ts +++ b/packages/core/src/astro/routes/api/media/[id]/confirm.ts @@ -14,10 +14,20 @@ import { requireOwnerPerm, requirePerm } from "#api/authorize.js"; 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 type { MediaItem } from "#types"; export const prerender = false; +/** + * Max raw bytes to buffer for server-side LQIP generation at confirm time. The + * signed-URL upload flow exists so large files bypass server buffering — re-reading + * the whole object into a Worker's 128 MB heap to compute a blurhash would OOM + * on the very uploads that flow was designed for. LQIP is progressive + * enhancement: large images simply ship without a server-generated placeholder. + */ +const MAX_PLACEHOLDER_DOWNLOAD_BYTES = 8 * 1024 * 1024; + /** * Add URL to media item (relative URL for portability) */ @@ -81,11 +91,61 @@ export const POST: APIRoute = async ({ params, request, locals }) => { } } + // For images, read the just-uploaded bytes back from storage once to + // generate LQIP placeholders (and server-side dimensions as a fallback). + // The signed-URL flow uploads directly to storage, so this confirm is the + // only point at which the server sees the bytes. Best-effort: a decode + // failure must not block the upload from being marked ready. We also cap + // the download size — buffering a large original into a Worker heap to + // compute a 32px blurhash would OOM on the uploads the signed-URL path + // exists to support, so oversized files skip the server-side placeholder. + let blurhash: string | undefined; + let dominantColor: string | undefined; + let width = body.width; + let height = body.height; + if (emdash.storage && existing.mimeType.startsWith("image/")) { + const knownSize = body.size ?? existing.size ?? undefined; + const tooLarge = knownSize != null && knownSize > MAX_PLACEHOLDER_DOWNLOAD_BYTES; + if (!tooLarge) { + try { + const { body: stream } = await emdash.storage.download(existing.storageKey); + const bytes = new Uint8Array(await new Response(stream).arrayBuffer()); + // Defense-in-depth for the unknown-size case: even though we + // already buffered it, refuse the decode so we don't also pay + // the (larger) RGBA allocation. + if (bytes.byteLength > MAX_PLACEHOLDER_DOWNLOAD_BYTES) { + console.warn( + `[media] confirm skipping placeholder: object ${existing.storageKey} is ${bytes.byteLength} bytes (> ${MAX_PLACEHOLDER_DOWNLOAD_BYTES})`, + ); + } else { + const enriched = await enrichImageMetadata(bytes, existing.mimeType, { + knownDimensions: + body.width != null && body.height != null + ? { width: body.width, height: body.height } + : undefined, + }); + blurhash = enriched.blurhash; + dominantColor = enriched.dominantColor; + width = width ?? enriched.width; + height = height ?? enriched.height; + } + } catch (error) { + console.error("[media] confirm placeholder generation failed:", error); + } + } else { + console.warn( + `[media] confirm skipping placeholder: object ${existing.storageKey} reported size ${knownSize} bytes (> ${MAX_PLACEHOLDER_DOWNLOAD_BYTES})`, + ); + } + } + // Confirm the upload const item = await repo.confirmUpload(id, { size: body.size, - width: body.width, - height: body.height, + width, + height, + blurhash, + dominantColor, }); if (!item) { diff --git a/packages/core/src/components/EmDashImage.astro b/packages/core/src/components/EmDashImage.astro index 5954f27302..78e32b4abc 100644 --- a/packages/core/src/components/EmDashImage.astro +++ b/packages/core/src/components/EmDashImage.astro @@ -158,15 +158,12 @@ if (img) { } } -// Build placeholder background style +// Build placeholder background style. Prefer the first-class MediaValue fields; +// fall back to `meta` for snapshots stored before LQIP was promoted off `meta`. const blurhash = - typeof image === "object" - ? (image?.meta?.blurhash as string | undefined) - : undefined; + img?.blurhash ?? (img?.meta?.blurhash as string | undefined); const dominantColor = - typeof image === "object" - ? (image?.meta?.dominantColor as string | undefined) - : undefined; + img?.dominantColor ?? (img?.meta?.dominantColor as string | undefined); let placeholderStyle = ""; if (blurhash) { diff --git a/packages/core/src/components/Image.astro b/packages/core/src/components/Image.astro index 0990f5b52c..f5f1e92ab6 100644 --- a/packages/core/src/components/Image.astro +++ b/packages/core/src/components/Image.astro @@ -25,7 +25,8 @@ export interface Props { url?: string; /** Provider ID for external media (e.g., "cloudflare-images") */ provider?: string; - /** Provider metadata (blurhash, dominantColor, etc.) */ + /** Provider metadata. LQIP is read from first-class fields below; + * `meta` only remains as a fallback for legacy snapshots. */ meta?: Record; }; alt?: string; @@ -34,6 +35,10 @@ export interface Props { width?: number; /** Original image height */ height?: number; + /** LQIP blurhash — first-class field (legacy snapshots store it in `asset.meta`). */ + blurhash?: string; + /** LQIP dominant color — first-class field (legacy snapshots store it in `asset.meta`). */ + dominantColor?: string; /** Display width for this instance (overrides original) */ displayWidth?: number; /** Display height for this instance (overrides original) */ @@ -152,9 +157,14 @@ if (!src) { } } -// Build placeholder background style -const blurhash = asset.meta?.blurhash as string | undefined; -const dominantColor = asset.meta?.dominantColor as string | undefined; +// Build placeholder background style. Prefer first-class LQIP fields; fall +// back to `asset.meta` for legacy snapshots persisted before LQIP was promoted. +const blurhash = (node.blurhash ?? (asset.meta?.blurhash as string | undefined)) as + | string + | undefined; +const dominantColor = (node.dominantColor ?? (asset.meta?.dominantColor as string | undefined)) as + | string + | undefined; let placeholderStyle = ""; if (blurhash) { diff --git a/packages/core/src/components/InlinePortableTextEditor.tsx b/packages/core/src/components/InlinePortableTextEditor.tsx index c818aa5077..4f015c7709 100644 --- a/packages/core/src/components/InlinePortableTextEditor.tsx +++ b/packages/core/src/components/InlinePortableTextEditor.tsx @@ -200,6 +200,13 @@ function convertPMNode(node: PMNode): PTBlock | PTBlock[] | null { } case "image": { const provider = attrStrOpt(node.attrs, "provider"); + const blurhash = attrStrOpt(node.attrs, "blurhash"); + const dominantColor = attrStrOpt(node.attrs, "dominantColor"); + // Persist LQIP as first-class block fields (matching the image-field + // MediaValue path) rather than nesting in `asset.meta`, so read sites + // and normalize don't need a dual-shape fallback. `asset.meta` is left + // to carry only provider-specific data — it isn't reconstructed here, + // so non-LQIP meta keys are never silently dropped on editor round-trip. return { _type: "image", _key: k(), @@ -212,6 +219,8 @@ function convertPMNode(node: PMNode): PTBlock | PTBlock[] | null { caption: attrStrOpt(node.attrs, "caption") ?? attrStrOpt(node.attrs, "title"), width: attrNum(node.attrs, "width"), height: attrNum(node.attrs, "height"), + ...(blurhash ? { blurhash } : {}), + ...(dominantColor ? { dominantColor } : {}), displayWidth: attrNum(node.attrs, "displayWidth"), displayHeight: attrNum(node.attrs, "displayHeight"), }; @@ -416,16 +425,38 @@ function convertPTBlock(block: PTBlock): JSONContent | null { } if (block._type === "image") { const ib = block as PTBlock & { - asset?: { _ref?: string; url?: string; provider?: string }; + asset?: { + _ref?: string; + url?: string; + provider?: string; + meta?: Record; + }; url?: string; alt?: string; caption?: string; width?: number; height?: number; + /** LQIP — first-class field (legacy snapshots keep it in `asset.meta`). */ + blurhash?: string; + dominantColor?: string; displayWidth?: number; displayHeight?: number; }; const asset = ib.asset; + const meta = asset?.meta; + // Prefer first-class LQIP fields; fall back to `asset.meta` for legacy. + const blurhash = + typeof ib.blurhash === "string" + ? ib.blurhash + : typeof meta?.blurhash === "string" + ? meta.blurhash + : null; + const dominantColor = + typeof ib.dominantColor === "string" + ? ib.dominantColor + : typeof meta?.dominantColor === "string" + ? meta.dominantColor + : null; return { type: "image", attrs: { @@ -437,6 +468,8 @@ function convertPTBlock(block: PTBlock): JSONContent | null { provider: asset?.provider, width: ib.width, height: ib.height, + blurhash, + dominantColor, displayWidth: ib.displayWidth, displayHeight: ib.displayHeight, }, @@ -1145,6 +1178,8 @@ interface MediaItemData { storageKey?: string; width?: number; height?: number; + blurhash?: string; + dominantColor?: string; alt?: string; provider?: string; previewUrl?: string; @@ -1224,6 +1259,8 @@ function InlineMediaPicker({ storageKey?: string; width?: number; height?: number; + blurhash?: string; + dominantColor?: string; alt?: string; meta?: Record; }>; @@ -1239,6 +1276,8 @@ function InlineMediaPicker({ storageKey: item.storageKey, width: item.width, height: item.height, + blurhash: item.blurhash, + dominantColor: item.dominantColor, alt: item.alt, provider: activeProvider === "local" ? undefined : activeProvider, previewUrl: item.previewUrl, @@ -1319,6 +1358,8 @@ function InlineMediaPicker({ storageKey: raw.storageKey, width: raw.width || dims.width, height: raw.height || dims.height, + blurhash: raw.blurhash, + dominantColor: raw.dominantColor, alt: raw.alt, }; } else { @@ -1339,6 +1380,8 @@ function InlineMediaPicker({ url: raw.previewUrl || "", width: raw.width || dims.width, height: raw.height || dims.height, + blurhash: raw.blurhash, + dominantColor: raw.dominantColor, alt: raw.alt, provider: activeProvider, previewUrl: raw.previewUrl, @@ -1854,6 +1897,8 @@ export function InlinePortableTextEditor({ provider: { default: null }, width: { default: null }, height: { default: null }, + blurhash: { default: null }, + dominantColor: { default: null }, }; }, }), @@ -1924,6 +1969,8 @@ export function InlinePortableTextEditor({ mediaId: item.id, width: item.width, height: item.height, + blurhash: item.blurhash, + dominantColor: item.dominantColor, }) .run(); setMediaPickerOpen(false); diff --git a/packages/core/src/database/repositories/media.ts b/packages/core/src/database/repositories/media.ts index 59c0ddf4ad..e62850a74f 100644 --- a/packages/core/src/database/repositories/media.ts +++ b/packages/core/src/database/repositories/media.ts @@ -143,7 +143,13 @@ export class MediaRepository { */ async confirmUpload( id: string, - metadata?: { width?: number; height?: number; size?: number }, + metadata?: { + width?: number; + height?: number; + size?: number; + blurhash?: string; + dominantColor?: string; + }, ): Promise { const existing = await this.findById(id); if (!existing) { @@ -156,6 +162,8 @@ export class MediaRepository { if (metadata?.width !== undefined) updates.width = metadata.width; if (metadata?.height !== undefined) updates.height = metadata.height; if (metadata?.size !== undefined) updates.size = metadata.size; + if (metadata?.blurhash !== undefined) updates.blurhash = metadata.blurhash; + if (metadata?.dominantColor !== undefined) updates.dominant_color = metadata.dominantColor; await this.db.updateTable("media").set(updates).where("id", "=", id).execute(); diff --git a/packages/core/src/media/enrich.ts b/packages/core/src/media/enrich.ts new file mode 100644 index 0000000000..e2ae0cea8b --- /dev/null +++ b/packages/core/src/media/enrich.ts @@ -0,0 +1,66 @@ +/** + * Image Metadata Enrichment + * + * Single seam that derives image dimensions and LQIP placeholders (blurhash, + * dominant color) from raw image bytes. Every server-side media-creation path + * routes through this so records are populated consistently. Pure-JS and + * Workers-safe (image-size reads headers only; generatePlaceholder guards + * decode size). + */ + +import { normalizeMime } from "./mime.js"; +import { generatePlaceholder, readDimensions } from "./placeholder.js"; + +export interface EnrichedImageMetadata { + width?: number; + height?: number; + blurhash?: string; + dominantColor?: string; +} + +/** + * Derive dimensions + LQIP placeholders from image bytes. + * + * - Non-image content types return `{}`. + * - `knownDimensions` (e.g. browser `naturalWidth/Height`) win over `image-size` + * because the browser applies EXIF orientation; `image-size` reports raw header + * dimensions, which are swapped for 90°/270°-rotated JPEGs. + * - `placeholder` lets a caller decode a smaller thumbnail for the blurhash to + * avoid OOM on large originals; dimensions still come from `bytes`. + * - Placeholders are jpeg/png only (the generator's supported formats); other + * image types still get dimensions. + */ +export async function enrichImageMetadata( + bytes: Uint8Array, + contentType: string, + opts?: { + knownDimensions?: { width: number; height: number }; + placeholder?: { bytes: Uint8Array; contentType: string }; + }, +): Promise { + const normalizedContentType = normalizeMime(contentType); + if (!normalizedContentType.startsWith("image/")) return {}; + + // Dimensions for the returned record come from the main bytes (or the + // caller's knownDimensions). The header is read exactly once here and passed + // into generatePlaceholder so it isn't re-read on the no-override path. + const dims = opts?.knownDimensions ?? readDimensions(bytes) ?? undefined; + + // When a smaller thumbnail override is supplied, decode that for the blurhash + // but let generatePlaceholder read the thumbnail's own dimensions for the OOM + // guard (the override buffer is what actually gets decoded). On the common + // no-override path pass the dims already read from this same buffer. + const override = opts?.placeholder; + const placeholder = await generatePlaceholder( + override ? override.bytes : bytes, + override ? normalizeMime(override.contentType) : normalizedContentType, + override ? undefined : dims, + ); + + return { + width: dims?.width, + height: dims?.height, + blurhash: placeholder?.blurhash, + dominantColor: placeholder?.dominantColor, + }; +} diff --git a/packages/core/src/media/index.ts b/packages/core/src/media/index.ts index 2879a072cb..4a32347df2 100644 --- a/packages/core/src/media/index.ts +++ b/packages/core/src/media/index.ts @@ -27,6 +27,7 @@ export type { export { mediaItemToValue } from "./types.js"; export { normalizeMediaValue } from "./normalize.js"; export { generatePlaceholder, type PlaceholderData } from "./placeholder.js"; +export { enrichImageMetadata, type EnrichedImageMetadata } from "./enrich.js"; // Built-in providers export { localMedia, type LocalMediaConfig } from "./local.js"; diff --git a/packages/core/src/media/local-runtime.ts b/packages/core/src/media/local-runtime.ts index baca6586a8..f3435388bc 100644 --- a/packages/core/src/media/local-runtime.ts +++ b/packages/core/src/media/local-runtime.ts @@ -73,6 +73,8 @@ export const createMediaProvider: CreateMediaProviderFn size: item.size ?? undefined, width: item.width ?? undefined, height: item.height ?? undefined, + blurhash: item.blurhash ?? undefined, + dominantColor: item.dominantColor ?? undefined, alt: item.alt ?? undefined, previewUrl: `/_emdash/api/media/file/${item.storageKey}`, meta: { @@ -97,6 +99,8 @@ export const createMediaProvider: CreateMediaProviderFn size: item.size ?? undefined, width: item.width ?? undefined, height: item.height ?? undefined, + blurhash: item.blurhash ?? undefined, + dominantColor: item.dominantColor ?? undefined, alt: item.alt ?? undefined, previewUrl: `/_emdash/api/media/file/${item.storageKey}`, meta: { @@ -148,6 +152,15 @@ export const createMediaProvider: CreateMediaProviderFn const src = `/_emdash/api/media/file/${storageKey}`; const mimeType = value.mimeType || ""; + // Prefer the first-class fields; fall back to `meta` for legacy snapshots + // stored before LQIP was promoted off the provider-specific `meta` bag. + const blurhash = + value.blurhash ?? + (typeof value.meta?.blurhash === "string" ? value.meta.blurhash : undefined); + const dominantColor = + value.dominantColor ?? + (typeof value.meta?.dominantColor === "string" ? value.meta.dominantColor : undefined); + // Determine embed type based on MIME type if (mimeType.startsWith("image/")) { return { @@ -155,6 +168,8 @@ export const createMediaProvider: CreateMediaProviderFn src, width: value.width, height: value.height, + blurhash, + dominantColor, alt: value.alt, }; } @@ -185,6 +200,8 @@ export const createMediaProvider: CreateMediaProviderFn src, width: value.width, height: value.height, + blurhash, + dominantColor, alt: value.alt, }; }, @@ -221,6 +238,8 @@ export function repoItemToProviderItem(item: { size: item.size ?? undefined, width: item.width ?? undefined, height: item.height ?? undefined, + blurhash: item.blurhash ?? undefined, + dominantColor: item.dominantColor ?? undefined, alt: item.alt ?? undefined, previewUrl: `/_emdash/api/media/file/${item.storageKey}`, meta: { diff --git a/packages/core/src/media/normalize.ts b/packages/core/src/media/normalize.ts index c1c491daa4..945dc66978 100644 --- a/packages/core/src/media/normalize.ts +++ b/packages/core/src/media/normalize.ts @@ -60,7 +60,14 @@ export async function normalizeMediaValue( const needsDimensions = result.width == null || result.height == null; const needsStorageKey = provider === "local" && !result.meta?.storageKey; const needsFileInfo = !result.mimeType || !result.filename; - const needsLookup = needsDimensions || needsStorageKey || needsFileInfo; + // LQIP placeholders are immutable facts of the bytes: if an image record is + // missing them, the provider may have gained them since (e.g. content saved + // before LQIP backfill ran, or the row's blurhash was populated later). Pull + // them on every image lookup so the LQIP backfill in mergeProviderData runs. + const needsLqip = + (result.mimeType ?? "").startsWith("image/") && + (result.blurhash == null || result.dominantColor == null); + const needsLookup = needsDimensions || needsStorageKey || needsFileInfo || needsLqip; if (!needsLookup || !id) return result; @@ -138,6 +145,8 @@ async function resolveInternalUrl( mimeType: item.mimeType, width: item.width, height: item.height, + blurhash: item.blurhash, + dominantColor: item.dominantColor, alt: item.alt, meta: item.meta, }; @@ -167,6 +176,8 @@ async function resolveLocalId( mimeType: item.mimeType, width: item.width, height: item.height, + blurhash: item.blurhash, + dominantColor: item.dominantColor, alt: item.alt, meta: item.meta, }; @@ -183,6 +194,12 @@ function mergeProviderData(existing: MediaValue, item: MediaProviderItem): Media if (result.width == null && item.width != null) result.width = item.width; if (result.height == null && item.height != null) result.height = item.height; + // Fill missing LQIP placeholders (immutable facts of the bytes; caller wins) + if (result.blurhash == null && item.blurhash != null) result.blurhash = item.blurhash; + if (result.dominantColor == null && item.dominantColor != null) { + result.dominantColor = item.dominantColor; + } + // Fill missing file info if (!result.filename && item.filename) result.filename = item.filename; if (!result.mimeType && item.mimeType) result.mimeType = item.mimeType; @@ -217,6 +234,8 @@ function recordToMediaValue(obj: Record): MediaValue { if (typeof obj.mimeType === "string") result.mimeType = obj.mimeType; if (typeof obj.width === "number") result.width = obj.width; if (typeof obj.height === "number") result.height = obj.height; + if (typeof obj.blurhash === "string") result.blurhash = obj.blurhash; + if (typeof obj.dominantColor === "string") result.dominantColor = obj.dominantColor; if (typeof obj.alt === "string") result.alt = obj.alt; if (isRecord(obj.meta)) result.meta = obj.meta; return result; diff --git a/packages/core/src/media/placeholder.ts b/packages/core/src/media/placeholder.ts index 51393c1ded..ac7a232fe4 100644 --- a/packages/core/src/media/placeholder.ts +++ b/packages/core/src/media/placeholder.ts @@ -9,6 +9,8 @@ import { encode } from "blurhash"; import { imageSize } from "image-size"; +import { normalizeMime } from "./mime.js"; + export interface PlaceholderData { blurhash: string; dominantColor: string; @@ -85,8 +87,12 @@ function extractDominantColor(data: Uint8Array, width: number, height: number): /** * Read image dimensions from headers without decoding pixel data. + * Returns null when the header cannot be parsed. + * + * Shared by every caller that needs pixel dimensions so the header is parsed + * once per buffer, not re-read inside generatePlaceholder. */ -function getImageDimensions(buffer: Uint8Array): { width: number; height: number } | null { +export function readDimensions(buffer: Uint8Array): { width: number; height: number } | null { try { const result = imageSize(buffer); if (result.width != null && result.height != null) { @@ -102,23 +108,37 @@ function getImageDimensions(buffer: Uint8Array): { width: number; height: number * Generate blurhash and dominant color from an image buffer. * Returns null for non-image MIME types or on failure. * - * @param dimensions - Optional pre-known dimensions. Used as a fallback when - * image-size cannot parse the buffer (e.g. truncated headers). When the - * decoded size (width * height * 4) exceeds MAX_DECODED_BYTES, placeholder - * generation is skipped to avoid OOM on memory-constrained runtimes. + * @param dimensions - Optional pre-known dimensions. When present they are + * trusted verbatim (the caller has typically already read them via + * readDimensions); otherwise dimensions are read from this buffer's header. + * Generation is skipped (returns null) when no dimensions are available at + * all, or when the decoded size (width * height * 4) exceeds + * MAX_DECODED_BYTES — both guards avoid OOM from unbounded decodes on + * memory-constrained runtimes. */ export async function generatePlaceholder( buffer: Uint8Array, mimeType: string, dimensions?: { width: number; height: number }, ): Promise { - const format = SUPPORTED_TYPES[mimeType]; + const format = SUPPORTED_TYPES[normalizeMime(mimeType)]; if (!format) return null; try { - // Safety net: skip decode if the image would exceed the memory budget - const dims = getImageDimensions(buffer) ?? dimensions; - if (dims && dims.width * dims.height * 4 > MAX_DECODED_BYTES) { + // Trust caller-supplied dimensions when present (the caller has usually + // already read them via readDimensions); otherwise read them from this + // buffer. The header is parsed at most once per call. + const dims = dimensions ?? readDimensions(buffer); + + // Safety net: the decoders allocate the full RGBA buffer with no internal + // cap, so refuse to decode unless we can bound the output size. When we + // have no parseable header AND no known dimensions, the decoded size is + // unbounded — a crafted/truncated PNG whose header image-size can't read + // could still be decodable by upng-js, so we must bail to avoid OOM on + // memory-constrained runtimes. LQIP is progressive enhancement; missing + // it is preferable to crashing the request. + if (!dims) return null; + if (dims.width * dims.height * 4 > MAX_DECODED_BYTES) { return null; } diff --git a/packages/core/src/media/types.ts b/packages/core/src/media/types.ts index 3139f97333..e9b8bc4800 100644 --- a/packages/core/src/media/types.ts +++ b/packages/core/src/media/types.ts @@ -85,6 +85,10 @@ export interface MediaProviderItem { /** Dimensions (for images/video) */ width?: number; height?: number; + /** LQIP blurhash placeholder (images only) */ + blurhash?: string; + /** LQIP dominant-color placeholder, as a CSS color (images only) */ + dominantColor?: string; /** Accessibility text */ alt?: string; /** Preview URL for admin UI thumbnail */ @@ -126,6 +130,10 @@ export interface ImageEmbed { sizes?: string; width?: number; height?: number; + /** LQIP blurhash placeholder for rendering before the image loads */ + blurhash?: string; + /** LQIP dominant-color placeholder, as a CSS color */ + dominantColor?: string; alt?: string; /** Base URL without transforms, for responsive image generation */ cdnBaseUrl?: string; @@ -256,6 +264,10 @@ export interface MediaValue { mimeType?: string; width?: number; height?: number; + /** Cached LQIP blurhash placeholder (images only) */ + blurhash?: string; + /** Cached LQIP dominant-color placeholder, as a CSS color (images only) */ + dominantColor?: string; alt?: string; /** Provider-specific data needed for embedding */ @@ -273,6 +285,8 @@ export function mediaItemToValue(providerId: string, item: MediaProviderItem): M mimeType: item.mimeType, width: item.width, height: item.height, + blurhash: item.blurhash, + dominantColor: item.dominantColor, alt: item.alt, meta: item.meta, }; diff --git a/packages/core/src/plugins/context.ts b/packages/core/src/plugins/context.ts index 533a87bffa..c7a9b53396 100644 --- a/packages/core/src/plugins/context.ts +++ b/packages/core/src/plugins/context.ts @@ -21,6 +21,7 @@ import { SsrfError, stripCredentialHeaders, } from "../import/ssrf.js"; +import { enrichImageMetadata } from "../media/enrich.js"; import { invalidateSiteSettingsCache } from "../settings/index.js"; import type { Storage } from "../storage/types.js"; import { CronAccessImpl } from "./cron.js"; @@ -508,6 +509,9 @@ export function createMediaAccessWithWrite( contentType, }); + // Derive dimensions + LQIP placeholders (no-op for non-images). + const enriched = await enrichImageMetadata(new Uint8Array(bytes), contentType); + // Create DB record — clean up storage on failure let media; try { @@ -517,6 +521,10 @@ export function createMediaAccessWithWrite( size: bytes.byteLength, storageKey, status: "ready", + width: enriched.width, + height: enriched.height, + blurhash: enriched.blurhash, + dominantColor: enriched.dominantColor, }); } catch (error) { try { diff --git a/packages/core/tests/integration/astro/media-confirm-placeholder.test.ts b/packages/core/tests/integration/astro/media-confirm-placeholder.test.ts new file mode 100644 index 0000000000..bcfc9d345c --- /dev/null +++ b/packages/core/tests/integration/astro/media-confirm-placeholder.test.ts @@ -0,0 +1,132 @@ +import type { APIContext } from "astro"; +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { POST as postConfirm } from "../../../src/astro/routes/api/media/[id]/confirm.js"; +import { MediaRepository } from "../../../src/database/repositories/media.js"; +import type { Database } from "../../../src/database/types.js"; +import { JPEG_4x4 } from "../../utils/image-fixtures.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +/** Storage stub matching the real interface: download returns a ReadableStream. */ +function storageWith(bytes: Uint8Array) { + return { + async exists() { + return true; + }, + async download() { + return { + body: new Response(bytes).body as ReadableStream, + contentType: "image/jpeg", + size: bytes.byteLength, + }; + }, + }; +} + +/** Storage stub whose download is spyable (to assert read-back never happens). */ +function spyableStorage(bytes: Uint8Array) { + const download = vi.fn(async () => ({ + body: new Response(bytes).body as ReadableStream, + contentType: "image/jpeg", + size: bytes.byteLength, + })); + return { + exists: vi.fn(async () => true), + download, + }; +} + +function buildContext(opts: { + db: Kysely; + id: string; + storage: unknown; + body: Record; +}): APIContext { + const request = new Request(`http://localhost/_emdash/api/media/${opts.id}/confirm`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-EmDash-Request": "1" }, + body: JSON.stringify(opts.body), + }); + return { + params: { id: opts.id }, + url: new URL(request.url), + request, + locals: { + emdash: { db: opts.db, storage: opts.storage }, + user: { id: "user-1", email: "t@example.com", name: "T", role: 50 as const }, + }, + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- minimal stub for tests + } as unknown as APIContext; +} + +describe("POST /media/:id/confirm — placeholder read-back", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + it("computes blurhash and dominantColor from the stored image on confirm", async () => { + const repo = new MediaRepository(db); + const pending = await repo.createPending({ + filename: "photo.jpg", + mimeType: "image/jpeg", + storageKey: "photo.jpg", + authorId: "user-1", + }); + + const res = await postConfirm( + buildContext({ + db, + id: pending.id, + storage: storageWith(JPEG_4x4), + body: { size: JPEG_4x4.byteLength, width: 4, height: 4 }, + }), + ); + + expect(res.status).toBe(200); + const row = await repo.findById(pending.id); + expect(row?.status).toBe("ready"); + expect(row?.width).toBe(4); + expect(row?.blurhash).toBeTruthy(); + expect(row?.dominantColor).toMatch(/^rgb\(/); + }); + + it("skips placeholder read-back for oversized images (OOM guard) but still confirms", async () => { + const repo = new MediaRepository(db); + const pending = await repo.createPending({ + filename: "huge.jpg", + mimeType: "image/jpeg", + storageKey: "huge.jpg", + authorId: "user-1", + }); + const storage = spyableStorage(JPEG_4x4); + + // Confirm claims a size far above the download cap. The signed-URL flow + // exists so large files bypass server buffering; confirm must not re-read + // such an object into memory just to compute a blurhash. + const res = await postConfirm( + buildContext({ + db, + id: pending.id, + storage, + body: { size: 64 * 1024 * 1024, width: 4000, height: 3000 }, + }), + ); + + expect(res.status).toBe(200); + expect(storage.download).not.toHaveBeenCalled(); + const row = await repo.findById(pending.id); + expect(row?.status).toBe("ready"); + // Client-supplied dimensions are still recorded even when LQIP is skipped. + expect(row?.width).toBe(4000); + expect(row?.height).toBe(3000); + expect(row?.blurhash).toBeNull(); + expect(row?.dominantColor).toBeNull(); + }); +}); diff --git a/packages/core/tests/integration/astro/media-upload-placeholder.test.ts b/packages/core/tests/integration/astro/media-upload-placeholder.test.ts new file mode 100644 index 0000000000..e5c99e4c86 --- /dev/null +++ b/packages/core/tests/integration/astro/media-upload-placeholder.test.ts @@ -0,0 +1,113 @@ +import type { APIContext } from "astro"; +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { POST as postMedia } from "../../../src/astro/routes/api/media.js"; +import type { Database } from "../../../src/database/types.js"; +import { JPEG_4x4 } from "../../utils/image-fixtures.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +interface CapturedInput { + width?: number; + height?: number; + blurhash?: string; + dominantColor?: string; +} + +function buildContext(opts: { + db: Kysely; + file: File; + captured: { value?: CapturedInput }; + width?: number; + height?: number; +}): APIContext { + const formData = new FormData(); + formData.append("file", opts.file); + if (opts.width != null) formData.append("width", String(opts.width)); + if (opts.height != null) formData.append("height", String(opts.height)); + const request = new Request("http://localhost/_emdash/api/media", { + method: "POST", + headers: { "X-EmDash-Request": "1" }, + body: formData, + }); + return { + params: {}, + url: new URL(request.url), + request, + locals: { + emdash: { + db: opts.db, + config: {}, + storage: { + async upload(o: { key: string }) { + return { key: o.key, url: `/m/${o.key}`, size: 0 }; + }, + }, + handleMediaCreate: async (input: CapturedInput & Record) => { + opts.captured.value = input; + return { + success: true as const, + data: { item: { id: "t", storageKey: "k", ...input } }, + }; + }, + }, + user: { id: "user-1", email: "t@example.com", name: "T", role: 50 as const }, + }, + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- minimal stub for tests + } as unknown as APIContext; +} + +describe("POST /media — server-side placeholder + dimensions", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + it("derives dimensions and blurhash from the uploaded image when the client sends none", async () => { + const captured: { value?: CapturedInput } = {}; + const file = new File([JPEG_4x4], "photo.jpg", { type: "image/jpeg" }); + + const res = await postMedia(buildContext({ db, file, captured })); + + expect(res.status).toBe(201); + expect(captured.value?.width).toBe(4); + expect(captured.value?.height).toBe(4); + expect(captured.value?.blurhash).toBeTruthy(); + expect(captured.value?.dominantColor).toMatch(/^rgb\(/); + }); + + it("preserves client-sent width/height for non-image uploads and skips placeholder", async () => { + const captured: { value?: CapturedInput } = {}; + const file = new File([new Uint8Array([1, 2, 3, 4])], "doc.pdf", { type: "application/pdf" }); + + const res = await postMedia(buildContext({ db, file, captured, width: 640, height: 480 })); + + expect(res.status).toBe(201); + expect(captured.value?.width).toBe(640); + expect(captured.value?.height).toBe(480); + expect(captured.value?.blurhash).toBeUndefined(); + expect(captured.value?.dominantColor).toBeUndefined(); + }); + + it("prefers client-sent width/height over server-derived dims (EXIF orientation safety)", async () => { + // Browser naturalWidth/Height apply EXIF orientation; image-size reports raw + // header dims (swapped for 90°/270° JPEGs). REST must honor client dims — + // matching confirm.ts — not the server header dims. + const captured: { value?: CapturedInput } = {}; + const file = new File([JPEG_4x4], "photo.jpg", { type: "image/jpeg" }); + + const res = await postMedia(buildContext({ db, file, captured, width: 999, height: 999 })); + + expect(res.status).toBe(201); + // Client dimensions win even though the server read 4×4 from the header. + expect(captured.value?.width).toBe(999); + expect(captured.value?.height).toBe(999); + // A blurhash is still generated from the uploaded bytes. + expect(captured.value?.blurhash).toBeTruthy(); + }); +}); diff --git a/packages/core/tests/integration/runtime/plugin-media-enrich.test.ts b/packages/core/tests/integration/runtime/plugin-media-enrich.test.ts new file mode 100644 index 0000000000..682bd89046 --- /dev/null +++ b/packages/core/tests/integration/runtime/plugin-media-enrich.test.ts @@ -0,0 +1,85 @@ +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { MediaRepository } from "../../../src/database/repositories/media.js"; +import type { Database } from "../../../src/database/types.js"; +import { createMediaAccessWithWrite } from "../../../src/plugins/context.js"; +import type { Storage } from "../../../src/storage/types.js"; +import { JPEG_4x4 } from "../../utils/image-fixtures.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +function fakeStorage(): Storage { + const store = new Map(); + return { + async upload(o) { + const b = o.body instanceof Uint8Array ? o.body : new Uint8Array(o.body as ArrayBuffer); + store.set(o.key, b); + return { key: o.key, url: `/m/${o.key}`, size: b.byteLength }; + }, + async download(key) { + const b = store.get(key) ?? new Uint8Array(); + return { + body: new Response(b).body as ReadableStream, + contentType: "application/octet-stream", + size: b.byteLength, + }; + }, + async delete(key) { + store.delete(key); + }, + async exists(key) { + return store.has(key); + }, + async list() { + return { files: [] }; + }, + async getSignedUploadUrl(o) { + return { + url: `/s/${o.key}`, + method: "PUT", + headers: {}, + expiresAt: new Date().toISOString(), + }; + }, + getPublicUrl(key) { + return `/m/${key}`; + }, + }; +} + +describe("plugin ctx.media.upload — metadata enrichment", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + it("populates width, height, blurhash and dominantColor for an image upload", async () => { + const media = createMediaAccessWithWrite(db, undefined, fakeStorage()); + const ab = JPEG_4x4.slice().buffer; // clean ArrayBuffer copy + const result = await media.upload("derived.jpg", "image/jpeg", ab); + + const row = await new MediaRepository(db).findById(result.mediaId); + expect(row?.width).toBe(4); + expect(row?.height).toBe(4); + expect(row?.blurhash).toBeTruthy(); + expect(row?.dominantColor).toMatch(/^rgb\(/); + }); + + it("leaves metadata null for a non-image upload without throwing", async () => { + const media = createMediaAccessWithWrite(db, undefined, fakeStorage()); + const result = await media.upload( + "data.bin", + "application/octet-stream", + new Uint8Array([1, 2, 3, 4]).buffer, + ); + + const row = await new MediaRepository(db).findById(result.mediaId); + expect(row?.width).toBeNull(); + expect(row?.blurhash).toBeNull(); + }); +}); diff --git a/packages/core/tests/integration/wordpress-import/media-enrich.test.ts b/packages/core/tests/integration/wordpress-import/media-enrich.test.ts new file mode 100644 index 0000000000..5a112d4429 --- /dev/null +++ b/packages/core/tests/integration/wordpress-import/media-enrich.test.ts @@ -0,0 +1,61 @@ +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { JPEG } = vi.hoisted(() => ({ + JPEG: new Uint8Array( + Buffer.from( + "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACP/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAVAQEBAAAAAAAAAAAAAAAAAAAHCf/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/ADoDFU3/2Q==", + "base64", + ), + ), +})); + +vi.mock("#import/ssrf.js", () => ({ + validateExternalUrl: () => {}, + SsrfError: class SsrfError extends Error {}, + ssrfSafeFetch: async () => + new Response(JPEG, { status: 200, headers: { "content-type": "image/jpeg" } }), +})); + +import { importMediaWithProgress } from "../../../src/astro/routes/api/import/wordpress/media.js"; +import { MediaRepository } from "../../../src/database/repositories/media.js"; +import type { Database } from "../../../src/database/types.js"; +import type { Storage } from "../../../src/storage/types.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +function fakeStorage(): Pick { + return { + async upload(o) { + const b = o.body instanceof Uint8Array ? o.body : new Uint8Array(o.body as ArrayBuffer); + return { key: o.key, url: `/m/${o.key}`, size: b.byteLength }; + }, + }; +} + +describe("WordPress import — media enrichment", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + vi.restoreAllMocks(); + }); + + it("enriches imported images with dimensions and placeholders", async () => { + const result = await importMediaWithProgress( + [{ id: 1, url: "https://cdn.example.com/p.jpg", filename: "p.jpg", mimeType: "image/jpeg" }], + db, + fakeStorage() as Storage, + () => {}, + ); + + expect(result.imported).toHaveLength(1); + const row = await new MediaRepository(db).findById(result.imported[0]!.mediaId); + expect(row?.width).toBe(4); + expect(row?.height).toBe(4); + expect(row?.blurhash).toBeTruthy(); + }); +}); diff --git a/packages/core/tests/unit/components/inline-portable-text-image.test.ts b/packages/core/tests/unit/components/inline-portable-text-image.test.ts new file mode 100644 index 0000000000..d5a92dc0f6 --- /dev/null +++ b/packages/core/tests/unit/components/inline-portable-text-image.test.ts @@ -0,0 +1,119 @@ +/** + * Inline editor image LQIP round-trip tests. + * + * Verifies blurhash/dominantColor survive the Portable Text ↔ ProseMirror + * conversion the inline (visual-editing) editor exercises, so author-inserted + * images keep their placeholders. LQIP is persisted as first-class block fields + * (matching the image-field path); `asset.meta` is only a read fallback for + * legacy snapshots. + */ + +import { describe, it, expect } from "vitest"; + +import { + _pmToPortableText as pmToPortableText, + _portableTextToPM as portableTextToPM, +} from "../../../src/components/InlinePortableTextEditor.js"; + +describe("Image LQIP round-trip (inline editor seam)", () => { + it("preserves blurhash and dominantColor through PT → PM → PT (legacy asset.meta input)", () => { + const imageBlock = { + _type: "image", + _key: "img001", + asset: { + _ref: "01ABC", + url: "/_emdash/api/media/file/01ABC.jpg", + meta: { + blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + dominantColor: "#aabbcc", + }, + }, + alt: "A photo", + width: 1200, + height: 800, + }; + + const pm = portableTextToPM([imageBlock]); + const node = pm.content?.[0] as { + type: string; + attrs?: { blurhash?: string; dominantColor?: string }; + }; + + expect(node.type).toBe("image"); + expect(node.attrs?.blurhash).toBe("LEHV6nWB2yk8pyo0adR*.7kCMdnj"); + expect(node.attrs?.dominantColor).toBe("#aabbcc"); + + const pt = pmToPortableText(pm); + const restored = pt[0] as { + _type: string; + asset?: { meta?: Record }; + blurhash?: string; + dominantColor?: string; + }; + + // Promoted to first-class fields on save; asset.meta no longer carries them. + expect(restored._type).toBe("image"); + expect(restored.blurhash).toBe("LEHV6nWB2yk8pyo0adR*.7kCMdnj"); + expect(restored.dominantColor).toBe("#aabbcc"); + expect(restored.asset?.meta?.blurhash).toBeUndefined(); + expect(restored.asset?.meta?.dominantColor).toBeUndefined(); + }); + + it("preserves first-class LQIP through PT → PM → PT", () => { + const imageBlock = { + _type: "image", + _key: "img001b", + asset: { _ref: "01ABC2", url: "/_emdash/api/media/file/01ABC2.jpg" }, + alt: "A photo", + width: 1200, + height: 800, + blurhash: "L6PZfSi_.AyE_3t7t7R**0o#DgR4", + dominantColor: "#112233", + }; + + const pm = portableTextToPM([imageBlock]); + const node = pm.content?.[0] as { + attrs?: { blurhash?: string; dominantColor?: string }; + }; + expect(node.attrs?.blurhash).toBe("L6PZfSi_.AyE_3t7t7R**0o#DgR4"); + expect(node.attrs?.dominantColor).toBe("#112233"); + + const pt = pmToPortableText(pm); + const restored = pt[0] as { + blurhash?: string; + dominantColor?: string; + asset?: { meta?: Record }; + }; + expect(restored.blurhash).toBe("L6PZfSi_.AyE_3t7t7R**0o#DgR4"); + expect(restored.dominantColor).toBe("#112233"); + expect(restored.asset?.meta?.blurhash).toBeUndefined(); + }); + + it("omits LQIP entirely when none is present", () => { + const imageBlock = { + _type: "image", + _key: "img002", + asset: { _ref: "01XYZ", url: "/_emdash/api/media/file/01XYZ.jpg" }, + alt: "No placeholder", + width: 640, + height: 480, + }; + + const pm = portableTextToPM([imageBlock]); + const node = pm.content?.[0] as { + attrs?: { blurhash?: string | null; dominantColor?: string | null }; + }; + expect(node.attrs?.blurhash ?? null).toBeNull(); + expect(node.attrs?.dominantColor ?? null).toBeNull(); + + const pt = pmToPortableText(pm); + const restored = pt[0] as { + asset?: { meta?: Record }; + blurhash?: string; + dominantColor?: string; + }; + expect(restored.asset?.meta).toBeUndefined(); + expect(restored.blurhash).toBeUndefined(); + expect(restored.dominantColor).toBeUndefined(); + }); +}); diff --git a/packages/core/tests/unit/database/repositories/media-confirm.test.ts b/packages/core/tests/unit/database/repositories/media-confirm.test.ts new file mode 100644 index 0000000000..dcd95cc8b6 --- /dev/null +++ b/packages/core/tests/unit/database/repositories/media-confirm.test.ts @@ -0,0 +1,41 @@ +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { MediaRepository } from "../../../../src/database/repositories/media.js"; +import type { Database } from "../../../../src/database/types.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../../utils/test-db.js"; + +describe("MediaRepository.confirmUpload", () => { + let db: Kysely; + let repo: MediaRepository; + + beforeEach(async () => { + db = await setupTestDatabase(); + repo = new MediaRepository(db); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + it("persists blurhash and dominantColor when confirming a pending upload", async () => { + const pending = await repo.createPending({ + filename: "x.jpg", + mimeType: "image/jpeg", + storageKey: "x.jpg", + }); + + const confirmed = await repo.confirmUpload(pending.id, { + width: 4, + height: 4, + size: 100, + blurhash: "LEHV6nWB2yk8", + dominantColor: "rgb(255,0,0)", + }); + + expect(confirmed?.status).toBe("ready"); + expect(confirmed?.width).toBe(4); + expect(confirmed?.blurhash).toBe("LEHV6nWB2yk8"); + expect(confirmed?.dominantColor).toBe("rgb(255,0,0)"); + }); +}); diff --git a/packages/core/tests/unit/media/enrich.test.ts b/packages/core/tests/unit/media/enrich.test.ts new file mode 100644 index 0000000000..2357598019 --- /dev/null +++ b/packages/core/tests/unit/media/enrich.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { enrichImageMetadata } from "../../../src/media/enrich.js"; +import { GIF_1x1, JPEG_4x4, PNG_4x4 } from "../../utils/image-fixtures.js"; + +describe("enrichImageMetadata", () => { + it("returns dimensions, blurhash and dominantColor for a JPEG", async () => { + const result = await enrichImageMetadata(JPEG_4x4, "image/jpeg"); + expect(result.width).toBe(4); + expect(result.height).toBe(4); + expect(result.blurhash).toBeTruthy(); + expect(result.dominantColor).toMatch(/^rgb\(/); + }); + + it("returns dimensions and a blurhash for a PNG", async () => { + const result = await enrichImageMetadata(PNG_4x4, "image/png"); + expect(result.width).toBe(4); + expect(result.height).toBe(4); + expect(result.blurhash).toBeTruthy(); + }); + + it("returns an empty object for non-image content types", async () => { + const result = await enrichImageMetadata(new Uint8Array([1, 2, 3]), "application/pdf"); + expect(result).toEqual({}); + }); + + it("prefers knownDimensions over header-derived dimensions (EXIF orientation safety)", async () => { + const result = await enrichImageMetadata(JPEG_4x4, "image/jpeg", { + knownDimensions: { width: 5, height: 9 }, + }); + expect(result.width).toBe(5); + expect(result.height).toBe(9); + }); + + it("decodes the placeholder override while keeping dimensions from the main bytes", async () => { + const result = await enrichImageMetadata(JPEG_4x4, "image/jpeg", { + placeholder: { bytes: PNG_4x4, contentType: "image/png" }, + }); + expect(result.width).toBe(4); // from JPEG_4x4 header + expect(result.blurhash).toBeTruthy(); // decoded from the PNG override + }); + + it("returns dimensions but no blurhash for a GIF (placeholder format unsupported)", async () => { + const result = await enrichImageMetadata(GIF_1x1, "image/gif"); + expect(result.width).toBe(1); + expect(result.height).toBe(1); + expect(result.blurhash).toBeUndefined(); + }); + + it("normalizes uppercase content types before placeholder generation (image/JPEG)", async () => { + const result = await enrichImageMetadata(JPEG_4x4, "image/JPEG"); + expect(result.width).toBe(4); + expect(result.height).toBe(4); + expect(result.blurhash).toBeTruthy(); + expect(result.dominantColor).toMatch(/^rgb\(/); + }); + + it("normalizes parameter-suffixed content types (image/jpeg; charset=binary)", async () => { + const result = await enrichImageMetadata(JPEG_4x4, "image/jpeg; charset=binary"); + expect(result.blurhash).toBeTruthy(); + }); + + it("treats image/JPG as jpeg", async () => { + const result = await enrichImageMetadata(JPEG_4x4, "image/JPG"); + expect(result.blurhash).toBeTruthy(); + }); +}); diff --git a/packages/core/tests/unit/media/media-value.test.ts b/packages/core/tests/unit/media/media-value.test.ts new file mode 100644 index 0000000000..67a9673129 --- /dev/null +++ b/packages/core/tests/unit/media/media-value.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; + +import { createMediaProvider } from "../../../src/media/local-runtime.js"; +import { mediaItemToValue } from "../../../src/media/types.js"; +import type { MediaProviderItem, MediaValue } from "../../../src/media/types.js"; + +describe("mediaItemToValue", () => { + it("copies blurhash and dominantColor onto the MediaValue", () => { + const item: MediaProviderItem = { + id: "01ABC", + filename: "photo.jpg", + mimeType: "image/jpeg", + width: 1200, + height: 800, + blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + dominantColor: "#aabbcc", + }; + + const value = mediaItemToValue("local", item); + + expect(value).toMatchObject({ + provider: "local", + id: "01ABC", + width: 1200, + height: 800, + blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + dominantColor: "#aabbcc", + }); + }); +}); + +describe("local provider getEmbed", () => { + // getEmbed never touches the database, so a stub db is enough to construct + // the provider. + const provider = createMediaProvider({ db: {} as never }); + + it("surfaces top-level blurhash and dominantColor on the image embed", () => { + const value: MediaValue = { + provider: "local", + id: "01ABC", + mimeType: "image/jpeg", + width: 1200, + height: 800, + blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + dominantColor: "#aabbcc", + meta: { storageKey: "01ABC.jpg" }, + }; + + const embed = provider.getEmbed(value); + + expect(embed).toMatchObject({ + type: "image", + blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + dominantColor: "#aabbcc", + }); + }); + + it("falls back to meta.blurhash for legacy MediaValue snapshots", () => { + const value: MediaValue = { + provider: "local", + id: "01ABC", + mimeType: "image/jpeg", + meta: { + storageKey: "01ABC.jpg", + blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + dominantColor: "#aabbcc", + }, + }; + + const embed = provider.getEmbed(value); + + expect(embed).toMatchObject({ + type: "image", + blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + dominantColor: "#aabbcc", + }); + }); +}); diff --git a/packages/core/tests/unit/media/normalize.test.ts b/packages/core/tests/unit/media/normalize.test.ts index 5c55a57e67..ceaa438c2c 100644 --- a/packages/core/tests/unit/media/normalize.test.ts +++ b/packages/core/tests/unit/media/normalize.test.ts @@ -246,7 +246,7 @@ describe("normalizeMediaValue", () => { }); }); - it("does not call provider when dimensions already present", async () => { + it("still calls the provider to backfill LQIP for images even when dimensions are present", async () => { const cfImages = mockProvider(null); const value = { @@ -263,10 +263,48 @@ describe("normalizeMediaValue", () => { const result = await normalizeMediaValue(value, getProvider({ "cloudflare-images": cfImages })); - expect(cfImages.get).not.toHaveBeenCalled(); + // blurhash/dominantColor are missing → provider is consulted to backfill + // them (e.g. records saved before LQIP, or rows that gained a blurhash + // later). Provider.get returns null here, so the value is unchanged. + expect(cfImages.get).toHaveBeenCalledWith("cf-abc123"); expect(result).toEqual(value); }); + it("backfills blurhash and dominantColor from the provider when only LQIP is missing", async () => { + const providerItem: MediaProviderItem = { + id: "01ABC", + filename: "photo.jpg", + mimeType: "image/jpeg", + width: 1200, + height: 800, + blurhash: "L6PZfSi_.AyE_3t7t7R**0o#DgR4", + dominantColor: "#112233", + meta: { storageKey: "01ABC.jpg" }, + }; + const local = mockProvider(providerItem); + + // Fully populated article row except for LQIP — gain from finding #3: + // the provider is now consulted so the blurhash backfill runs. + const result = await normalizeMediaValue( + { + provider: "local", + id: "01ABC", + width: 1200, + height: 800, + filename: "photo.jpg", + mimeType: "image/jpeg", + meta: { storageKey: "01ABC.jpg" }, + }, + getProvider({ local }), + ); + + expect(local.get).toHaveBeenCalledWith("01ABC"); + expect(result).toMatchObject({ + blurhash: "L6PZfSi_.AyE_3t7t7R**0o#DgR4", + dominantColor: "#112233", + }); + }); + it("preserves caller alt over provider alt", async () => { const providerItem: MediaProviderItem = { id: "01ABC", @@ -428,6 +466,89 @@ describe("normalizeMediaValue", () => { expect(local.get).toHaveBeenCalledWith("01ABC"); }); + it("copies blurhash and dominantColor from local provider to top-level", async () => { + const providerItem: MediaProviderItem = { + id: "01ABC", + filename: "photo.jpg", + mimeType: "image/jpeg", + width: 1200, + height: 800, + blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + dominantColor: "#aabbcc", + meta: { storageKey: "01ABC.jpg" }, + }; + const local = mockProvider(providerItem); + + // Missing filename/mimeType triggers the provider lookup. + const result = await normalizeMediaValue( + { + provider: "local", + id: "01ABC", + width: 1200, + height: 800, + meta: { storageKey: "01ABC.jpg" }, + }, + getProvider({ local }), + ); + + expect(result).toMatchObject({ + provider: "local", + id: "01ABC", + blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + dominantColor: "#aabbcc", + }); + }); + + it("copies blurhash and dominantColor when resolving a bare local media ID", async () => { + const providerItem: MediaProviderItem = { + id: "01ABC", + filename: "photo.png", + mimeType: "image/png", + width: 1024, + height: 768, + blurhash: "L6PZfSi_.AyE_3t7t7R**0o#DgR4", + dominantColor: "#112233", + meta: { storageKey: "01ABC.png" }, + }; + const local = mockProvider(providerItem); + + const result = await normalizeMediaValue("01ABC", getProvider({ local })); + + expect(result).toMatchObject({ + provider: "local", + id: "01ABC", + blurhash: "L6PZfSi_.AyE_3t7t7R**0o#DgR4", + dominantColor: "#112233", + }); + }); + + it("preserves caller-supplied blurhash over provider value", async () => { + const providerItem: MediaProviderItem = { + id: "01ABC", + filename: "photo.jpg", + mimeType: "image/jpeg", + width: 1200, + height: 800, + blurhash: "PROVIDER_HASH", + dominantColor: "#000000", + meta: { storageKey: "01ABC.jpg" }, + }; + const local = mockProvider(providerItem); + + const result = await normalizeMediaValue( + { + provider: "local", + id: "01ABC", + blurhash: "CALLER_HASH", + dominantColor: "#ffffff", + }, + getProvider({ local }), + ); + + expect(result!.blurhash).toBe("CALLER_HASH"); + expect(result!.dominantColor).toBe("#ffffff"); + }); + it("handles provider.get throwing gracefully", async () => { const local: MediaProvider = { list: vi.fn().mockResolvedValue({ items: [] }), diff --git a/packages/core/tests/unit/media/placeholder.test.ts b/packages/core/tests/unit/media/placeholder.test.ts index bf89ad1bee..1a780951c5 100644 --- a/packages/core/tests/unit/media/placeholder.test.ts +++ b/packages/core/tests/unit/media/placeholder.test.ts @@ -140,4 +140,33 @@ describe("generatePlaceholder", () => { expect(result).not.toBeNull(); expect(result!.blurhash).toBeTruthy(); }); + + it("returns null when no dimensions can be determined — refuses unbounded decode (OOM guard)", async () => { + // A crafted/truncated PNG whose header image-size cannot parse but a + // decoder might still accept. Without known dimensions the decoded size + // is unbounded, so generation must bail instead of risking OOM. + const unparseable = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0x04, 0x05]); + expect(await generatePlaceholder(unparseable, "image/png")).toBeNull(); + }); + + it("matches case-insensitive MIME types (image/JPEG)", async () => { + const result = await generatePlaceholder(new Uint8Array(JPEG_4x4), "image/JPEG"); + expect(result).not.toBeNull(); + expect(result!.blurhash).toBeTruthy(); + }); + + it("matches MIME types with a parameter suffix (image/jpeg; charset=binary)", async () => { + const result = await generatePlaceholder( + new Uint8Array(JPEG_4x4), + "image/jpeg; charset=binary", + ); + expect(result).not.toBeNull(); + expect(result!.blurhash).toBeTruthy(); + }); + + it("treats image/JPG as jpeg", async () => { + const result = await generatePlaceholder(new Uint8Array(JPEG_4x4), "image/JPG"); + expect(result).not.toBeNull(); + expect(result!.blurhash).toBeTruthy(); + }); }); diff --git a/packages/core/tests/utils/image-fixtures.ts b/packages/core/tests/utils/image-fixtures.ts new file mode 100644 index 0000000000..81bdadbc4a --- /dev/null +++ b/packages/core/tests/utils/image-fixtures.ts @@ -0,0 +1,20 @@ +/** Minimal 4x4 solid red JPEG. */ +export const JPEG_4x4 = new Uint8Array( + Buffer.from( + "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACP/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAVAQEBAAAAAAAAAAAAAAAAAAAHCf/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/ADoDFU3/2Q==", + "base64", + ), +); + +/** Minimal 4x4 solid red PNG. */ +export const PNG_4x4 = new Uint8Array( + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAQAAAAEAQMAAACTPww9AAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGUExURf8AAP///0EdNBEAAAABYktHRAH/Ai3eAAAAB3RJTUUH6gIcETMVn1ZhnwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyNi0wMi0yOFQxNzo1MToyMCswMDowMJE6EiQAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjYtMDItMjhUMTc6NTE6MjArMDA6MDDgZ6qYAAAAKHRFWHRkYXRlOnRpbWVzdGFtcAAyMDI2LTAyLTI4VDE3OjUxOjIwKzAwOjAwt3KLRwAAAAtJREFUCNdjYIAAAAAIAAEvIN0xAAAAAElFTkSuQmCC", + "base64", + ), +); + +/** 1x1 transparent GIF — image-size parses dimensions, but blurhash does not support GIF. */ +export const GIF_1x1 = new Uint8Array( + Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64"), +); From 468f523a5eb083295465ae3cf48bba5609f0ef3a Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:57:40 +0300 Subject: [PATCH 2/2] fix(media): use header dims, not client dims, for placeholder OOM guard enrichImageMetadata fed caller-supplied knownDimensions into generatePlaceholder's decoded-size cap. Since knownDimensions is decoupled from the buffer, a client could claim a tiny size for a huge image and bypass the guard, making the decoder allocate an unbounded RGBA buffer and OOM the runtime. Read header dimensions from the bytes that are actually decoded and use only those for the guard; keep knownDimensions for the stored record so the EXIF-orientation fix is preserved. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/media/enrich.ts | 34 +++++++++++++------ packages/core/tests/unit/media/enrich.test.ts | 22 ++++++++++++ 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/packages/core/src/media/enrich.ts b/packages/core/src/media/enrich.ts index e2ae0cea8b..8eea2beccb 100644 --- a/packages/core/src/media/enrich.ts +++ b/packages/core/src/media/enrich.ts @@ -23,8 +23,12 @@ export interface EnrichedImageMetadata { * * - Non-image content types return `{}`. * - `knownDimensions` (e.g. browser `naturalWidth/Height`) win over `image-size` - * because the browser applies EXIF orientation; `image-size` reports raw header - * dimensions, which are swapped for 90°/270°-rotated JPEGs. + * for the *stored record* because the browser applies EXIF orientation; + * `image-size` reports raw header dimensions, which are swapped for + * 90°/270°-rotated JPEGs. They are NOT used for the decode OOM guard — see below. + * - The placeholder OOM guard uses only header dimensions read from the bytes + * actually decoded. Caller-supplied `knownDimensions` are untrusted for the + * guard: a client could claim a tiny size for a huge image to bypass the cap. * - `placeholder` lets a caller decode a smaller thumbnail for the blurhash to * avoid OOM on large originals; dimensions still come from `bytes`. * - Placeholders are jpeg/png only (the generator's supported formats); other @@ -41,25 +45,33 @@ export async function enrichImageMetadata( const normalizedContentType = normalizeMime(contentType); if (!normalizedContentType.startsWith("image/")) return {}; - // Dimensions for the returned record come from the main bytes (or the - // caller's knownDimensions). The header is read exactly once here and passed - // into generatePlaceholder so it isn't re-read on the no-override path. - const dims = opts?.knownDimensions ?? readDimensions(bytes) ?? undefined; + // Header dimensions are read once from the actual bytes. They feed the + // placeholder OOM guard, which must never trust caller-supplied dimensions: + // `knownDimensions` is decoupled from the buffer, so a client could claim a + // tiny size for a huge image and slip past the decoded-size cap, making the + // decoder allocate an unbounded RGBA buffer and OOM the runtime. Only dims + // read from the buffer that actually gets decoded can bound the decode. + const headerDims = readDimensions(bytes) ?? undefined; + + // Dimensions published on the record prefer the caller's knownDimensions + // (e.g. browser naturalWidth/Height, which apply EXIF orientation) over the + // raw header dims, which are swapped for 90°/270°-rotated JPEGs. + const recordDims = opts?.knownDimensions ?? headerDims; // When a smaller thumbnail override is supplied, decode that for the blurhash - // but let generatePlaceholder read the thumbnail's own dimensions for the OOM + // and let generatePlaceholder read the thumbnail's own header for the OOM // guard (the override buffer is what actually gets decoded). On the common - // no-override path pass the dims already read from this same buffer. + // no-override path pass the header dims already read from this same buffer. const override = opts?.placeholder; const placeholder = await generatePlaceholder( override ? override.bytes : bytes, override ? normalizeMime(override.contentType) : normalizedContentType, - override ? undefined : dims, + override ? undefined : headerDims, ); return { - width: dims?.width, - height: dims?.height, + width: recordDims?.width, + height: recordDims?.height, blurhash: placeholder?.blurhash, dominantColor: placeholder?.dominantColor, }; diff --git a/packages/core/tests/unit/media/enrich.test.ts b/packages/core/tests/unit/media/enrich.test.ts index 2357598019..95f0397c25 100644 --- a/packages/core/tests/unit/media/enrich.test.ts +++ b/packages/core/tests/unit/media/enrich.test.ts @@ -64,4 +64,26 @@ describe("enrichImageMetadata", () => { const result = await enrichImageMetadata(JPEG_4x4, "image/JPG"); expect(result.blurhash).toBeTruthy(); }); + + it("guards the decode with header dimensions, not client knownDimensions (OOM bypass)", async () => { + // A real JPEG whose header declares 3000×3000 (3000²×4 = 36 MB RGBA, over + // the 32 MB decode cap) but is tiny on the wire (solid color). A malicious + // client claims a 1×1 size to slip past the guard so the decoder allocates + // the full RGBA buffer and OOMs the runtime. + const { encode } = await import("jpeg-js"); + const side = 3000; + const raw = { data: Buffer.alloc(side * side * 4, 0xff), width: side, height: side }; + const bigJpeg = new Uint8Array(encode(raw, 50).data); + + const result = await enrichImageMetadata(bigJpeg, "image/jpeg", { + knownDimensions: { width: 1, height: 1 }, + }); + + // The guard reads the real header dims and skips the oversized decode. + expect(result.blurhash).toBeUndefined(); + expect(result.dominantColor).toBeUndefined(); + // Client dims are still trusted for the stored record (EXIF-orientation fix). + expect(result.width).toBe(1); + expect(result.height).toBe(1); + }); });