Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-rivers-bloom.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": minor
---

Renders local media through storage `publicUrl` when configured. `EmDashImage` and the Portable Text image block now call a new `locals.emdash.getPublicMediaUrl()` helper, so R2 and S3 deployments with a custom domain serve images from that domain. `S3Storage.getPublicUrl` now returns the `/_emdash/api/media/file/{key}` path when no `publicUrl` is set (previously `{endpoint}/{bucket}/{key}`).
5 changes: 4 additions & 1 deletion packages/core/src/astro/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
} from "../emdash-runtime.js";
import { setI18nConfig } from "../i18n/config.js";
import type { Database, Storage } from "../index.js";
import { createPublicMediaUrlResolver } from "../media/url.js";
import type { SandboxRunner } from "../plugins/sandbox/types.js";
import type { ResolvedPlugin } from "../plugins/types.js";
import { getRequestContext, runWithContext } from "../request-context.js";
Expand Down Expand Up @@ -301,10 +302,11 @@ export const onRequest = defineMiddleware(async (context, next) => {
try {
const runtime = await getRuntime(config, initSubTimings);
setupVerified = true;
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- partial object; getPageRuntime() only checks for these two methods
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- partial object; getPageRuntime() only checks for the page-contribution methods
locals.emdash = {
collectPageMetadata: runtime.collectPageMetadata.bind(runtime),
collectPageFragments: runtime.collectPageFragments.bind(runtime),
getPublicMediaUrl: createPublicMediaUrlResolver(runtime.storage),
} as EmDashHandlers;
} catch {
// Non-fatal — EmDashHead will fall back to base SEO contributions
Expand Down Expand Up @@ -445,6 +447,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
// Direct access (for advanced use cases)
storage: runtime.storage,
db: runtime.db,
getPublicMediaUrl: createPublicMediaUrlResolver(runtime.storage),
hooks: runtime.hooks,
email: runtime.email,
configuredPlugins: runtime.configuredPlugins,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/astro/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ export interface EmDashHandlers {
// Direct access to storage and database for advanced use cases
storage: import("../index.js").Storage | null;
db: Kysely<import("../index.js").Database>;
getPublicMediaUrl?: (storageKey: string) => string;

// Hook pipeline for plugin integrations
hooks: import("../plugins/hooks.js").HookPipeline;
Expand Down
13 changes: 7 additions & 6 deletions packages/core/src/components/EmDashImage.astro
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type { MediaValue } from "../fields/types.js";
import type { HTMLAttributes } from "astro/types";
import type { ImageEmbed } from "../media/types.js";
import { getMediaProvider } from "../media/provider-loader.js";
import { buildRenderMediaUrl } from "../media/url.js";
// Standard responsive breakpoints
const BREAKPOINTS = [640, 750, 828, 960, 1080, 1280, 1600, 1920];

Expand Down Expand Up @@ -53,14 +54,14 @@ function normalizeImage(
}

/**
* Build the URL for a local image
* Build the URL for a local image. Prefers `meta.storageKey`; falls back to
* the internal proxy with `img.id` when no storage key is available.
*/
function buildLocalImageUrl(img: MediaValue): string {
const storageKey = (img.meta?.storageKey as string) || img.id;
if (storageKey) {
return `/_emdash/api/media/file/${storageKey}`;
}
return "";
return buildRenderMediaUrl(Astro.locals.emdash?.getPublicMediaUrl, {
storageKey: img.meta?.storageKey as string | undefined,
id: img.id,
});
}

/**
Expand Down
8 changes: 5 additions & 3 deletions packages/core/src/components/Gallery.astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Uses Astro's Image component for optimization when dimensions are available.
*/
import { Image as AstroImage } from "astro:assets";
import { buildRenderMediaUrl } from "../media/url.js";

export interface Props {
node: {
Expand Down Expand Up @@ -39,9 +40,10 @@ if (!images.length) {
<div class="emdash-gallery" style={`--columns: ${columns}`}>
{
images.map((image) => {
const src =
image.asset.url ||
`/_emdash/api/media/file/${image.asset._ref}`;
const src = buildRenderMediaUrl(Astro.locals.emdash?.getPublicMediaUrl, {
url: image.asset.url,
id: image.asset._ref,
});
const hasSize = image.width && image.height;
return (
<figure class="emdash-gallery-item">
Expand Down
11 changes: 8 additions & 3 deletions packages/core/src/components/Image.astro
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/
import type { ImageEmbed } from "../media/types.js";
import { getMediaProvider } from "../media/provider-loader.js";
import { buildRenderMediaUrl } from "../media/url.js";
// Standard responsive breakpoints
const BREAKPOINTS = [640, 750, 828, 960, 1080, 1280, 1600, 1920];

Expand Down Expand Up @@ -122,10 +123,14 @@ if (providerId && providerId !== "local") {
}
}

// Fallback for local provider — prefer stored URL (includes storage key with extension),
// fall back to _ref (bare ULID, works if media file endpoint supports ID lookup)
// Fallback for local provider. `asset.url` carries the storage key with
// extension when present; `asset._ref` is a bare ULID that only the internal
// `/file/{id}` route can resolve. `buildRenderMediaUrl` picks the right shape.
if (!src) {
src = asset.url || `/_emdash/api/media/file/${asset._ref}`;
src = buildRenderMediaUrl(Astro.locals.emdash?.getPublicMediaUrl, {
url: asset.url,
id: asset._ref,
});
}

// Build placeholder background style
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/media/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import type { MediaProvider, MediaProviderItem, MediaValue } from "./types.js";

const INTERNAL_MEDIA_PREFIX = "/_emdash/api/media/file/";
export const INTERNAL_MEDIA_PREFIX = "/_emdash/api/media/file/";
const URL_PATTERN = /^https?:\/\//;

/**
Expand Down
78 changes: 78 additions & 0 deletions packages/core/src/media/url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Public media URL resolution.
*
* Used at render time by the Image components to decide whether a storage
* key should be served from the configured `publicUrl` (R2 custom domain,
* S3 CDN) or through the internal `/_emdash/api/media/file/{key}` route.
*/
import type { Storage } from "../storage/types.js";
import { INTERNAL_MEDIA_PREFIX } from "./normalize.js";

// Keys accepted by the public-URL rewrite: the `{ulid}{ext}` shape produced by
// the upload pipeline, with letters, digits, dots, dashes, and underscores.
// Slashes, `?`, `#`, and `%` are rejected so attacker-controlled content in a
// portable-text `asset.url` cannot traverse or reroute on the CDN origin.
const SAFE_STORAGE_KEY = /^[A-Za-z0-9._-]+$/;

/**
* Resolve the public URL for a locally stored media key. Returns an empty
* string when no key is given. When a storage adapter is supplied, defers to
* `storage.getPublicUrl()`; otherwise returns the internal proxy route.
*/
export function resolvePublicMediaUrl(
storage: Storage | null | undefined,
storageKey: string,
): string {
if (!storageKey) return "";
if (storage) return storage.getPublicUrl(storageKey);
return `/_emdash/api/media/file/${storageKey}`;
}

/**
* Build the `getPublicMediaUrl` closure attached to `Astro.locals.emdash`.
* Shared by the anonymous fast path and the full-runtime path in middleware.
*
* @internal
*/
export function createPublicMediaUrlResolver(
storage: Storage | null | undefined,
): (key: string) => string {
return (key) => resolvePublicMediaUrl(storage, key);
}

/** Input shape for {@link buildRenderMediaUrl}. */
export interface RenderMediaRef {
/** Storage key with extension (the canonical shape from the upload pipeline). */
storageKey?: string;
/** Pre-baked URL (either an internal proxy URL or an external URL). */
url?: string;
/** Bare media id (ULID without extension); only the internal proxy can look this up. */
id?: string;
}

/**
* Build a render-time media URL. Prefers `storageKey`, then rewrites an
* internal `url` via `resolve`, then falls back to the internal proxy for a
* bare `id`. External URLs and non-matching internal-looking URLs pass
* through untouched. Returns `""` when nothing usable is present.
*
* @internal
*/
export function buildRenderMediaUrl(
resolve: ((key: string) => string) | undefined,
ref: RenderMediaRef,
): string {
const { storageKey, url, id } = ref;
if (storageKey) {
return resolve ? resolve(storageKey) : `${INTERNAL_MEDIA_PREFIX}${storageKey}`;
}
if (url) {
if (resolve && url.startsWith(INTERNAL_MEDIA_PREFIX)) {
const key = url.slice(INTERNAL_MEDIA_PREFIX.length);
if (SAFE_STORAGE_KEY.test(key)) return resolve(key);
}
return url;
}
if (id) return `${INTERNAL_MEDIA_PREFIX}${id}`;
return "";
}
4 changes: 2 additions & 2 deletions packages/core/src/storage/s3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,8 +317,8 @@ export class S3Storage implements Storage {
if (this.publicUrl) {
return `${this.publicUrl.replace(TRAILING_SLASH_PATTERN, "")}/${key}`;
}
// Default to endpoint + bucket + key
return `${this.endpoint.replace(TRAILING_SLASH_PATTERN, "")}/${this.bucket}/${key}`;
// No public URL configured; defer to the /_emdash/api/media/file route.
return `/_emdash/api/media/file/${key}`;
}
}

Expand Down
133 changes: 133 additions & 0 deletions packages/core/tests/unit/media/url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, it, expect } from "vitest";

import {
buildRenderMediaUrl,
createPublicMediaUrlResolver,
resolvePublicMediaUrl,
} from "../../../src/media/url.js";
import type { Storage } from "../../../src/storage/types.js";

function storageWith(publicUrl: string): Storage {
return {
upload: async () => ({ key: "", url: "", size: 0 }),
download: async () => {
throw new Error("not used");
},
delete: async () => {},
exists: async () => true,
list: async () => ({ files: [] }),
getSignedUploadUrl: async () => {
throw new Error("not used");
},
getPublicUrl: (key) => `${publicUrl}/${key}`,
};
}

describe("resolvePublicMediaUrl", () => {
it("returns an empty string when storageKey is empty", () => {
expect(resolvePublicMediaUrl(null, "")).toBe("");
});

it("uses the proxied media endpoint when no storage is provided", () => {
expect(resolvePublicMediaUrl(null, "01ABC.jpg")).toBe("/_emdash/api/media/file/01ABC.jpg");
});

it("uses storage.getPublicUrl when a storage adapter is provided", () => {
const storage = storageWith("https://media.example.com");
expect(resolvePublicMediaUrl(storage, "01ABC.jpg")).toBe("https://media.example.com/01ABC.jpg");
});
});

describe("createPublicMediaUrlResolver", () => {
it("returns a closure that reuses the storage adapter", () => {
const resolver = createPublicMediaUrlResolver(storageWith("https://media.example.com"));
expect(resolver("01ABC.jpg")).toBe("https://media.example.com/01ABC.jpg");
expect(resolver("01XYZ.png")).toBe("https://media.example.com/01XYZ.png");
});

it("falls back to the internal proxy when no storage is given", () => {
const resolver = createPublicMediaUrlResolver(null);
expect(resolver("01ABC.jpg")).toBe("/_emdash/api/media/file/01ABC.jpg");
});
});

describe("buildRenderMediaUrl", () => {
const resolveCdn = (key: string) => `https://media.example.com/${key}`;

it("routes an explicit storageKey through resolve", () => {
expect(buildRenderMediaUrl(resolveCdn, { storageKey: "01ABC.jpg" })).toBe(
"https://media.example.com/01ABC.jpg",
);
});

it("uses the internal proxy for storageKey when resolve is absent", () => {
expect(buildRenderMediaUrl(undefined, { storageKey: "01ABC.jpg" })).toBe(
"/_emdash/api/media/file/01ABC.jpg",
);
});

it("rewrites an internal url via resolve so publicUrl is honored", () => {
expect(
buildRenderMediaUrl(resolveCdn, {
url: "/_emdash/api/media/file/01ABC.jpg",
id: "01ABC",
}),
).toBe("https://media.example.com/01ABC.jpg");
});

it("leaves an external url untouched even when resolve is given", () => {
expect(
buildRenderMediaUrl(resolveCdn, {
url: "https://other-cdn.example.com/01ABC.jpg",
}),
).toBe("https://other-cdn.example.com/01ABC.jpg");
});

it("returns an internal url as-is when no resolve is given", () => {
expect(
buildRenderMediaUrl(undefined, {
url: "/_emdash/api/media/file/01ABC.jpg",
}),
).toBe("/_emdash/api/media/file/01ABC.jpg");
});

it("uses the internal proxy for a bare id", () => {
expect(buildRenderMediaUrl(resolveCdn, { id: "01ABC" })).toBe("/_emdash/api/media/file/01ABC");
});

it("returns an empty string when no fields are usable", () => {
expect(buildRenderMediaUrl(resolveCdn, {})).toBe("");
});

it("does not rewrite a url that only shares the media prefix", () => {
expect(
buildRenderMediaUrl(resolveCdn, {
url: "/_emdash/api/media/file-list/01ABC.jpg",
}),
).toBe("/_emdash/api/media/file-list/01ABC.jpg");
});

it("passes an internal url through when the captured key contains a slash", () => {
expect(
buildRenderMediaUrl(resolveCdn, {
url: "/_emdash/api/media/file/../other-tenant/secret.pdf",
}),
).toBe("/_emdash/api/media/file/../other-tenant/secret.pdf");
});

it("passes an internal url through when the captured key contains a query string", () => {
expect(
buildRenderMediaUrl(resolveCdn, {
url: "/_emdash/api/media/file/01ABC.jpg?v=2",
}),
).toBe("/_emdash/api/media/file/01ABC.jpg?v=2");
});

it("passes an internal url through when the captured key is percent-encoded", () => {
expect(
buildRenderMediaUrl(resolveCdn, {
url: "/_emdash/api/media/file/01%2FABC.jpg",
}),
).toBe("/_emdash/api/media/file/01%2FABC.jpg");
});
});
14 changes: 14 additions & 0 deletions packages/core/tests/unit/storage/s3.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,4 +248,18 @@ describe("resolveS3Config", () => {
expect(typeof storage.getPublicUrl).toBe("function");
});
});

describe("getPublicUrl", () => {
it("uses publicUrl when configured", () => {
setEnv({ ...FULL_ENV, S3_PUBLIC_URL: "https://cdn.example.com/" });
const storage = createStorage({});
expect(storage.getPublicUrl("01ABC.jpg")).toBe("https://cdn.example.com/01ABC.jpg");
});

it("falls back to the proxied media endpoint when no publicUrl is configured", () => {
setEnv({ ...FULL_ENV, S3_PUBLIC_URL: undefined });
const storage = createStorage({});
expect(storage.getPublicUrl("01ABC.jpg")).toBe("/_emdash/api/media/file/01ABC.jpg");
});
});
});
Loading