Skip to content

Fixed media inliner to store correct relative paths with CDN storage#26791

Merged
vershwal merged 1 commit intomainfrom
fix/s3-storage-relative-paths
Mar 12, 2026
Merged

Fixed media inliner to store correct relative paths with CDN storage#26791
vershwal merged 1 commit intomainfrom
fix/s3-storage-relative-paths

Conversation

@vershwal
Copy link
Copy Markdown
Member

@vershwal vershwal commented Mar 11, 2026

ref https://linear.app/ghost/issue/HKG-1648

  • When using CDN storage (S3Storage), saveRaw() returns a full CDN URL (e.g., https://storage.ghost.is/.../content/images/photo.jpg) instead of a relative path like local storage does (/content/images/photo.jpg)
  • The media inliner's storeMediaLocally() passed this CDN URL directly to callers, which prepended GHOST_URL to it, producing malformed database entries like __GHOST_URL__https://storage.ghost.is/...
  • This broke image references in posts after running content imports on CDN-configured sites
  • Used urlUtils.toTransformReady() in storeMediaLocally() to convert the storage adapter's return value into a GHOST_URL-prefixed relative path, which correctly handles both CDN URLs and local paths — callers no longer manually prepend GHOST_URL
  • The oembed service (the only other saveRaw caller) was verified to not have this issue — it returns the URL as-is to the admin client, which flows through the model pipeline where formatOnWrite handles the CDN-to-GHOST_URL transformation correctly

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 11, 2026

Walkthrough

storeMediaLocally was changed to return a transform-ready URL directly, and calling sites now use that returned value as the inlined source instead of prepending a GHOST_URL prefix. Unit tests were added for CDN storage adapter behavior and for handling storage.saveRaw returning relative local paths, verifying correct inlined URLs and field values.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: fixing the media inliner to handle CDN storage by converting full CDN URLs back to relative paths.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description check ✅ Passed The PR description clearly explains the problem (CDN URLs breaking media inliner), the solution (using urlUtils.toTransformReady to normalize paths), and the scope of changes across the codebase.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/s3-storage-relative-paths

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes incorrect media paths produced during external media inlining when using CDN-backed storage adapters (e.g., S3) by converting returned CDN URLs back into relative /content/... paths before callers prepend __GHOST_URL__.

Changes:

  • Update ExternalMediaInliner.storeMediaLocally() to strip configured CDN base URLs from saveRaw() return values.
  • Add unit tests covering CDN-URL and relative-path saveRaw() behavior for the external media inliner.
  • Add an oEmbed unit test covering saveRaw() returning a relative path (local storage behavior).

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
ghost/core/core/server/services/media-inliner/external-media-inliner.js Adds CDN base URL stripping logic to ensure returned paths are compatible with __GHOST_URL__ prefixing.
ghost/core/test/unit/server/services/media-inliner/test/external-media-inliner.test.js Adds CDN-configured test cases to verify correct __GHOST_URL__/content/... storage for both CDN URL and relative-path saveRaw() outputs.
ghost/core/test/unit/server/services/oembed/oembed-service.test.js Adds coverage for local-storage-style relative path return from saveRaw() in oEmbed image processing.

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

Comment thread ghost/core/core/server/services/media-inliner/external-media-inliner.js Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
ghost/core/core/server/services/media-inliner/external-media-inliner.js (1)

150-164: Prefer adapter-owned URL normalization here.

saveRaw() is storage-specific, and the storage contract already exposes urlToPath(url). Rebuilding that mapping from urls:image|media|files duplicates adapter logic and can drift for backends whose public URL shape does not exactly match those config values. If possible, normalize absolute saveRaw() results through the adapter instead of hardcoding config-prefix stripping here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/media-inliner/external-media-inliner.js`
around lines 150 - 164, The current manual stripping of config-based CDN
prefixes duplicates adapter logic; instead, call the storage adapter's
urlToPath(url) (e.g. storageAdapter.urlToPath(filePath) or the same adapter
instance used by saveRaw()) to convert absolute URLs into relative paths
returned by saveRaw(), and use the returned path if non-null; keep the existing
config-prefix stripping as a fallback (use variables filePath and cdnBaseUrls as
before) so behavior is preserved for adapters that don't implement urlToPath().
ghost/core/test/unit/server/services/media-inliner/test/external-media-inliner.test.js (1)

994-1126: Add explicit media / files CDN cases to this suite.

These tests only exercise /content/images, so the new urls:media and urls:files branches are still unproven. Parameterizing the storage path and expected output across all three prefixes would make the regression coverage match the implementation claim.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ghost/core/test/unit/server/services/media-inliner/test/external-media-inliner.test.js`
around lines 994 - 1126, The tests only cover the /content/images branch; update
the four test cases (those that construct ExternalMediaInliner and call
inlineContent/inlineFields) to run for each CDN prefix (image, media, files) by
parameterizing the
storagePath/staticFileURLPrefix/getTargetDir/getUniqueFileName and expected
output; specifically, iterate over an array of prefixes (e.g. '/content/images',
'/content/media', '/content/files') and for each create a storage stub returned
by getMediaStorage (the existing sinon.stub().withArgs('.gif').returns({...}))
with storagePath and staticFileURLPrefix set to the current prefix and
getUniqueFileName returning `${prefix}/unique-*.jpg` (and adjust path.relative
stub to match), then assert the result equals
`__GHOST_URL__/${prefix.replace(/^\/+/,'')}/unique-*.jpg`; apply this
parameterization to both inlineContent tests and both inlineFields tests so all
three URL config branches are exercised.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@ghost/core/core/server/services/media-inliner/external-media-inliner.js`:
- Around line 150-164: The current manual stripping of config-based CDN prefixes
duplicates adapter logic; instead, call the storage adapter's urlToPath(url)
(e.g. storageAdapter.urlToPath(filePath) or the same adapter instance used by
saveRaw()) to convert absolute URLs into relative paths returned by saveRaw(),
and use the returned path if non-null; keep the existing config-prefix stripping
as a fallback (use variables filePath and cdnBaseUrls as before) so behavior is
preserved for adapters that don't implement urlToPath().

In
`@ghost/core/test/unit/server/services/media-inliner/test/external-media-inliner.test.js`:
- Around line 994-1126: The tests only cover the /content/images branch; update
the four test cases (those that construct ExternalMediaInliner and call
inlineContent/inlineFields) to run for each CDN prefix (image, media, files) by
parameterizing the
storagePath/staticFileURLPrefix/getTargetDir/getUniqueFileName and expected
output; specifically, iterate over an array of prefixes (e.g. '/content/images',
'/content/media', '/content/files') and for each create a storage stub returned
by getMediaStorage (the existing sinon.stub().withArgs('.gif').returns({...}))
with storagePath and staticFileURLPrefix set to the current prefix and
getUniqueFileName returning `${prefix}/unique-*.jpg` (and adjust path.relative
stub to match), then assert the result equals
`__GHOST_URL__/${prefix.replace(/^\/+/,'')}/unique-*.jpg`; apply this
parameterization to both inlineContent tests and both inlineFields tests so all
three URL config branches are exercised.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8be777d7-231c-46c4-9181-9a5feb3d2344

📥 Commits

Reviewing files that changed from the base of the PR and between 4cfe408 and e210a19.

📒 Files selected for processing (3)
  • ghost/core/core/server/services/media-inliner/external-media-inliner.js
  • ghost/core/test/unit/server/services/media-inliner/test/external-media-inliner.test.js
  • ghost/core/test/unit/server/services/oembed/oembed-service.test.js

Copy link
Copy Markdown
Collaborator

@allouis allouis left a comment

Choose a reason for hiding this comment

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

I think we can return urlUtils.toTransformReady(filePath) here instead 🤔

@vershwal vershwal force-pushed the fix/s3-storage-relative-paths branch from e210a19 to f8b2cf8 Compare March 12, 2026 10:20
- When using CDN storage (S3Storage), saveRaw() returns a full CDN URL (e.g., https://storage.ghost.is/.../content/images/photo.jpg) instead of a relative path like local storage does (/content/images/photo.jpg)
- The media inliner's storeMediaLocally() passed this CDN URL directly to callers, which prepended __GHOST_URL__ to it, producing malformed database entries like __GHOST_URL__https://storage.ghost.is/...
- This broke image references in posts after running content imports on CDN-configured sites
- Used urlUtils.toTransformReady() in storeMediaLocally() to convert the storage adapter's return value into a __GHOST_URL__-prefixed relative path, which correctly handles both CDN URLs and local paths — callers no longer manually prepend __GHOST_URL__
- The oembed service (the only other saveRaw caller) was verified to not have this issue — it returns the URL as-is to the admin client, which flows through the model pipeline where formatOnWrite handles the CDN-to-__GHOST_URL__ transformation correctly
@vershwal vershwal force-pushed the fix/s3-storage-relative-paths branch from f8b2cf8 to 26884dc Compare March 12, 2026 10:21
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
ghost/core/test/unit/server/services/media-inliner/test/external-media-inliner.test.js (1)

999-1009: Differentiate the CDN bases per asset type.

All three config keys are set to the same URL, and every case writes under /content/images. That means a regression which only strips urls:image would still pass here, so urls:media and urls:files are not independently covered.

Also applies to: 1017-1135

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ghost/core/test/unit/server/services/media-inliner/test/external-media-inliner.test.js`
around lines 999 - 1009, The test's beforeEach currently sets the same
CDN_BASE_URL for all asset types which masks regressions; update the setup in
beforeEach to use distinct base URLs for each asset type (e.g., CDN_IMAGE_BASE,
CDN_MEDIA_BASE, CDN_FILES_BASE) when calling configUtils.set('urls:image'...),
configUtils.set('urls:media'...), configUtils.set('urls:files'...) and when
assigning urlUtils._assetBaseUrls, and adjust any expectations/assertions in the
tests that rely on specific upload paths so they assert different output paths
for image, media and files; apply the same change to the other test blocks
referenced (around 1017-1135) to ensure independent coverage.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@ghost/core/test/unit/server/services/media-inliner/test/external-media-inliner.test.js`:
- Around line 1033-1039: The test stubs call getMediaStorage with the wrong
extension ('.gif') so they never match the code path that uses media.extension
('.jpg'); update the sinon.stub().withArgs('.gif') occurrences to
withArgs('.jpg') for the blocks around test cases that generate JPG URLs/targets
(the getMediaStorage stub used in the test file, and the similar blocks at the
locations flagged: the blocks at ~1033, ~1063, ~1090, ~1119), ensuring the
returned getUniqueFileName/saveRaw values remain JPG-based and the rest of the
test expectations remain unchanged.

---

Nitpick comments:
In
`@ghost/core/test/unit/server/services/media-inliner/test/external-media-inliner.test.js`:
- Around line 999-1009: The test's beforeEach currently sets the same
CDN_BASE_URL for all asset types which masks regressions; update the setup in
beforeEach to use distinct base URLs for each asset type (e.g., CDN_IMAGE_BASE,
CDN_MEDIA_BASE, CDN_FILES_BASE) when calling configUtils.set('urls:image'...),
configUtils.set('urls:media'...), configUtils.set('urls:files'...) and when
assigning urlUtils._assetBaseUrls, and adjust any expectations/assertions in the
tests that rely on specific upload paths so they assert different output paths
for image, media and files; apply the same change to the other test blocks
referenced (around 1017-1135) to ensure independent coverage.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4e326ca3-ee21-4b6d-a867-928f64e1ecda

📥 Commits

Reviewing files that changed from the base of the PR and between e210a19 and 26884dc.

📒 Files selected for processing (3)
  • ghost/core/core/server/services/media-inliner/external-media-inliner.js
  • ghost/core/test/unit/server/services/media-inliner/test/external-media-inliner.test.js
  • ghost/core/test/unit/server/services/oembed/oembed-service.test.js
🚧 Files skipped from review as they are similar to previous changes (2)
  • ghost/core/test/unit/server/services/oembed/oembed-service.test.js
  • ghost/core/core/server/services/media-inliner/external-media-inliner.js

@vershwal
Copy link
Copy Markdown
Member Author

Thanks for the suggestion @allouis , urlUtils.toTransformReady() is a much cleaner fix! I've updated the PR. It also simplified the callers since storeMediaLocally now returns the path in transform-ready format directly.

@vershwal vershwal merged commit 96ae83b into main Mar 12, 2026
30 checks passed
@vershwal vershwal deleted the fix/s3-storage-relative-paths branch March 12, 2026 11:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants