diff --git a/apps/admin/vite-ember-assets.ts b/apps/admin/vite-ember-assets.ts index 0341c0ac7c3..0be1f079802 100644 --- a/apps/admin/vite-ember-assets.ts +++ b/apps/admin/vite-ember-assets.ts @@ -4,6 +4,7 @@ import fs from 'fs'; import sirv from 'sirv'; const GHOST_ADMIN_PATH = path.resolve(__dirname, '../../ghost/core/core/built/admin'); +const GHOST_ADMIN_DIST = path.resolve(__dirname, '../../ghost/admin/dist'); function isAbsoluteUrl(url: string): boolean { return url.startsWith('http://') || @@ -29,12 +30,14 @@ export function emberAssetsPlugin() { transformIndexHtml: { order: 'post', handler() { - // Path to the Ghost admin index.html file - const indexPath = path.resolve(GHOST_ADMIN_PATH, 'index.html'); + // Read from Ember's own build output (not the combined output + // in built/admin which gets overwritten by closeBundle and would + // accumulate duplicate path prefixes on repeated builds) + const indexPath = path.resolve(GHOST_ADMIN_DIST, 'index.html'); try { const indexContent = fs.readFileSync(indexPath, 'utf-8'); const base = config.base || '/'; - + // Extract stylesheets const styleRegex = /]*rel="stylesheet"[^>]*href="([^"]*)"[^>]*>/g; const styles: HtmlTagDescriptor[] = []; diff --git a/ghost/admin/app/components/admin-x/admin-x-component.js b/ghost/admin/app/components/admin-x/admin-x-component.js index 6b87ea698a9..407252d82e8 100644 --- a/ghost/admin/app/components/admin-x/admin-x-component.js +++ b/ghost/admin/app/components/admin-x/admin-x-component.js @@ -1,6 +1,7 @@ import * as Sentry from '@sentry/ember'; import Component from '@glimmer/component'; import React, {Suspense} from 'react'; +import assetBase from 'ghost-admin/utils/asset-base'; import config from 'ghost-admin/config/environment'; import fetch from 'fetch'; import fetchKoenigLexical from 'ghost-admin/utils/fetch-koenig-lexical'; @@ -57,7 +58,7 @@ export const importComponent = async (packageName) => { throw new Error(`Missing config for ${packageName}. Add it in asset delivery.`); } - const baseUrl = (config.cdnUrl ? `${config.cdnUrl}assets/` : ghostPaths().assetRootWithHost); + const baseUrl = `${assetBase()}assets/`; let url = new URL(`${baseUrl}${relativePath}/${config[`${configKey}Filename`]}?v=${config[`${configKey}Hash`]}`); const customUrl = config[`${configKey}CustomUrl`]; diff --git a/ghost/admin/app/helpers/parse-history-event.js b/ghost/admin/app/helpers/parse-history-event.js index 47b43b06d4c..2db3dc91f6b 100644 --- a/ghost/admin/app/helpers/parse-history-event.js +++ b/ghost/admin/app/helpers/parse-history-event.js @@ -1,10 +1,7 @@ import Helper from '@ember/component/helper'; -import config from 'ghost-admin/config/environment'; -import {inject as service} from '@ember/service'; +import assetBase from 'ghost-admin/utils/asset-base'; export default class ParseHistoryEvent extends Helper { - @service ghostPaths; - compute([ev]) { const action = getAction(ev); const actionIcon = getActionIcon(ev); @@ -14,7 +11,7 @@ export default class ParseHistoryEvent extends Helper { const actor = getActor(ev); const actorLinkTarget = getActorLinkTarget(ev); - const assetRoot = (config.cdnUrl ? '' : this.ghostPaths.assetRoot.replace(/\/$/, '')); + const assetRoot = `${assetBase()}assets`; const actorIcon = getActorIcon(ev, assetRoot); return { diff --git a/ghost/admin/app/models/user.js b/ghost/admin/app/models/user.js index 38599453dd4..a07186446fc 100644 --- a/ghost/admin/app/models/user.js +++ b/ghost/admin/app/models/user.js @@ -1,6 +1,6 @@ import BaseModel from './base'; import ValidationEngine from 'ghost-admin/mixins/validation-engine'; -import config from 'ghost-admin/config/environment'; +import assetBase from 'ghost-admin/utils/asset-base'; import {attr, hasMany} from '@ember-data/model'; import {computed} from '@ember/object'; import {equal, or} from '@ember/object/computed'; @@ -96,17 +96,17 @@ export default BaseModel.extend(ValidationEngine, { } }), - profileImageUrl: computed('ghostPaths.assetRoot', 'profileImage', function () { + profileImageUrl: computed('profileImage', function () { // keep path separate so asset rewriting correctly picks it up let defaultImage = '/img/user-image.png'; - let defaultPath = (config.cdnUrl ? '' : this.ghostPaths.assetRoot.replace(/\/$/, '')) + defaultImage; + let defaultPath = `${assetBase()}assets${defaultImage}`; return this.profileImage || defaultPath; }), - coverImageUrl: computed('ghostPaths.assetRoot', 'coverImage', function () { + coverImageUrl: computed('coverImage', function () { // keep path separate so asset rewriting correctly picks it up let defaultImage = '/img/user-cover.png'; - let defaultPath = (config.cdnUrl ? '' : this.ghostPaths.assetRoot.replace(/\/$/, '')) + defaultImage; + let defaultPath = `${assetBase()}assets${defaultImage}`; return this.coverImage || defaultPath; }), diff --git a/ghost/admin/app/services/lazy-loader.js b/ghost/admin/app/services/lazy-loader.js index 6a3a543df17..c2562df19f0 100644 --- a/ghost/admin/app/services/lazy-loader.js +++ b/ghost/admin/app/services/lazy-loader.js @@ -1,12 +1,12 @@ import RSVP from 'rsvp'; import Service, {inject as service} from '@ember/service'; +import assetBase from 'ghost-admin/utils/asset-base'; import classic from 'ember-classic-decorator'; import config from 'ghost-admin/config/environment'; @classic export default class LazyLoaderService extends Service { @service ajax; - @service ghostPaths; // This is needed so we can disable it in unit tests testing = undefined; @@ -35,7 +35,7 @@ export default class LazyLoaderService extends Service { let script = document.createElement('script'); script.type = 'text/javascript'; script.async = true; - script.src = `${config.cdnUrl ? '' : this.ghostPaths.adminRoot}${url}`; + script.src = `${assetBase()}${url}`; let el = document.getElementsByTagName('script')[0]; el.parentNode.insertBefore(script, el); @@ -63,7 +63,7 @@ export default class LazyLoaderService extends Service { let link = document.createElement('link'); link.id = `${key}-styles`; link.rel = alternate ? 'alternate stylesheet' : 'stylesheet'; - link.href = `${config.cdnUrl ? '' : this.ghostPaths.adminRoot}${url}`; + link.href = `${assetBase()}${url}`; link.onload = () => { link.onload = null; if (alternate) { diff --git a/ghost/admin/app/utils/asset-base.js b/ghost/admin/app/utils/asset-base.js new file mode 100644 index 00000000000..db6f8b3b550 --- /dev/null +++ b/ghost/admin/app/utils/asset-base.js @@ -0,0 +1,50 @@ +import ghostPaths from 'ghost-admin/utils/ghost-paths'; + +let _assetBase = null; + +/** + * Resolve the asset base URL from script elements in the given document root. + * Exported for direct testing — callers should use the default export instead. + * + * @param {Document} doc The document to search for script tags + * @returns {string} Absolute URL with trailing slash + */ +export function resolveAssetBase(doc) { + // Find the Ember app script — its src tells us where assets are served from. + // The browser always resolves script.src to an absolute URL. + // Matches both non-fingerprinted (ghost.js) and fingerprinted (ghost-{hash}.js). + const script = doc.querySelector('script[src*="assets/ghost"]'); + + if (script && script.src) { + try { + const url = new URL(script.src); + const assetsIdx = url.pathname.indexOf('/assets/'); + if (assetsIdx > 0) { + return `${url.origin}${url.pathname.substring(0, assetsIdx)}/`; + } + } catch (e) { + // Fall through to ghostPaths + } + } + + // Fallback: absolute URL from the current origin so new URL() never throws + return `${window.location.origin}${ghostPaths().adminRoot}`; +} + +/** + * Derives the asset base URL from where the Ember scripts were loaded. + * If loaded from a CDN, returns the CDN base. If local, returns the admin root. + * + * Always returns an absolute URL (with origin and trailing slash) so callers + * can safely pass the result to `new URL()`. Examples: + * CDN: "https://assets.ghost.io/admin-forward/" + * Local: "http://localhost:2368/ghost/" + */ +export default function assetBase() { + if (_assetBase !== null) { + return _assetBase; + } + + _assetBase = resolveAssetBase(document); + return _assetBase; +} diff --git a/ghost/admin/app/utils/fetch-koenig-lexical.js b/ghost/admin/app/utils/fetch-koenig-lexical.js index 382824ef782..f4d13edf268 100644 --- a/ghost/admin/app/utils/fetch-koenig-lexical.js +++ b/ghost/admin/app/utils/fetch-koenig-lexical.js @@ -1,15 +1,12 @@ +import assetBase from 'ghost-admin/utils/asset-base'; import config from 'ghost-admin/config/environment'; -import ghostPaths from 'ghost-admin/utils/ghost-paths'; export default async function fetchKoenigLexical() { if (window['@tryghost/koenig-lexical']) { return window['@tryghost/koenig-lexical']; } - // If we pass an editor URL (the env var from the dev script), use that - // Else, if we pass a CDN URL, use that - // Else, use the asset root from the ghostPaths util - const baseUrl = (config.editorUrl || (config.cdnUrl ? `${config.cdnUrl}assets/koenig-lexical/` : `${ghostPaths().assetRootWithHost}koenig-lexical/`)); + const baseUrl = (config.editorUrl || `${assetBase()}assets/koenig-lexical/`); const url = new URL(`${baseUrl}${config.editorFilename}?v=${config.editorHash}`); if (url.protocol === 'http:') { diff --git a/ghost/admin/config/environment.js b/ghost/admin/config/environment.js index d77a92194fd..beb9bba2507 100644 --- a/ghost/admin/config/environment.js +++ b/ghost/admin/config/environment.js @@ -5,7 +5,6 @@ module.exports = function (environment) { let ENV = { modulePrefix: 'ghost-admin', environment, - cdnUrl: process.env.GHOST_CDN_URL || '', editorUrl: process.env.EDITOR_URL || '', rootURL: '', locationType: 'trailing-hash', diff --git a/ghost/admin/tests/integration/services/lazy-loader-test.js b/ghost/admin/tests/integration/services/lazy-loader-test.js index 2117989ab29..da61432d418 100644 --- a/ghost/admin/tests/integration/services/lazy-loader-test.js +++ b/ghost/admin/tests/integration/services/lazy-loader-test.js @@ -1,4 +1,5 @@ import Pretender from 'pretender'; +import assetBase from 'ghost-admin/utils/asset-base'; import {describe, it} from 'mocha'; import {expect} from 'chai'; import {setupTest} from 'ember-mocha'; @@ -7,9 +8,6 @@ describe('Integration: Service: lazy-loader', function () { setupTest(); let server; - let ghostPaths = { - adminRoot: '/assets/' - }; beforeEach(function () { server = new Pretender(); @@ -23,28 +21,29 @@ describe('Integration: Service: lazy-loader', function () { let subject = this.owner.lookup('service:lazy-loader'); subject.setProperties({ - ghostPaths, scriptPromises: {}, testing: false }); + const expectedSrc = `${assetBase()}lazy-test.js`; + // first load should add script element await subject.loadScript('test', 'lazy-test.js') .then(() => {}) .catch(() => {}); expect( - document.querySelectorAll('script[src="/assets/lazy-test.js"]').length, + document.querySelectorAll(`script[src="${expectedSrc}"]`).length, 'no of script tags on first load' ).to.equal(1); // second load should not add another script element - await subject.loadScript('test', '/assets/lazy-test.js') + await subject.loadScript('test', 'lazy-test.js') .then(() => { }) .catch(() => { }); expect( - document.querySelectorAll('script[src="/assets/lazy-test.js"]').length, + document.querySelectorAll(`script[src="${expectedSrc}"]`).length, 'no of script tags on second load' ).to.equal(1); }); @@ -53,14 +52,15 @@ describe('Integration: Service: lazy-loader', function () { let subject = this.owner.lookup('service:lazy-loader'); subject.setProperties({ - ghostPaths, testing: false }); + const expectedHref = `${assetBase()}style.css`; + return subject.loadStyle('testing', 'style.css').catch(() => { - // we add a catch handler here because `/assets/style.css` doesn't exist + // we add a catch handler here because the style.css doesn't exist expect(document.querySelectorAll('#testing-styles').length).to.equal(1); - expect(document.querySelector('#testing-styles').getAttribute('href')).to.equal('/assets/style.css'); + expect(document.querySelector('#testing-styles').getAttribute('href')).to.equal(expectedHref); }); }); }); diff --git a/ghost/admin/tests/unit/utils/asset-base-test.js b/ghost/admin/tests/unit/utils/asset-base-test.js new file mode 100644 index 00000000000..d63d1311756 --- /dev/null +++ b/ghost/admin/tests/unit/utils/asset-base-test.js @@ -0,0 +1,94 @@ +import {describe, it} from 'mocha'; +import {expect} from 'chai'; +import {resolveAssetBase} from 'ghost-admin/utils/asset-base'; + +/** + * Create a minimal document fragment containing a