Added frontend key to ghost_head for portal (#14782)

refs: https://github.com/TryGhost/Team/issues/1599
refs: f3d5d9cf6b

- this commit adds the concept of a frontend data service, intended for passing data to the frontend from the server in a clean way. This is the start of a new & improved pattern, to hopefully reduce coupling
- the newly added internal frontend key is then exposed through this pattern so that the frontend can make use of it
- the first use case is so that portal can use it to talk to the content API instead of having weird endpoints for portal
- this key will also be used by other internal scripts in future, it's public and therefore safe to expose, but it's meant for internal use only and therefore is not exposed in a generic way e.g. as a helper
This commit is contained in:
Hannah Wolfe 2022-05-11 17:34:31 +01:00 committed by GitHub
parent 2e8c66d93c
commit 409dc3b534
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 216 additions and 9 deletions

View File

@ -168,15 +168,22 @@ async function initServicesForFrontend({bootLogger}) {
await offers.init();
debug('End: Offers');
const frontendDataService = require('./server/services/frontend-data-service');
let dataService = await frontendDataService.init();
debug('End: initServicesForFrontend');
return {dataService};
}
/**
* Frontend is intended to be just Ghost's frontend
*/
async function initFrontend() {
async function initFrontend(dataService) {
debug('Begin: initFrontend');
const proxyService = require('./frontend/services/proxy');
proxyService.init({dataService});
const helperService = require('./frontend/services/helpers');
await helperService.init();
@ -414,10 +421,10 @@ async function bootGhost({backend = true, frontend = true, server = true} = {})
// Step 4 - Load Ghost with all its services
debug('Begin: Load Ghost Services & Apps');
await initCore({ghostServer, config, bootLogger, frontend});
await initServicesForFrontend({bootLogger});
const {dataService} = await initServicesForFrontend({bootLogger});
if (frontend) {
await initFrontend();
await initFrontend(dataService);
}
const ghostApp = await initExpressApps({frontend, backend, config});

View File

@ -2,7 +2,7 @@
// Usage: `{{ghost_head}}`
//
// Outputs scripts and other assets at the top of a Ghost theme
const {metaData, settingsCache, config, blogIcon, urlUtils, labs} = require('../services/proxy');
const {metaData, settingsCache, config, blogIcon, urlUtils, labs, getFrontendKey} = require('../services/proxy');
const {escapeExpression, SafeString} = require('../services/handlebars');
// BAD REQUIRE
@ -43,7 +43,7 @@ function finaliseStructuredData(meta) {
return head;
}
function getMembersHelper(data) {
function getMembersHelper(data, frontendKey) {
if (settingsCache.get('members_signup_access') === 'none') {
return '';
}
@ -52,8 +52,7 @@ function getMembersHelper(data) {
const stripeDirectPublishableKey = settingsCache.get('stripe_publishable_key');
const stripeConnectAccountId = settingsCache.get('stripe_connect_account_id');
const colorString = _.has(data, 'site._preview') && data.site.accent_color ? ` data-accent-color="${data.site.accent_color}"` : '';
const portalUrl = config.get('portal:url');
let membersHelper = `<script defer src="${portalUrl}" data-ghost="${urlUtils.getSiteUrl()}"${colorString} crossorigin="anonymous"></script>`;
let membersHelper = `<script defer src="${config.get('portal:url')}" data-ghost="${urlUtils.getSiteUrl()}"${colorString} data-key="${frontendKey}" data-api="${urlUtils.urlFor('api', {type: 'content'}, true)}" crossorigin="anonymous"></script>`;
membersHelper += (`<style id="gh-members-styles">${templateStyles}</style>`);
if ((!!stripeDirectSecretKey && !!stripeDirectPublishableKey) || !!stripeConnectAccountId) {
membersHelper += '<script async src="https://js.stripe.com/v3/"></script>';
@ -129,6 +128,8 @@ module.exports = async function ghost_head(options) { // eslint-disable-line cam
* - it should not break anything
*/
const meta = await getMetaData(dataRoot, dataRoot);
const frontendKey = await getFrontendKey();
debug('end fetch');
if (context) {
@ -194,7 +195,7 @@ module.exports = async function ghost_head(options) { // eslint-disable-line cam
// no code injection for amp context!!!
if (!_.includes(context, 'amp')) {
head.push(getMembersHelper(options.data));
head.push(getMembersHelper(options.data, frontendKey));
// @TODO do this in a more "frameworky" way
if (cardAssetService.hasFile('js')) {

View File

@ -5,7 +5,13 @@ const config = require('../../shared/config');
// Require from the handlebars framework
const {SafeString} = require('./handlebars');
let _dataService = {};
module.exports = {
getFrontendKey: () => {
return _dataService.getFrontendKey();
},
/**
* Section two: data manipulation
* Stuff that modifies API data (SDK layer)
@ -47,3 +53,7 @@ module.exports = {
urlService: require('../../server/services/url'),
urlUtils: require('../../shared/url-utils')
};
module.exports.init = ({dataService}) => {
_dataService = dataService;
};

View File

@ -1,3 +1,4 @@
const _ = require('lodash');
const limitService = require('../services/limits');
const ghostBookshelf = require('./base');
const {NoPermissionError} = require('@tryghost/errors');
@ -64,6 +65,14 @@ const Integration = ghostBookshelf.Model.extend({
return options;
},
defaultRelations: function defaultRelations(methodName, options) {
if (['edit', 'add', 'destroy'].indexOf(methodName) !== -1) {
options.withRelated = _.union(['api_keys'], options.withRelated || []);
}
return options;
},
async permissible(integrationModel, action, context, attrs, loadedPerms, hasUserPermission, hasApiKeyPermission) {
const isAdd = (action === 'add');
@ -76,6 +85,14 @@ const Integration = ghostBookshelf.Model.extend({
if (!hasUserPermission || !hasApiKeyPermission) {
throw new NoPermissionError();
}
},
async getInternalFrontendKey(options) {
options = options || {};
options.withRelated = ['api_keys'];
return this.findOne({slug: 'ghost-internal-frontend'}, options);
}
});

View File

@ -0,0 +1,27 @@
const errors = require('@tryghost/errors');
const logging = require('@tryghost/logging');
class FrontendDataService {
constructor({IntegrationModel}) {
this.IntegrationModel = IntegrationModel;
this.frontendKey = null;
}
async getFrontendKey() {
if (this.frontendKey) {
return this.frontendKey;
}
try {
const key = await this.IntegrationModel.getInternalFrontendKey();
this.frontendKey = key.toJSON().api_keys[0].secret;
} catch (error) {
this.frontendKey = null;
logging.error(new errors.InternalServerError({message: 'Unable to find the internal frontend key', err: error}));
}
return this.frontendKey;
}
}
module.exports = FrontendDataService;

View File

@ -0,0 +1,6 @@
const models = require('../../models');
const FrontendDataService = require('./frontend-data-service');
module.exports.init = () => {
return new FrontendDataService({IntegrationModel: models.Integration});
};

View File

@ -13,7 +13,8 @@ const routing = require('../../../../core/frontend/services/routing');
const urlService = require('../../../../core/server/services/url');
const ghost_head = require('../../../../core/frontend/helpers/ghost_head');
const {settingsCache} = require('../../../../core/frontend/services/proxy');
const proxy = require('../../../../core/frontend/services/proxy');
const {settingsCache} = proxy;
describe('{{ghost_head}} helper', function () {
let posts = [];
@ -21,6 +22,8 @@ describe('{{ghost_head}} helper', function () {
let authors = [];
let users = [];
let keyStub;
const makeFixtures = () => {
const {createPost, createUser, createTag} = testUtils.DataGenerator.forKnex;
@ -278,6 +281,12 @@ describe('{{ghost_head}} helper', function () {
before(function () {
// @TODO: remove when visibility is refactored out of models
models.init();
keyStub = sinon.stub().resolves('xyz');
const dataService = {
getFrontendKey: keyStub
};
proxy.init({dataService});
});
beforeEach(function () {
@ -1657,6 +1666,7 @@ describe('{{ghost_head}} helper', function () {
})).then(function (rendered) {
should.exist(rendered);
rendered.string.should.containEql('<script defer src="https://unpkg.com/@tryghost/portal');
rendered.string.should.containEql('data-ghost="http://127.0.0.1:2369/" data-key="xyz" data-api="http://127.0.0.1:2369/ghost/api/content/"');
rendered.string.should.containEql('<style id="gh-members-styles">');
done();
}).catch(done);
@ -1674,6 +1684,7 @@ describe('{{ghost_head}} helper', function () {
})).then(function (rendered) {
should.exist(rendered);
rendered.string.should.containEql('<script defer src="https://unpkg.com/@tryghost/portal');
rendered.string.should.containEql('data-ghost="http://127.0.0.1:2369/" data-key="xyz" data-api="http://127.0.0.1:2369/ghost/api/content/"');
rendered.string.should.containEql('<style id="gh-members-styles">');
done();
}).catch(done);
@ -1694,6 +1705,7 @@ describe('{{ghost_head}} helper', function () {
})).then(function (rendered) {
should.exist(rendered);
rendered.string.should.containEql('<script defer src="https://unpkg.com/@tryghost/portal');
rendered.string.should.containEql('data-ghost="http://127.0.0.1:2369/" data-key="xyz" data-api="http://127.0.0.1:2369/ghost/api/content/"');
rendered.string.should.containEql('<style id="gh-members-styles">');
rendered.string.should.containEql('<script async src="https://js.stripe.com');
done();
@ -1715,6 +1727,7 @@ describe('{{ghost_head}} helper', function () {
})).then(function (rendered) {
should.exist(rendered);
rendered.string.should.containEql('<script defer src="https://unpkg.com/@tryghost/portal');
rendered.string.should.containEql('data-ghost="http://127.0.0.1:2369/" data-key="xyz" data-api="http://127.0.0.1:2369/ghost/api/content/"');
rendered.string.should.containEql('<style id="gh-members-styles">');
rendered.string.should.containEql('<script async src="https://js.stripe.com');
done();
@ -1734,6 +1747,7 @@ describe('{{ghost_head}} helper', function () {
})).then(function (rendered) {
should.exist(rendered);
rendered.string.should.not.containEql('<script defer src="https://unpkg.com/@tryghost/portal');
rendered.string.should.not.containEql('data-ghost="http://127.0.0.1:2369/" data-key="xyz" data-api="http://127.0.0.1:2369/ghost/api/content/"');
rendered.string.should.not.containEql('<style id="gh-members-styles">');
rendered.string.should.not.containEql('<script async src="https://js.stripe.com');
done();
@ -1755,6 +1769,7 @@ describe('{{ghost_head}} helper', function () {
})).then(function (rendered) {
should.exist(rendered);
rendered.string.should.containEql('<script defer src="https://unpkg.com/@tryghost/portal');
rendered.string.should.containEql('data-ghost="http://127.0.0.1:2369/" data-key="xyz" data-api="http://127.0.0.1:2369/ghost/api/content/"');
rendered.string.should.containEql('<style id="gh-members-styles">');
rendered.string.should.not.containEql('<script async src="https://js.stripe.com');
done();

View File

@ -68,4 +68,34 @@ describe('Unit: models/integration', function () {
});
});
});
describe('getInternalFrontendKey', function () {
const mockDb = require('mock-knex');
let tracker;
before(function () {
mockDb.mock(knex);
tracker = mockDb.getTracker();
});
after(function () {
mockDb.unmock(knex);
});
it('generates correct query', function () {
const queries = [];
tracker.install();
tracker.on('query', (query) => {
queries.push(query);
query.response([]);
});
return models.Integration.getInternalFrontendKey().then(() => {
queries.length.should.eql(1);
queries[0].sql.should.eql('select `integrations`.* from `integrations` where `integrations`.`slug` = ? limit ?');
queries[0].bindings.should.eql(['ghost-internal-frontend', 1]);
});
});
});
});

View File

@ -0,0 +1,63 @@
const assert = require('assert');
const sinon = require('sinon');
const models = require('../../../../../core/server/models');
const FrontendDataService = require('../../../../../core/server/services/frontend-data-service/frontend-data-service');
describe('Frontend Data Service', function () {
let service, modelStub, fakeModel;
before(function () {
models.init();
});
beforeEach(function () {
fakeModel = {
toJSON: () => {
return {api_keys: [{secret: 'xyz'}]};
}
};
modelStub = sinon.stub(models.Integration, 'getInternalFrontendKey');
service = new FrontendDataService({
IntegrationModel: models.Integration
});
});
afterEach(function () {
sinon.restore();
});
it('returns null if anything goes wrong', async function () {
modelStub.returns();
const key = await service.getFrontendKey();
sinon.assert.calledOnce(modelStub);
assert.equal(key, null);
});
it('returns the key from a model response', async function () {
modelStub.returns(fakeModel);
const key = await service.getFrontendKey();
sinon.assert.calledOnce(modelStub);
assert.equal(key, 'xyz');
});
it('returns the key from cache the second time', async function () {
modelStub.returns(fakeModel);
let key = await service.getFrontendKey();
sinon.assert.calledOnce(modelStub);
assert.equal(key, 'xyz');
key = await service.getFrontendKey();
sinon.assert.calledOnce(modelStub);
assert.equal(key, 'xyz');
});
});

View File

@ -0,0 +1,24 @@
const models = require('../../../../../core/server/models');
const assert = require('assert');
describe('Frontend Data Service', function () {
let frontendDataService;
before(function () {
models.init();
});
it('Provides expected public API', async function () {
frontendDataService = require('../../../../../core/server/services/frontend-data-service');
assert.ok(frontendDataService.init);
});
it('init gets an integration model', function () {
frontendDataService = require('../../../../../core/server/services/frontend-data-service');
let instance = frontendDataService.init();
assert.ok(instance.IntegrationModel);
assert.equal(instance.frontendKey, null);
});
});

View File

@ -800,6 +800,13 @@
"description": "Internal Scheduler integration",
"type": "internal",
"api_keys": [{"type": "admin", "role": "Scheduler Integration"}]
},
{
"slug": "ghost-internal-frontend",
"name": "Ghost Internal Frontend",
"description": "Internal frontend integration",
"type": "internal",
"api_keys": [{"type": "content"}]
}
]
}