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 c969e382cf..fec123a7b6 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 0121ea7d91..44b3fae5c2 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 40401d989d..b567a9f4e0 100644 --- a/packages/core/src/astro/integration/index.ts +++ b/packages/core/src/astro/integration/index.ts @@ -70,26 +70,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. */ @@ -147,6 +142,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`; @@ -402,19 +450,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 00c200a01d..f46165c046 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -164,6 +164,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 ca6676d87b..6fe72e4c72 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -477,6 +477,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