Skip to content

Fix R2 publicUrl being ignored for API, local provider, plugins, settings (follow-up to #675) - #729

Open
drudge wants to merge 3 commits into
emdash-cms:mainfrom
drudge:drudge/fix-media-url-public-cdn
Open

Fix R2 publicUrl being ignored for API, local provider, plugins, settings (follow-up to #675)#729
drudge wants to merge 3 commits into
emdash-cms:mainfrom
drudge:drudge/fix-media-url-public-cdn

Conversation

@drudge

@drudge drudge commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Extends the storage-publicUrl fix from #675 to the server surfaces that the render-layer PR explicitly left as a follow-up.

#675 routes EmDashImage and the Portable Text image block through the new resolvePublicMediaUrl() helper so rendered media on R2/S3 deployments picks up the configured CDN. Several server surfaces still hard-code /_emdash/api/media/file/{key}, so the same deployments continue to round-trip through the Worker for everything that isn't a direct image render:

  • astro/routes/api/media.ts — GET list, POST upload, dedup path
  • astro/routes/api/media/[id]/confirm.ts — confirm response
  • astro/routes/api/media/upload-url.ts — dedup path
  • plugins/context.tscreateMediaAccessWithWrite().upload return value
  • media/local-runtime.tslist, get, getEmbed, getThumbnailUrl
  • settings/index.tsresolveMediaReference (was accepting an unused _storage arg; now actually uses it so site.logo.url / site.favicon.url are CDN-aware)

Every call site goes through resolvePublicMediaUrl() from #675, so there's still a single source of truth for the URL shape. Admin list thumbnails, JSON API consumers, and plugin authors now see the same CDN URL that EmDashImage already emits.

Related: partial follow-up to #508; the scope note in #675 calls this out explicitly.

Stacking note

Based on top of #675. If #675 merges first, the first commit on this branch drops out as a no-op and this PR rebases cleanly to just the server-surface commit. If this one is reviewed first, merging it will subsume #675.

Type of change

  • Bug fix
  • Feature
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes (0 errors; the 22 pre-existing warnings are unchanged)
  • pnpm test passes (2456 tests, including the 3 new settings cases)
  • pnpm format has been run
  • I have added/updated tests for my changes (3 new cases in tests/unit/settings/settings.test.ts)
  • User-visible strings in the admin UI are wrapped for translation — N/A, no admin UI changes
  • I have added a changeset (.changeset/fix-media-url-server-surfaces.md, patch bump)
  • New features link to an approved Discussion — N/A, bug fix

AI-generated code disclosure

  • This PR includes AI-generated code

Screenshots / test output

 ✓ tests/unit/media/url.test.ts (3 tests) 1ms
 ✓ tests/unit/settings/settings.test.ts (20 tests) 225ms

 Test Files  147 passed (147)
      Tests  2456 passed (2456)

Copilot AI review requested due to automatic review settings April 22, 2026 16:58
@changeset-bot

changeset-bot Bot commented Apr 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f16147e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Apr 22, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@729

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@729

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@729

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@729

emdash

npm i https://pkg.pr.new/emdash@729

create-emdash

npm i https://pkg.pr.new/create-emdash@729

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@729

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@729

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@729

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@729

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@729

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@729

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@729

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@729

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@729

commit: f16147e

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Extends the storage.publicUrl / CDN-aware media URL behavior (introduced in #675) to additional server-side surfaces so API responses, plugin upload results, local media provider URLs, and site settings media URLs consistently use resolvePublicMediaUrl() (and therefore storage.getPublicUrl() when available).

Changes:

  • Added resolvePublicMediaUrl() helper and routed multiple server surfaces (API routes, plugin context, local media runtime, settings resolver) through it.
  • Updated S3Storage.getPublicUrl() fallback to return the internal /_emdash/api/media/file/{key} route when publicUrl is not configured.
  • Added/updated unit tests for S3 getPublicUrl, resolvePublicMediaUrl, and settings media URL resolution.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/core/src/media/url.ts New shared helper for resolving public media URLs via storage adapter or internal proxy route.
packages/core/src/astro/middleware.ts Exposes locals.emdash.getPublicMediaUrl() so render-layer components can resolve CDN URLs.
packages/core/src/astro/types.ts Adds getPublicMediaUrl to EmDashHandlers typing.
packages/core/src/components/EmDashImage.astro Uses locals.emdash.getPublicMediaUrl() for local media URL resolution.
packages/core/src/components/Image.astro Uses locals.emdash.getPublicMediaUrl() in the local-provider fallback path.
packages/core/src/astro/routes/api/media.ts Media list/create responses now compute URLs via resolvePublicMediaUrl().
packages/core/src/astro/routes/api/media/[id]/confirm.ts Confirm response now computes URL via resolvePublicMediaUrl().
packages/core/src/astro/routes/api/media/upload-url.ts Signed-upload dedup response now computes URL via resolvePublicMediaUrl().
packages/core/src/plugins/context.ts Plugin createMediaAccessWithWrite().upload() now returns CDN-aware URL.
packages/core/src/media/local-runtime.ts Local provider list/get/embed/thumbnail URLs now use resolvePublicMediaUrl().
packages/core/src/settings/index.ts resolveMediaReference now uses resolvePublicMediaUrl(storage, storageKey) when storage is provided.
packages/core/src/storage/s3.ts getPublicUrl() fallback changed to internal proxy route when no publicUrl is set.
packages/core/tests/unit/media/url.test.ts New unit tests for resolvePublicMediaUrl().
packages/core/tests/unit/settings/settings.test.ts Adds tests ensuring logo URL resolution honors storage getPublicUrl() when provided.
packages/core/tests/unit/storage/s3.test.ts Adds tests for S3Storage.getPublicUrl() configured vs fallback behavior.
.changeset/fix-media-url-server-surfaces.md Changeset describing the server-surface follow-up (patch).
.changeset/quiet-rivers-bloom.md Additional changeset (minor) for the earlier render-layer fix (#675).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@@ -0,0 +1,5 @@
---
"emdash": minor

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR introduces two changesets for the same package (emdash), and this one (quiet-rivers-bloom.md) bumps the version as minor even though the change described is a bug fix. This is likely to cause an unintended minor release (and potentially a double bump if the patch changeset is also applied). Consider removing this changeset (if it belongs to #675) or downgrading/merging it into a single patch changeset for the combined fix.

Suggested change
"emdash": minor
"emdash": patch

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changeset isn’t a duplicate — it came along when I cherry-picked #675’s commit as the base for this PR (see the "Stacking note" in the description). Two clean paths forward:

  • If Fix R2 publicUrl being ignored for rendered media #675 merges first, rebasing this branch drops the cherry-pick (and quiet-rivers-bloom.md) automatically, leaving only my patch-bump changeset.
  • If this PR merges first, changesets will just take max(minor, patch) = minor for emdash, so no double bump — the two entries end up as adjacent CHANGELOG lines describing the render-layer fix and this server-surfaces follow-up.

Happy to squash them into a single minor changeset if maintainers prefer one combined CHANGELOG entry.

Comment thread packages/core/src/media/local-runtime.ts
@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

eyupcanakman and others added 3 commits April 24, 2026 10:05
…settings

PR emdash-cms#675 routed the `EmDashImage` and Portable Text image block through
`resolvePublicMediaUrl()` so rendered media on R2/S3 deployments picks
up the configured custom domain. Several server surfaces still hard-
coded `/_emdash/api/media/file/{key}`, so the same deployments continued
to round-trip through the Worker for everything that wasn't a direct
image render:

- `astro/routes/api/media.ts` — GET list, POST upload, dedup path
- `astro/routes/api/media/[id]/confirm.ts` — confirm response
- `astro/routes/api/media/upload-url.ts` — dedup path
- `plugins/context.ts` — `createMediaAccessWithWrite().upload` return
- `media/local-runtime.ts` — `list`, `get`, `getEmbed`, `getThumbnailUrl`
- `settings/index.ts` — `resolveMediaReference` (was accepting an unused
  `_storage` arg; now actually uses it so `site.logo.url` /
  `site.favicon.url` are CDN-aware)

Every call site goes through the `resolvePublicMediaUrl()` helper added
in the render-layer fix, so there's still a single source of truth for
the URL shape. Admin list thumbnails, JSON API consumers, and plugin
authors now see the same CDN URL that `EmDashImage` already emits.

Tests: three new cases in `tests/unit/settings/settings.test.ts` cover
the logo/favicon resolver with storage provided and null.
Exported helper still hardcoded `/_emdash/api/media/file/${storageKey}`,
so any external caller would reintroduce the CDN bypass the rest of the
PR fixed. Adds an optional `storage` arg and defers to the helper when
provided; falls back to the internal route otherwise.

Signature change is additive (trailing optional), so existing zero-arg
call sites keep compiling.
@github-actions

Copy link
Copy Markdown
Contributor

This PR has been inactive for 14 days. It will be closed automatically in 7 days if there is no further activity.

If you're still working on this, please push an update or leave a comment.

@github-actions github-actions Bot added stale and removed stale labels Jun 27, 2026
@jproz

jproz commented Jul 8, 2026

Copy link
Copy Markdown

I'm not sure this is linked to this or not --- but here goes --

I had Claude write up the submission --- but for context, I discovered this as a result of a cloudflare access rule I have to ensure images at _emdash/api/media/file/* are always accessible and thus cloudflare security scan says I've over-provisioned the path.

I won't go into my full configuration - because the end state doesn't change - I'm trying to get images served from media.[mydomain.com] connected to R2 vs the mydomain.com/_emdash/api/media/file/* -- so here's the write up of what was uncovered.


publicUrl (R2 custom domain) breaks <Image> rendering on Cloudflare Workers — /_image cross-origin fetch fails

Environment: emdash 0.28.0, @astrojs/cloudflare v13, R2 storage, output: "server".

Setup: R2 bucket connected to a custom domain (media.example.com) that serves
objects fine (curl https://media.example.com/<key> → 200). Storage config:

storage: r2({ binding: "MEDIA", publicUrl: "https://media.example.com" })

Symptom: every image rendered via (or a Portable Text image block) — i.e.
local media with stored width/height — is broken. The emitted markup is:

<img src="/_image?href=https%3A%2F%2Fmedia.example.com%2F<key>&w=1600&h=" >

and /_image returns 500 (403 without image.remotePatterns; 404 with
imageService: "passthrough").

Root cause: with publicUrl set, #675 correctly routes through
getPublicMediaUrl, so src becomes the absolute cross-origin
https://media.example.com/. Since the media has dimensions, Image.astro sets
astroImageSrc and hands it to astro:assets, so Astro's /_image endpoint must
fetch() that cross-origin host to load source bytes before transforming. On Cloudflare
Workers that subrequest fails → /_image errors. This is the same failure class #1494
fixes for same-origin media, but #1494's transform route is same-origin only — per its
own note, publicUrl/external media falls back to the astro:assets (/_image) path,
which is the one that fails here.

Repro:

  1. emdash 0.28.0 on Cloudflare (@astrojs/cloudflare), R2 storage.
  2. Connect a custom domain to the R2 bucket; confirm it serves objects (200).
  3. Set publicUrl to that domain.
  4. Render any content image via (media with stored dimensions).
  5. The returns 500; broken image on the page.

Configs that do NOT fix it:

  • imageService: "passthrough" → /_image passthrough endpoint 404s (responsive srcset
    still generated).
  • image.remotePatterns: [{ protocol: "https", hostname: "media.example.com" }] → clears
    the 403 but /_image still 500s (the default cloudflare-binding service still can't
    fetch/transform the remote host; no IMAGES binding configured).

Expected: when media resolves to an absolute publicUrl/external URL,
should treat it as already-served (a CDN URL) and emit a plain (optionally
with a provider getSrc for srcset) rather than routing it through /_image. That
matches the documented intent ("serve media directly from R2 for better performance").
Otherwise, the docs should state the exact image-service setup publicUrl requires on
Cloudflare, since the current default silently breaks every rendered image.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants