From f78d7fa7b668809d4ada3084797c4911fba63caa Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 19 Jun 2026 20:38:17 +0100 Subject: [PATCH] fix(media): optimize storage-backed images behind Cloudflare Access Wrap Astro's image endpoint so EmDash media bytes are read straight from the storage adapter instead of being fetched over HTTP. The stock endpoint fetches the (absolute) media URL to load the source; on Cloudflare that is a self-subrequest that fails behind Access / global_fetch_strictly_public, 404ing from /_image. The wrapped endpoint matches the internal media route by pathname, reads bytes from storage (no fetch), and transforms them with sharp (Node) or the IMAGES binding (Cloudflare); everything else delegates to the stock endpoint unchanged. On by default where the image service is local; opt out with images: false. --- .../storage-backed-image-optimization.md | 6 + e2e/fixture-cloudflare/emdash-env.d.ts | 4 +- e2e/fixture/emdash-env.d.ts | 6 +- e2e/tests/image-optimization.spec.ts | 39 ++++ packages/cloudflare/package.json | 4 + packages/cloudflare/src/image-endpoint.ts | 109 ++++++++++++ packages/cloudflare/tsdown.config.ts | 1 + packages/core/package.json | 8 + packages/core/src/astro/image-endpoint.ts | 84 +++++++++ packages/core/src/astro/integration/index.ts | 105 ++++++++--- .../core/src/astro/integration/runtime.ts | 11 ++ packages/core/src/astro/middleware.ts | 4 + packages/core/src/media/image-endpoint.ts | 167 ++++++++++++++++++ .../integration/image-remote-patterns.test.ts | 64 ++++++- .../tests/unit/media/image-endpoint.test.ts | 138 +++++++++++++++ packages/core/tsdown.config.ts | 3 + 16 files changed, 727 insertions(+), 26 deletions(-) create mode 100644 .changeset/storage-backed-image-optimization.md create mode 100644 e2e/tests/image-optimization.spec.ts create mode 100644 packages/cloudflare/src/image-endpoint.ts create mode 100644 packages/core/src/astro/image-endpoint.ts create mode 100644 packages/core/src/media/image-endpoint.ts create mode 100644 packages/core/tests/unit/media/image-endpoint.test.ts diff --git a/.changeset/storage-backed-image-optimization.md b/.changeset/storage-backed-image-optimization.md new file mode 100644 index 0000000000..b53d952e45 --- /dev/null +++ b/.changeset/storage-backed-image-optimization.md @@ -0,0 +1,6 @@ +--- +"emdash": minor +"@emdash-cms/cloudflare": minor +--- + +Fixes responsive image optimization for storage-backed media on Cloudflare. EmDash now wraps Astro's image endpoint to read media bytes directly from your storage adapter instead of fetching them over HTTP, so `Image` and Portable Text images generate a real responsive `srcset` even when the site is behind Cloudflare Access (previously these 404'd and fell back to a full-size image). This is on by default and also removes an internal HTTP round-trip on Node. Set `images: false` in your `emdash()` config to leave Astro's image endpoint untouched. diff --git a/e2e/fixture-cloudflare/emdash-env.d.ts b/e2e/fixture-cloudflare/emdash-env.d.ts index eb76158218..f7b5f9e673 100644 --- a/e2e/fixture-cloudflare/emdash-env.d.ts +++ b/e2e/fixture-cloudflare/emdash-env.d.ts @@ -3,7 +3,7 @@ /// -import type { ContentBylineCredit, PortableTextBlock } from "emdash"; +import type { ContentBylineCredit, TaxonomyTerm, PortableTextBlock } from "emdash"; export interface Page { id: string; @@ -15,6 +15,7 @@ export interface Page { updatedAt: Date; publishedAt: Date | null; bylines?: ContentBylineCredit[]; + terms?: Record; } export interface Post { @@ -30,6 +31,7 @@ export interface Post { updatedAt: Date; publishedAt: Date | null; bylines?: ContentBylineCredit[]; + terms?: Record; } declare module "emdash" { diff --git a/e2e/fixture/emdash-env.d.ts b/e2e/fixture/emdash-env.d.ts index 918ed35bd8..f7b5f9e673 100644 --- a/e2e/fixture/emdash-env.d.ts +++ b/e2e/fixture/emdash-env.d.ts @@ -3,7 +3,7 @@ /// -import type { ContentBylineCredit, PortableTextBlock } from "emdash"; +import type { ContentBylineCredit, TaxonomyTerm, PortableTextBlock } from "emdash"; export interface Page { id: string; @@ -15,6 +15,7 @@ export interface Page { updatedAt: Date; publishedAt: Date | null; bylines?: ContentBylineCredit[]; + terms?: Record; } export interface Post { @@ -22,7 +23,7 @@ export interface Post { slug: string | null; status: string; title: string; - featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number }; + featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record }; body?: PortableTextBlock[]; excerpt?: string; theme_color?: string; @@ -30,6 +31,7 @@ export interface Post { updatedAt: Date; publishedAt: Date | null; bylines?: ContentBylineCredit[]; + terms?: Record; } declare module "emdash" { diff --git a/e2e/tests/image-optimization.spec.ts b/e2e/tests/image-optimization.spec.ts new file mode 100644 index 0000000000..07060bbae3 --- /dev/null +++ b/e2e/tests/image-optimization.spec.ts @@ -0,0 +1,39 @@ +/** + * Image optimization E2E. + * + * The seed creates a published "Post With Image" whose Portable Text body has an + * image block (rendered by EmDashImage at /posts/post-with-image). This asserts + * the image flows through Astro's image pipeline (a `/_image` src) and that the + * wrapped endpoint serves real image bytes from storage -- not a redirect or + * 404. On the Cloudflare target this exercises the storage-backed endpoint that + * makes optimization work without an HTTP fetch of the media URL. + */ + +import { test, expect } from "../fixtures"; + +test.describe("image optimization", () => { + test("renders an optimized image served by the wrapped image endpoint", async ({ + page, + request, + }) => { + const img = page.locator("figure.emdash-image img").first(); + + // The workerd dev runner's Vite dep optimizer can transiently 500 a cold + // route even after warm-up; reload until the page renders. (Dev-only; the + // deployed Worker has no optimizer.) + for (let attempt = 0; attempt < 5; attempt++) { + await page.goto("/posts/post-with-image"); + if (await img.isVisible().catch(() => false)) break; + await page.waitForTimeout(1000); + } + await expect(img).toBeVisible(); + + const src = await img.getAttribute("src"); + expect(src, "image src should be optimized via Astro's image endpoint").toContain("/_image"); + + // The optimized URL must return real image bytes, not an Access redirect or 404. + const res = await request.get(src!); + expect(res.status()).toBe(200); + expect(res.headers()["content-type"]).toMatch(/^image\//); + }); +}); diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index fd3e809491..86d8c12dad 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -37,6 +37,10 @@ "types": "./dist/storage/r2.d.mts", "default": "./dist/storage/r2.mjs" }, + "./image-endpoint": { + "types": "./dist/image-endpoint.d.mts", + "default": "./dist/image-endpoint.mjs" + }, "./auth": { "types": "./dist/auth/index.d.mts", "default": "./dist/auth/index.mjs" diff --git a/packages/cloudflare/src/image-endpoint.ts b/packages/cloudflare/src/image-endpoint.ts new file mode 100644 index 0000000000..441ecd8d49 --- /dev/null +++ b/packages/cloudflare/src/image-endpoint.ts @@ -0,0 +1,109 @@ +/** + * Cloudflare image endpoint -- the `image.endpoint` EmDash installs under the + * Cloudflare adapter. + * + * For an EmDash media URL it reads the source bytes straight from the storage + * adapter (the R2 binding) and resizes them with the Cloudflare `IMAGES` + * binding -- no HTTP fetch, so it works behind Cloudflare Access and with + * `global_fetch_strictly_public`. Every other image is delegated to the + * adapter's stock transform endpoint unchanged (bundled assets via the `ASSETS` + * binding, allowed-remote via fetch). + */ + +// @astrojs/cloudflare's binding-mode transform endpoint; resolved in the consumer. +import { GET as adapterGET } from "@astrojs/cloudflare/image-transform-endpoint"; +import type { APIRoute } from "astro"; +import { env } from "cloudflare:workers"; +import type { Storage } from "emdash"; +import { + IMMUTABLE_IMAGE_CACHE, + matchInternalMediaKey, + originalMediaHeaders, + parseTransformParams, + type ImageTransformFormat, +} from "emdash/media/image-endpoint"; + +export const prerender = false; + +const FORMAT_MIME: Record = { + webp: "image/webp", + avif: "image/avif", + jpeg: "image/jpeg", + png: "image/png", +}; + +/** Resolve the Images binding by the name the Cloudflare adapter configured. */ +function resolveImagesBinding(): ImagesBinding | undefined { + const configured = (globalThis as { __ASTRO_IMAGES_BINDING_NAME?: unknown }) + .__ASTRO_IMAGES_BINDING_NAME; + const name = typeof configured === "string" && configured ? configured : "IMAGES"; + // env from cloudflare:workers has no index signature, so a cast is needed. + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- Images binding accessed from untyped env object + return (env as Record)[name] as ImagesBinding | undefined; +} + +function streamOriginal(body: ReadableStream, contentType: string): Response { + return new Response(body, { status: 200, headers: originalMediaHeaders(contentType) }); +} + +function isNotFound(error: unknown): boolean { + return ( + error instanceof Error && + (error.message.includes("not found") || error.message.includes("NOT_FOUND")) + ); +} + +export const GET: APIRoute = async (ctx) => { + const url = new URL(ctx.request.url); + const key = matchInternalMediaKey(url.searchParams.get("href")); + // App.Locals.emdash is augmented by `emdash/locals`, not loaded in this + // package's compilation; narrow to the field we need. + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- App.Locals augmentation lives in the emdash package + const storage = (ctx.locals as { emdash?: { storage?: Storage | null } }).emdash?.storage; + + // 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); + + try { + const source = await storage.download(key); + + // Only raster images are transformable; serve anything else unchanged. + if (!source.contentType.startsWith("image/")) { + return streamOriginal(source.body, source.contentType); + } + + const images = resolveImagesBinding(); + const parsed = parseTransformParams(url.searchParams); + + // No binding or unparseable params: serve the original so the URL resolves. + if (!images || !parsed.ok) { + return streamOriginal(source.body, source.contentType); + } + + const { width, height, format, quality } = parsed.options; + const outputMime = FORMAT_MIME[format] ?? "image/webp"; + const transform: ImageTransform = {}; + if (width) transform.width = width; + if (height) transform.height = height; + const output: ImageOutputOptions = { format: outputMime }; + if (quality) output.quality = quality; + + const result = await images.input(source.body).transform(transform).output(output); + const response = result.response(); + if (!response.body) return new Response(null, { status: 500 }); + + return new Response(response.body, { + status: 200, + headers: { + "Content-Type": response.headers.get("Content-Type") ?? outputMime, + "Cache-Control": IMMUTABLE_IMAGE_CACHE, + "X-Content-Type-Options": "nosniff", + }, + }); + } catch (error) { + if (isNotFound(error)) return new Response("Not Found", { status: 404 }); + console.error("[emdash] image transform failed:", error); + return new Response("Internal Server Error", { status: 500 }); + } +}; diff --git a/packages/cloudflare/tsdown.config.ts b/packages/cloudflare/tsdown.config.ts index dcaa527a8a..58718c12bc 100644 --- a/packages/cloudflare/tsdown.config.ts +++ b/packages/cloudflare/tsdown.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ "src/db/playground.ts", "src/db/playground-middleware.ts", "src/storage/r2.ts", + "src/image-endpoint.ts", "src/auth/index.ts", "src/sandbox/index.ts", "src/worker.ts", diff --git a/packages/core/package.json b/packages/core/package.json index dfa033ba81..fd28b89a70 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -106,6 +106,14 @@ "types": "./dist/media/local-runtime.d.mts", "default": "./dist/media/local-runtime.mjs" }, + "./media/image-endpoint": { + "types": "./dist/media/image-endpoint.d.mts", + "default": "./dist/media/image-endpoint.mjs" + }, + "./image-endpoint": { + "types": "./dist/astro/image-endpoint.d.mts", + "default": "./dist/astro/image-endpoint.mjs" + }, "./runtime": { "types": "./dist/runtime.d.mts", "default": "./dist/runtime.mjs" diff --git a/packages/core/src/astro/image-endpoint.ts b/packages/core/src/astro/image-endpoint.ts new file mode 100644 index 0000000000..21763c800e --- /dev/null +++ b/packages/core/src/astro/image-endpoint.ts @@ -0,0 +1,84 @@ +/** + * Node image endpoint -- the `image.endpoint` EmDash installs on non-Cloudflare + * platforms whose image service is local (sharp). + * + * 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. + */ + +import type { APIRoute } from "astro"; +// @ts-ignore - astro/assets internal endpoint, resolved by the consumer's Astro build +import { GET as genericGET } from "astro/assets/endpoint/generic"; +// @ts-ignore - astro:assets is resolved by the consumer's Astro build +import { getConfiguredImageService, imageConfig } from "astro:assets"; + +import { + IMMUTABLE_IMAGE_CACHE, + matchInternalMediaKey, + originalMediaHeaders, +} from "../media/image-endpoint.js"; + +export const prerender = false; + +const FORMAT_MIME: Record = { + webp: "image/webp", + avif: "image/avif", + png: "image/png", + jpeg: "image/jpeg", + jpg: "image/jpeg", + gif: "image/gif", +}; + +function isNotFound(error: unknown): boolean { + return ( + error instanceof Error && + (error.message.includes("not found") || error.message.includes("NOT_FOUND")) + ); +} + +function streamOriginal(body: ReadableStream, contentType: string): Response { + return new Response(body, { status: 200, headers: originalMediaHeaders(contentType) }); +} + +export const GET: APIRoute = async (ctx) => { + const url = new URL(ctx.request.url); + const key = matchInternalMediaKey(url.searchParams.get("href")); + const storage = ctx.locals.emdash?.storage; + + // Not EmDash media, or storage unavailable: let the stock endpoint handle it + // (bundled assets, allowed remote, `publicUrl` media). + if (!key || !storage) return genericGET(ctx); + + const service = await getConfiguredImageService(); + if (!("transform" in service)) return genericGET(ctx); + + try { + const source = await storage.download(key); + + // Only raster images are transformable; serve anything else unchanged. + if (!source.contentType.startsWith("image/")) { + return streamOriginal(source.body, source.contentType); + } + + const transform = await service.parseURL(url, imageConfig); + if (!transform) return streamOriginal(source.body, source.contentType); + + const inputBuffer = new Uint8Array(await new Response(source.body).arrayBuffer()); + const { data, format } = await service.transform(inputBuffer, transform, imageConfig); + + return new Response(data, { + status: 200, + headers: { + "Content-Type": FORMAT_MIME[format] ?? source.contentType, + "Cache-Control": IMMUTABLE_IMAGE_CACHE, + "X-Content-Type-Options": "nosniff", + }, + }); + } catch (error) { + if (isNotFound(error)) return new Response("Not Found", { status: 404 }); + console.error("[emdash] image transform failed:", error); + return new Response("Internal Server Error", { status: 500 }); + } +}; diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts index fc1336425c..cde589fc8d 100644 --- a/packages/core/src/astro/integration/index.ts +++ b/packages/core/src/astro/integration/index.ts @@ -69,26 +69,21 @@ interface ImageRemotePattern { /** * Build `image.remotePatterns` entries so Astro will optimize EmDash media. * - * Astro's image services only transform **absolute** URLs whose host is - * authorized; everything else is passed through unoptimized. We authorize the - * media sources automatically: + * Astro's image services only build a transform URL for sources allowed via + * `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), so - * media served directly from a public bucket is optimized. + * 1. The storage adapter's public URL host (R2 custom domain, S3/CDN). * 2. The site's own origin, scoped to the media proxy route - * (`/_emdash/api/media/file/**`), so same-origin proxied media (local - * storage, or R2 without a public URL) is optimized too. The pathname - * scope keeps Astro's image endpoint from acting as an open proxy for the - * whole origin. Only registered when `siteUrl` is known at build time; - * `getPublicOrigin` resolves the matching origin at render time. - * 3. In `astro dev` the dev-server origin (`localhost:`) isn't known at - * build time, so we register a host-agnostic pattern scoped to the media - * route. This is dev-only — it never ships in a production build — so the - * missing host check can't be abused on a deployed site. + * (`/_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. + * 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. * - * Returns an empty array when no source is statically known (e.g. a production - * build using local storage with no `siteUrl`), in which case media renders as - * a plain ``. + * Returns an empty array when no source is statically known (production build, + * local storage, no `siteUrl`), in which case media renders as a plain ``. * * @internal Exported for unit testing. */ @@ -146,6 +141,59 @@ export function buildImageRemotePatterns( return patterns; } +/** + * Stock image endpoints EmDash may safely replace with its storage-backed + * wrapper. Our wrapper delegates non-EmDash images to the platform's transform + * endpoint, so we only override endpoints whose transform we can delegate to. + */ +const OVERRIDABLE_IMAGE_ENDPOINTS = new Set([ + "astro/assets/endpoint/generic", + "astro/assets/endpoint/node", + "astro/assets/endpoint/dev", + "@astrojs/cloudflare/image-transform-endpoint", +]); + +/** + * Stock endpoints that deliberately don't transform (the user opted into + * passthrough). We leave these untouched -- and without a warning, since it's a + * supported choice, not a custom endpoint. Overriding would route non-EmDash + * images through a transformer the passthrough setup doesn't provide. + */ +const PASSTHROUGH_IMAGE_ENDPOINTS = new Set(["@astrojs/cloudflare/image-passthrough-endpoint"]); + +/** + * 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. + * + * Returns `{ entrypoint }` to install, `{ warn }` to skip with a warning (a + * custom endpoint we can't delegate to), or `{}` to skip silently (opted out). + * + * @internal Exported for unit testing. + */ +export function resolveImageEndpoint(opts: { + imagesDisabled: boolean; + currentEntrypoint: string | undefined; + isCloudflare: boolean; +}): { entrypoint?: string; warn?: string } { + if (opts.imagesDisabled) return {}; + const current = opts.currentEntrypoint; + if (current === undefined || OVERRIDABLE_IMAGE_ENDPOINTS.has(current)) { + return { + entrypoint: opts.isCloudflare + ? "@emdash-cms/cloudflare/image-endpoint" + : "emdash/image-endpoint", + }; + } + // A deliberate passthrough setup: leave it alone, no warning. + if (PASSTHROUGH_IMAGE_ENDPOINTS.has(current)) return {}; + return { + warn: + `A custom image.endpoint (${current}) is configured; EmDash will not wrap ` + + `it, so storage-backed media may render unoptimized.`, + }; +} + // Terminal formatting const dim = (s: string) => `\x1b[2m${s}\x1b[22m`; const bold = (s: string) => `\x1b[1m${s}\x1b[22m`; @@ -385,19 +433,32 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration { }, ]; - // Authorize media sources for Astro image optimization so the - // Image components can generate a responsive srcset for R2/S3 and - // same-origin proxied media. `updateConfig` merges arrays, so any - // user-configured remotePatterns are preserved. + // Authorize media sources so Astro's image service builds transform + // URLs for them (it won't optimize an un-allowed source). `updateConfig` + // merges arrays, so user-configured remotePatterns are preserved. const imageRemotePatterns = buildImageRemotePatterns( resolvedConfig.storage, resolvedConfig.siteUrl, 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. + const { entrypoint: imageEndpoint, warn: imageEndpointWarning } = resolveImageEndpoint({ + imagesDisabled: resolvedConfig.images === false, + currentEntrypoint: astroConfig.image?.endpoint?.entrypoint, + isCloudflare: astroConfig.adapter?.name === "@astrojs/cloudflare", + }); + if (imageEndpointWarning) logger.warn(imageEndpointWarning); + + const imageConfig: Record = {}; + if (imageRemotePatterns.length) imageConfig.remotePatterns = imageRemotePatterns; + if (imageEndpoint) imageConfig.endpoint = { entrypoint: imageEndpoint }; + updateConfig({ security: securityConfig, - ...(imageRemotePatterns.length ? { image: { remotePatterns: imageRemotePatterns } } : {}), + ...(Object.keys(imageConfig).length ? { image: imageConfig } : {}), // fonts is a valid AstroConfig key but may not be in the // type definition for the minimum supported Astro version ...({ fonts: emdashFonts } as Record), diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index 61d504a9c2..d5f0a59691 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -151,6 +151,17 @@ export interface EmDashConfig { * Storage configuration (for media) */ storage?: StorageDescriptor; + /** + * 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. + */ + images?: boolean; /** * Trusted plugins to load (run in main isolate) * diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts index bc467b1f85..52886c7889 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -475,6 +475,10 @@ export const onRequest = defineMiddleware(async (context, next) => { collectPageMetadata: runtime.collectPageMetadata.bind(runtime), 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. + storage: runtime.storage, } as EmDashHandlers; } catch { // Non-fatal — EmDashHead will fall back to base SEO contributions diff --git a/packages/core/src/media/image-endpoint.ts b/packages/core/src/media/image-endpoint.ts new file mode 100644 index 0000000000..69d299565e --- /dev/null +++ b/packages/core/src/media/image-endpoint.ts @@ -0,0 +1,167 @@ +/** + * 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. + * + * Kept free of `astro:*` / `virtual:emdash/*` imports so it stays in the + * precompiled package and can be unit-tested directly. + */ + +import { INTERNAL_MEDIA_PREFIX } from "./normalize.js"; + +/** Output formats the wrapped endpoint can produce on Cloudflare. */ +export const ALLOWED_TRANSFORM_FORMATS = ["webp", "avif", "jpeg", "png"] as const; + +/** Default output format -- broad support, strong compression. */ +export const DEFAULT_TRANSFORM_FORMAT: ImageTransformFormat = "webp"; + +/** Upper bound for a requested dimension; caps the work a single request asks for. */ +export const MAX_TRANSFORM_DIMENSION = 4000; + +/** A format string accepted by {@link ImageTransformOptions.format}. */ +export type ImageTransformFormat = (typeof ALLOWED_TRANSFORM_FORMATS)[number]; + +/** Validated options for a single transform. */ +export interface ImageTransformOptions { + width?: number; + height?: number; + format: ImageTransformFormat; + quality?: number; +} + +/** Long-lived immutable cache -- transform output is deterministic per key+params. */ +export const IMMUTABLE_IMAGE_CACHE = "public, max-age=31536000, immutable"; + +/** + * Raster types safe to render inline. Anything else (SVG, PDF, ...) is served + * as an attachment so it can't execute as an active document. Mirrors the + * `/_emdash/api/media/file/{key}` route's allowlist. + */ +const SAFE_INLINE_IMAGE_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/avif", + "image/x-icon", +]); + +/** + * Headers for streaming **original** stored bytes (the no-transform fallback). + * Carries the same stored-XSS protections as the media file route: a sandbox + * CSP, `nosniff`, and `Content-Disposition: attachment` for anything not on the + * inline raster allowlist (so a stored SVG can't run scripts in the site + * origin). Transformed output is always generated raster and doesn't need this. + */ +export function originalMediaHeaders(contentType: string): Record { + return { + "Content-Type": contentType, + "Cache-Control": IMMUTABLE_IMAGE_CACHE, + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": + "sandbox; default-src 'none'; img-src 'self'; style-src 'unsafe-inline'", + "Content-Disposition": SAFE_INLINE_IMAGE_TYPES.has(contentType) ? "inline" : "attachment", + }; +} + +/** Storage keys safe to serve: the flat `{ulid}{ext}` shape, no slashes/traversal. */ +const SAFE_STORAGE_KEY = /^[A-Za-z0-9._-]+$/; + +/** Plain decimal digits only -- rejects "1e3", "0x10", "+5", whitespace. */ +const DECIMAL_DIGITS = /^\d+$/; + +/** Whether a storage key is safe to resolve against the storage backend. */ +export function isSafeTransformKey(key: string): boolean { + return SAFE_STORAGE_KEY.test(key); +} + +/** + * If `href` points at the internal EmDash media route + * (`/_emdash/api/media/file/{key}`) with a safe key, return the key; otherwise + * `null` (the endpoint then delegates to the stock image endpoint for bundled + * assets, allowed remote, and `publicUrl` media). + * + * The component absolutizes same-origin media (Astro only optimizes absolute, + * remote-allowed URLs), so `href` is typically `https://site/_emdash/...` but + * may be relative. We match on the **pathname** only and never fetch `href` — + * the key is read from our own storage — so the host is irrelevant and can't be + * an SSRF vector. A dummy base resolves both absolute and relative forms and + * strips any query/fragment. + */ +export function matchInternalMediaKey(href: string | null | undefined): string | null { + if (!href) return null; + let pathname: string; + try { + pathname = new URL(href, "http://localhost").pathname; + } catch { + return null; + } + if (!pathname.startsWith(INTERNAL_MEDIA_PREFIX)) return null; + const key = pathname.slice(INTERNAL_MEDIA_PREFIX.length); + if (!key || !isSafeTransformKey(key)) return null; + return key; +} + +/** Type guard for {@link ImageTransformFormat}. */ +export function isTransformFormat(value: string): value is ImageTransformFormat { + return (ALLOWED_TRANSFORM_FORMATS as readonly string[]).includes(value); +} + +/** Outcome of parsing transform query params: validated options or an error. */ +export type ParsedTransformParams = + | { ok: true; options: ImageTransformOptions } + | { ok: false; message: string }; + +/** + * Parse and validate `?w=&h=&f=&q=` query params. Width is required (it sizes + * the rendition); dimensions are bounded so a request can't ask for an + * unbounded or nonsensical transform. + */ +export function parseTransformParams(params: URLSearchParams): ParsedTransformParams { + const width = parseDimension(params.get("w")); + if (width === null) return { ok: false, message: "Invalid 'w' (width)" }; + if (width === undefined) return { ok: false, message: "Missing 'w' (width)" }; + + const height = parseDimension(params.get("h")); + if (height === null) return { ok: false, message: "Invalid 'h' (height)" }; + + const formatRaw = params.get("f"); + let format: ImageTransformFormat = DEFAULT_TRANSFORM_FORMAT; + if (formatRaw !== null) { + if (!isTransformFormat(formatRaw)) { + return { ok: false, message: `Unsupported 'f' (format): ${formatRaw}` }; + } + format = formatRaw; + } + + const qualityRaw = params.get("q"); + let quality: number | undefined; + if (qualityRaw !== null) { + const q = Number(qualityRaw); + if (!Number.isInteger(q) || q < 1 || q > 100) { + return { ok: false, message: "Invalid 'q' (quality), expected 1-100" }; + } + quality = q; + } + + return { ok: true, options: { width, height, format, quality } }; +} + +/** + * Parse a dimension query value. + * - `undefined`: param absent + * - `null`: present but invalid (non-integer, out of range) + * - `number`: valid, within [1, MAX_TRANSFORM_DIMENSION] + */ +function parseDimension(raw: string | null): number | undefined | null { + if (raw === null) return undefined; + if (!DECIMAL_DIGITS.test(raw)) return null; + const n = Number(raw); + if (n < 1 || n > MAX_TRANSFORM_DIMENSION) return null; + return n; +} diff --git a/packages/core/tests/unit/astro/integration/image-remote-patterns.test.ts b/packages/core/tests/unit/astro/integration/image-remote-patterns.test.ts index 8f407df148..6fd165cd66 100644 --- a/packages/core/tests/unit/astro/integration/image-remote-patterns.test.ts +++ b/packages/core/tests/unit/astro/integration/image-remote-patterns.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { buildImageRemotePatterns } from "../../../../src/astro/integration/index.js"; +import { + buildImageRemotePatterns, + resolveImageEndpoint, +} from "../../../../src/astro/integration/index.js"; const s3 = (publicUrl?: string) => ({ entrypoint: "x", config: { publicUrl } }); const localStorage = { entrypoint: "x", config: { directory: "./uploads" } }; @@ -56,3 +59,62 @@ describe("buildImageRemotePatterns", () => { ]); }); }); + +describe("resolveImageEndpoint", () => { + it("installs the Node endpoint on a stock/undefined endpoint", () => { + expect( + resolveImageEndpoint({ + imagesDisabled: false, + currentEntrypoint: undefined, + isCloudflare: false, + }), + ).toEqual({ entrypoint: "emdash/image-endpoint" }); + expect( + resolveImageEndpoint({ + imagesDisabled: false, + currentEntrypoint: "astro/assets/endpoint/generic", + isCloudflare: false, + }), + ).toEqual({ entrypoint: "emdash/image-endpoint" }); + }); + + it("installs the Cloudflare endpoint under the Cloudflare adapter", () => { + expect( + resolveImageEndpoint({ + imagesDisabled: false, + currentEntrypoint: "@astrojs/cloudflare/image-transform-endpoint", + isCloudflare: true, + }), + ).toEqual({ entrypoint: "@emdash-cms/cloudflare/image-endpoint" }); + }); + + it("skips silently when images are disabled", () => { + expect( + resolveImageEndpoint({ + imagesDisabled: true, + currentEntrypoint: undefined, + isCloudflare: true, + }), + ).toEqual({}); + }); + + it("leaves a deliberate passthrough endpoint alone without warning", () => { + expect( + resolveImageEndpoint({ + imagesDisabled: false, + currentEntrypoint: "@astrojs/cloudflare/image-passthrough-endpoint", + isCloudflare: true, + }), + ).toEqual({}); + }); + + it("warns and skips when a custom endpoint is configured", () => { + const result = resolveImageEndpoint({ + imagesDisabled: false, + currentEntrypoint: "./src/my-endpoint.ts", + isCloudflare: false, + }); + expect(result.entrypoint).toBeUndefined(); + expect(result.warn).toMatch(/custom image\.endpoint/); + }); +}); diff --git a/packages/core/tests/unit/media/image-endpoint.test.ts b/packages/core/tests/unit/media/image-endpoint.test.ts new file mode 100644 index 0000000000..ac389a8211 --- /dev/null +++ b/packages/core/tests/unit/media/image-endpoint.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from "vitest"; + +import { + matchInternalMediaKey, + isSafeTransformKey, + parseTransformParams, + isTransformFormat, + originalMediaHeaders, + MAX_TRANSFORM_DIMENSION, +} from "../../../src/media/image-endpoint.js"; + +describe("matchInternalMediaKey", () => { + it("extracts the key from a relative internal media URL", () => { + expect(matchInternalMediaKey("/_emdash/api/media/file/01J5ABC.webp")).toBe("01J5ABC.webp"); + }); + + it("extracts the key from an absolute internal media URL (the component absolutizes)", () => { + expect(matchInternalMediaKey("https://example.com/_emdash/api/media/file/01J5ABC.webp")).toBe( + "01J5ABC.webp", + ); + // Host-agnostic: the key is read from our own storage, href is never fetched. + expect(matchInternalMediaKey("http://localhost:4444/_emdash/api/media/file/a-b_c.png")).toBe( + "a-b_c.png", + ); + }); + + it("ignores any query string or fragment on the URL", () => { + expect(matchInternalMediaKey("/_emdash/api/media/file/x.jpg?foo=1")).toBe("x.jpg"); + expect(matchInternalMediaKey("https://example.com/_emdash/api/media/file/x.jpg#f")).toBe( + "x.jpg", + ); + }); + + it("returns null for non-internal URLs", () => { + expect(matchInternalMediaKey("/_astro/bundled.abc.png")).toBeNull(); + expect(matchInternalMediaKey("https://cdn.example.com/x.jpg")).toBeNull(); + expect(matchInternalMediaKey("/images/foo.jpg")).toBeNull(); + }); + + it("returns null for empty/missing href", () => { + expect(matchInternalMediaKey(null)).toBeNull(); + expect(matchInternalMediaKey(undefined)).toBeNull(); + expect(matchInternalMediaKey("")).toBeNull(); + expect(matchInternalMediaKey("/_emdash/api/media/file/")).toBeNull(); + }); + + it("rejects traversal and unsafe key characters", () => { + // ".." collapses the path back out of the media prefix + expect(matchInternalMediaKey("/_emdash/api/media/file/../secret")).toBeNull(); + // a slash in the key (sub-path) is not the flat storage-key shape + expect(matchInternalMediaKey("/_emdash/api/media/file/a/b.jpg")).toBeNull(); + // percent-encoding is rejected by the safe-key charset + expect(matchInternalMediaKey("/_emdash/api/media/file/x%2e%2e")).toBeNull(); + }); +}); + +describe("isSafeTransformKey", () => { + it("accepts flat ulid+ext keys", () => { + expect(isSafeTransformKey("01J5ABC.webp")).toBe(true); + expect(isSafeTransformKey("a-b_c.1.png")).toBe(true); + }); + + it("rejects slashes, query chars, and whitespace", () => { + expect(isSafeTransformKey("a/b")).toBe(false); + expect(isSafeTransformKey("../secret")).toBe(false); + expect(isSafeTransformKey("x?y")).toBe(false); + expect(isSafeTransformKey("x y")).toBe(false); + }); +}); + +describe("isTransformFormat", () => { + it("accepts supported formats and rejects others", () => { + expect(isTransformFormat("webp")).toBe(true); + expect(isTransformFormat("avif")).toBe(true); + expect(isTransformFormat("gif")).toBe(false); + expect(isTransformFormat("svg")).toBe(false); + }); +}); + +describe("parseTransformParams", () => { + const parse = (qs: string) => parseTransformParams(new URLSearchParams(qs)); + + it("requires a width", () => { + const r = parse("h=200"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.message).toMatch(/width/i); + }); + + it("parses width, height, format, quality", () => { + const r = parse("w=640&h=480&f=avif&q=80"); + expect(r).toEqual({ + ok: true, + options: { width: 640, height: 480, format: "avif", quality: 80 }, + }); + }); + + it("defaults format to webp and leaves height/quality undefined", () => { + const r = parse("w=800"); + expect(r).toEqual({ ok: true, options: { width: 800, format: "webp" } }); + }); + + it("rejects out-of-range and non-integer dimensions", () => { + expect(parse("w=0").ok).toBe(false); + expect(parse(`w=${MAX_TRANSFORM_DIMENSION + 1}`).ok).toBe(false); + expect(parse("w=12.5").ok).toBe(false); + expect(parse("w=640&h=-1").ok).toBe(false); + }); + + it("rejects unsupported format and bad quality", () => { + expect(parse("w=640&f=gif").ok).toBe(false); + expect(parse("w=640&q=0").ok).toBe(false); + expect(parse("w=640&q=101").ok).toBe(false); + expect(parse("w=640&q=foo").ok).toBe(false); + }); + + it("rejects exotic numeric encodings for dimensions", () => { + expect(parse("w=1e3").ok).toBe(false); + expect(parse("w=0x10").ok).toBe(false); + expect(parse("w=+5").ok).toBe(false); + expect(parse("w= 5 ").ok).toBe(false); + }); +}); + +describe("originalMediaHeaders", () => { + it("renders safe raster types inline with a sandbox CSP", () => { + const h = originalMediaHeaders("image/png"); + expect(h["Content-Type"]).toBe("image/png"); + expect(h["Content-Disposition"]).toBe("inline"); + expect(h["X-Content-Type-Options"]).toBe("nosniff"); + expect(h["Content-Security-Policy"]).toContain("sandbox"); + }); + + it("forces attachment + sandbox for SVG and other active types", () => { + expect(originalMediaHeaders("image/svg+xml")["Content-Disposition"]).toBe("attachment"); + expect(originalMediaHeaders("application/pdf")["Content-Disposition"]).toBe("attachment"); + expect(originalMediaHeaders("image/svg+xml")["Content-Security-Policy"]).toContain("sandbox"); + }); +}); diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index fe677f4063..7c85123988 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -94,6 +94,9 @@ export default defineConfig({ // Media providers "src/media/index.ts", "src/media/local-runtime.ts", + // Image-endpoint helpers (portable) + the Node image endpoint + "src/media/image-endpoint.ts", + "src/astro/image-endpoint.ts", // Runtime exports (depends on virtual modules - for live.config.ts) "src/runtime.ts", // Seed engine