Added initial concept of calculated settings (#14766)

refs: https://github.com/TryGhost/Team/issues/626

- calculated settings are simplified settings (booleans) that are based on other settings or data
- they make it easier for us to determine what state features are in elsewhere in ghost e.g. admin and themes
- this duplicates some of the members config concepts in the settings service
This commit is contained in:
Hannah Wolfe 2022-05-10 21:49:38 +01:00 committed by GitHub
parent 54b4a3c351
commit c5ba27e2b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 189 additions and 17 deletions

View File

@ -2,13 +2,23 @@
* Settings Lib
* A collection of utilities for handling settings including a cache
*/
const errors = require('@tryghost/errors');
const tpl = require('@tryghost/tpl');
const events = require('../../lib/common/events');
const models = require('../../models');
const labs = require('../../../shared/labs');
const config = require('../../../shared/config');
const SettingsCache = require('../../../shared/settings-cache');
const SettingsBREADService = require('./settings-bread-service');
const {obfuscatedSetting, isSecretSetting, hideValueIfSecret} = require('./settings-utils');
const ObjectId = require('bson-objectid');
const messages = {
incorrectKeyType: 'type must be one of "direct" or "connect".'
};
/**
* @returns {SettingsBREADService} instance of the PostsService
*/
@ -20,13 +30,36 @@ const getSettingsBREADServiceInstance = () => {
});
};
class CalculatedField {
constructor({key, type, group, fn, dependents}) {
this.key = key;
this.type = type;
this.group = group;
this.fn = fn;
this.dependents = dependents;
}
getSetting() {
return {
key: this.key,
type: this.type,
group: this.group,
value: this.fn(),
// @TODO: remove this hack
id: ObjectId().toHexString(),
created_at: new Date().toISOString().replace(/\d{3}Z$/, '000Z'),
updated_at: new Date().toISOString().replace(/\d{3}Z$/, '000Z')
};
}
}
module.exports = {
/**
* Initialize the cache, used in boot and in testing
*/
async init() {
const settingsCollection = await models.Settings.populateDefaults();
SettingsCache.init(events, settingsCollection);
SettingsCache.init(events, settingsCollection, this.getCalculatedFields());
},
/**
@ -36,6 +69,72 @@ module.exports = {
SettingsCache.reset(events);
},
isMembersEnabled() {
return SettingsCache.get('members_signup_access') !== 'none';
},
isMembersInviteOnly() {
return SettingsCache.get('members_signup_access') === 'invite';
},
/**
* @param {'direct' | 'connect'} type - The "type" of keys to fetch from settings
* @returns {{publicKey: string, secretKey: string} | null}
*/
getStripeKeys(type) {
if (type !== 'direct' && type !== 'connect') {
throw new errors.IncorrectUsageError({message: tpl(messages.incorrectKeyType)});
}
const secretKey = SettingsCache.get(`stripe_${type === 'connect' ? 'connect_' : ''}secret_key`);
const publicKey = SettingsCache.get(`stripe_${type === 'connect' ? 'connect_' : ''}publishable_key`);
if (!secretKey || !publicKey) {
return null;
}
return {
secretKey,
publicKey
};
},
/**
* @returns {{publicKey: string, secretKey: string} | null}
*/
getActiveStripeKeys() {
const stripeDirect = config.get('stripeDirect');
if (stripeDirect) {
return this.getStripeKeys('direct');
}
const connectKeys = this.getStripeKeys('connect');
if (!connectKeys) {
return this.getStripeKeys('direct');
}
return connectKeys;
},
isStripeConnected() {
return this.isMembersEnabled() && this.getActiveStripeKeys() !== null;
},
/**
*
*/
getCalculatedFields() {
const fields = [];
fields.push(new CalculatedField({key: 'members_enabled', type: 'boolean', group: 'members', fn: this.isMembersEnabled.bind(this), dependents: ['members_signup_access']}));
fields.push(new CalculatedField({key: 'members_invite_only', type: 'boolean', group: 'members', fn: this.isMembersInviteOnly.bind(this), dependents: ['members_signup_access']}));
fields.push(new CalculatedField({key: 'paid_members_enabled', type: 'boolean', group: 'members', fn: this.isStripeConnected.bind(this), dependents: ['members_signup_access', 'stripe_secret_key', 'stripe_publishable_key', 'stripe_connect_secret_key', 'stripe_connect_publishable_key']}));
return fields;
},
/**
* Handles synchronization of routes.yaml hash loaded in the frontend with
* the value stored in the settings table.

View File

@ -3,6 +3,7 @@
// circular dependency bugs.
const debug = require('@tryghost/debug')('settings:cache');
const _ = require('lodash');
const publicSettings = require('./public');
// Local function, only ever used for initializing
@ -12,6 +13,13 @@ const updateSettingFromModel = function updateSettingFromModel(settingModel) {
module.exports.set(settingModel.get('key'), settingModel.toJSON());
};
const updateCalculatedField = function updateCalculatedField(field) {
return () => {
debug('Auto updating', field.key);
module.exports.set(field.key, field.getSetting());
};
};
/**
* ## Cache
* Holds cached settings
@ -20,6 +28,7 @@ const updateSettingFromModel = function updateSettingFromModel(settingModel) {
* @type {{}} - object of objects
*/
let settingsCache = {};
let _calculatedFields = [];
const doGet = (key, options) => {
if (!settingsCache[key]) {
@ -116,10 +125,11 @@ module.exports = {
*
* @param {EventEmitter} events
* @param {Bookshelf.Collection<Settings>} settingsCollection
* @param {Array} calculatedFields
* @return {object}
*/
init(events, settingsCollection) {
// First, reset the cache and listeners
init(events, settingsCollection, calculatedFields) {
// First, reset the cache and
this.reset(events);
// // if we have been passed a collection of settings, use this to populate the cache
@ -127,11 +137,21 @@ module.exports = {
_.each(settingsCollection.models, updateSettingFromModel);
}
_calculatedFields = Array.isArray(calculatedFields) ? calculatedFields : [];
// Bind to events to automatically keep up-to-date
events.on('settings.edited', updateSettingFromModel);
events.on('settings.added', updateSettingFromModel);
events.on('settings.deleted', updateSettingFromModel);
// set and bind calculated fields
_calculatedFields.forEach((field) => {
updateCalculatedField(field)();
field.dependents.forEach((dependent) => {
events.on(`settings.${dependent}.edited`, updateCalculatedField(field));
});
});
return settingsCache;
},
@ -145,5 +165,14 @@ module.exports = {
events.removeListener('settings.edited', updateSettingFromModel);
events.removeListener('settings.added', updateSettingFromModel);
events.removeListener('settings.deleted', updateSettingFromModel);
//unbind calculated fields
_calculatedFields.forEach((field) => {
field.dependents.forEach((dependent) => {
events.removeListener(`settings.${dependent}.edited`, updateCalculatedField(field));
});
});
_calculatedFields = [];
}
};

View File

@ -26,5 +26,8 @@ module.exports = {
twitter_image: 'twitter_image',
twitter_title: 'twitter_title',
twitter_description: 'twitter_description',
members_support_address: 'members_support_address'
members_support_address: 'members_support_address',
members_enabled: 'members_enabled',
members_invite_only: 'members_invite_only',
paid_members_enabled: 'paid_members_enabled'
};

View File

@ -1055,6 +1055,33 @@ Object {
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"value": false,
},
Object {
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"group": "members",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"key": "members_enabled",
"type": "boolean",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"value": true,
},
Object {
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"group": "members",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"key": "members_invite_only",
"type": "boolean",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"value": false,
},
Object {
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"group": "members",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"key": "paid_members_enabled",
"type": "boolean",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"value": true,
},
Object {
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"flags": null,
@ -1115,7 +1142,7 @@ exports[`Settings API Can request all settings 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "18299",
"content-length": "18867",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",

View File

@ -2,7 +2,7 @@ const assert = require('assert');
const {agentProvider, fixtureManager, mockManager, matchers} = require('../../utils/e2e-framework');
const {stringMatching, anyEtag, anyObjectId, anyISODateTime} = matchers;
const CURRENT_SETTINGS_COUNT = 86;
const CURRENT_SETTINGS_COUNT = 89;
const settingsMatcher = {
id: anyObjectId,

View File

@ -14,6 +14,8 @@ Object {
"lang": "en",
"locale": "en",
"logo": null,
"members_enabled": true,
"members_invite_only": false,
"members_support_address": "noreply",
"meta_description": null,
"meta_title": null,
@ -42,6 +44,7 @@ Object {
"og_description": null,
"og_image": null,
"og_title": null,
"paid_members_enabled": true,
"secondary_navigation": Array [
Object {
"label": "Data & privacy",
@ -83,9 +86,9 @@ exports[`Settings Content API Can request settings 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "*",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "942",
"content-length": "1021",
"content-type": "application/json; charset=utf-8",
"etag": "W/\\"3ae-FBGPtlUjSvGtTGLOj2sW5Rbn33s\\"",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Encoding",
"x-powered-by": "Express",
}

View File

@ -1,4 +1,5 @@
const {agentProvider, fixtureManager, matchers} = require('../../utils/e2e-framework');
const {anyEtag} = matchers;
describe('Settings Content API', function () {
let agent;
@ -12,7 +13,9 @@ describe('Settings Content API', function () {
it('Can request settings', async function () {
await agent.get('settings/')
.expectStatus(200)
.matchHeaderSnapshot()
.matchHeaderSnapshot({
etag: anyEtag
})
.matchBodySnapshot();
});
});

View File

@ -492,6 +492,8 @@ Object {
"lang": "en",
"locale": "en",
"logo": null,
"members_enabled": true,
"members_invite_only": false,
"members_support_address": "noreply",
"meta_description": null,
"meta_title": null,
@ -520,6 +522,7 @@ Object {
"og_description": null,
"og_image": null,
"og_title": null,
"paid_members_enabled": true,
"secondary_navigation": Array [
Object {
"label": "Data & privacy",
@ -549,10 +552,10 @@ exports[`API Versioning Content API responds with current content version header
Object {
"access-control-allow-origin": "*",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "942",
"content-length": "1021",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": "W/\\"3ae-FBGPtlUjSvGtTGLOj2sW5Rbn33s\\"",
"etag": "W/\\"3fd-PXBX1gYn1ftAeK+GoVbEnAsmVAE\\"",
"vary": "Accept-Encoding",
"x-powered-by": "Express",
}
@ -572,6 +575,8 @@ Object {
"lang": "en",
"locale": "en",
"logo": null,
"members_enabled": true,
"members_invite_only": false,
"members_support_address": "noreply",
"meta_description": null,
"meta_title": null,
@ -600,6 +605,7 @@ Object {
"og_description": null,
"og_image": null,
"og_title": null,
"paid_members_enabled": true,
"secondary_navigation": Array [
Object {
"label": "Data & privacy",
@ -629,9 +635,9 @@ exports[`API Versioning Content API responds with no content version header when
Object {
"access-control-allow-origin": "*",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "942",
"content-length": "1021",
"content-type": "application/json; charset=utf-8",
"etag": "W/\\"3ae-FBGPtlUjSvGtTGLOj2sW5Rbn33s\\"",
"etag": "W/\\"3fd-PXBX1gYn1ftAeK+GoVbEnAsmVAE\\"",
"vary": "Accept-Encoding",
"x-powered-by": "Express",
}

View File

@ -441,6 +441,8 @@ const defaultSettingsKeyTypes = [
}
];
const calculatedSettingsTypes = ['members_enabled', 'members_invite_only', 'paid_members_enabled'];
describe('Settings API (canary)', function () {
let request;
@ -467,7 +469,7 @@ describe('Settings API (canary)', function () {
jsonResponse.settings.should.be.an.Object();
const settings = jsonResponse.settings;
should.equal(settings.length, defaultSettingsKeyTypes.length);
should.equal(settings.length, (defaultSettingsKeyTypes.length + calculatedSettingsTypes.length));
for (const defaultSetting of defaultSettingsKeyTypes) {
should.exist(settings.find((setting) => {
return (setting.key === defaultSetting.key)
@ -500,7 +502,7 @@ describe('Settings API (canary)', function () {
jsonResponse.settings.should.be.an.Object();
const settings = jsonResponse.settings;
// Returns all settings
should.equal(settings.length, defaultSettingsKeyTypes.length);
should.equal(settings.length, (defaultSettingsKeyTypes.length + calculatedSettingsTypes.length));
for (const defaultSetting of defaultSettingsKeyTypes) {
should.exist(settings.find((setting) => {
return setting.key === defaultSetting.key && setting.type === defaultSetting.type;
@ -553,7 +555,7 @@ describe('Settings API (canary)', function () {
jsonResponse.settings.should.be.an.Object();
const settings = jsonResponse.settings;
Object.keys(settings).length.should.equal(defaultSettingsKeyTypes.length);
Object.keys(settings).length.should.equal((defaultSettingsKeyTypes.length + calculatedSettingsTypes.length));
localUtils.API.checkResponse(jsonResponse, 'settings');
});

View File

@ -11,7 +11,7 @@ should.equal(true, true);
describe('UNIT: settings cache', function () {
beforeEach(function () {
cache.init(events, {});
cache.init(events, {}, []);
});
afterEach(function () {