Skip to content
Open
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
9 changes: 6 additions & 3 deletions apps/admin/vite-ember-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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://') ||
Expand All @@ -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 = /<link[^>]*rel="stylesheet"[^>]*href="([^"]*)"[^>]*>/g;
const styles: HtmlTagDescriptor[] = [];
Expand Down
3 changes: 2 additions & 1 deletion ghost/admin/app/components/admin-x/admin-x-component.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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`];
Expand Down
7 changes: 2 additions & 5 deletions ghost/admin/app/helpers/parse-history-event.js
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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 {
Expand Down
10 changes: 5 additions & 5 deletions ghost/admin/app/models/user.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
}),

Expand Down
6 changes: 3 additions & 3 deletions ghost/admin/app/services/lazy-loader.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
50 changes: 50 additions & 0 deletions ghost/admin/app/utils/asset-base.js
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 2 additions & 5 deletions ghost/admin/app/utils/fetch-koenig-lexical.js
Original file line number Diff line number Diff line change
@@ -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:') {
Expand Down
1 change: 0 additions & 1 deletion ghost/admin/config/environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
20 changes: 10 additions & 10 deletions ghost/admin/tests/integration/services/lazy-loader-test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -7,9 +8,6 @@ describe('Integration: Service: lazy-loader', function () {
setupTest();

let server;
let ghostPaths = {
adminRoot: '/assets/'
};

beforeEach(function () {
server = new Pretender();
Expand All @@ -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);
});
Expand All @@ -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);
});
});
});
94 changes: 94 additions & 0 deletions ghost/admin/tests/unit/utils/asset-base-test.js
Original file line number Diff line number Diff line change
@@ -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 <script> tag with the
* given src. Uses a real DOMParser so the querySelector logic in
* resolveAssetBase runs against actual DOM nodes.
*/
function docWithScript(src) {
const doc = document.implementation.createHTMLDocument('test');
const script = doc.createElement('script');
script.src = src;
doc.body.appendChild(script);
return doc;
}

function emptyDoc() {
return document.implementation.createHTMLDocument('test');
}

describe('Unit: Util: asset-base', function () {
describe('script detection', function () {
it('extracts base from a non-fingerprinted script (ghost.js)', function () {
const doc = docWithScript('http://localhost:2368/ghost/assets/ghost.js');
const result = resolveAssetBase(doc);

expect(result).to.equal('http://localhost:2368/ghost/');
});

it('extracts base from a fingerprinted script (ghost-{hash}.js)', function () {
const doc = docWithScript('http://127.0.0.1:2368/ghost/assets/ghost-a1b2c3d4e5.js');
const result = resolveAssetBase(doc);

expect(result).to.equal('http://127.0.0.1:2368/ghost/');
});

it('extracts CDN origin from a CDN-hosted script', function () {
const doc = docWithScript('https://cdn.example.com/admin-forward/assets/ghost-a1b2c3d4e5.js');
const result = resolveAssetBase(doc);

expect(result).to.equal('https://cdn.example.com/admin-forward/');
});

it('handles a subdirectory install', function () {
const doc = docWithScript('http://example.com/blog/ghost/assets/ghost.js');
const result = resolveAssetBase(doc);

expect(result).to.equal('http://example.com/blog/ghost/');
});
});

describe('fallback', function () {
it('falls back when no script is found', function () {
const result = resolveAssetBase(emptyDoc());

expect(result).to.match(/^https?:\/\//);
expect(result).to.include('/ghost/');
expect(result).to.match(/\/$/);
});

it('falls back when script has no admin root prefix (test/dev environment)', function () {
// In the Ember test environment, ghost.js is served at /assets/ghost.js
// without the /ghost/ admin root prefix. The function should fall through
// to ghostPaths rather than returning a bare origin with no admin root.
const doc = docWithScript('/assets/ghost.js');
const result = resolveAssetBase(doc);

expect(result).to.match(/^https?:\/\//);
expect(result).to.include('/ghost/');
expect(result).to.match(/\/$/);
});
});

describe('new URL() safety', function () {
it('script-derived result works with new URL()', function () {
const doc = docWithScript('http://localhost:2368/ghost/assets/ghost-abc123.js');
const base = resolveAssetBase(doc);

const koenigUrl = new URL(`${base}assets/koenig-lexical/koenig-lexical.umd.js`);
expect(koenigUrl.href).to.equal(
'http://localhost:2368/ghost/assets/koenig-lexical/koenig-lexical.umd.js'
);
});

it('fallback result works with new URL()', function () {
const base = resolveAssetBase(emptyDoc());

// This is the exact pattern used by fetchKoenigLexical and importComponent —
// a relative path is NOT valid input for new URL(), so the base must be absolute.
expect(() => new URL(`${base}assets/koenig-lexical/koenig-lexical.umd.js`)).to.not.throw();
});
});
});
Loading