0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-08 02:52:39 -05:00

Added ability to override settings via config (#22089)

ref
https://linear.app/ghost/issue/ENG-1974/create-config-option-to-forcibly-disable-email-track-clicks

- We want to have the ability to turn off click tracking for sites that
are adversely impacted by massive bursts of traffic from email link
checkers, but we don't currently have a pattern for this.
- This commit introduces a new configuration parameters
`hostSettings:settingsOverrides`, which accepts key/value pairs of
settings keys -> values. The value passed in here will override whatever
value is set for the associated setting key in the database
- It also adds an `is_read_only: true` property to any setting that is
overridden, which is included in the /api/admin/settings endpoint. This
value can be used by the frontend to disable the control to prevent a
user from trying to change the value.
- The value in the database is preserved, as the override is implemented
in the settings cache `get()` and `getAll()` methods.
- This commit only includes the backend changes — another commit will
follow to allow disabling the 'Newsletter clicks' toggle in Admin's
settings.
This commit is contained in:
Chris Raible 2025-01-30 17:05:42 -08:00 committed by GitHub
parent 1d0091506b
commit 3ee9f43f6c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 869 additions and 10 deletions

View file

@ -3,7 +3,7 @@ const _ = require('lodash');
module.exports = (attrs) => {
if (Array.isArray(attrs)) {
return attrs.map((setting) => {
return _.pick(setting, ['key', 'value']);
return _.pick(setting, ['key', 'value', 'is_read_only']);
});
}

View file

@ -5,6 +5,7 @@
const events = require('../../lib/common/events');
const models = require('../../models');
const labs = require('../../../shared/labs');
const config = require('../../../shared/config');
const adapterManager = require('../adapter-manager');
const SettingsCache = require('../../../shared/settings-cache');
const SettingsBREADService = require('./SettingsBREADService');
@ -71,7 +72,8 @@ module.exports = {
async init() {
const cacheStore = adapterManager.getAdapter('cache:settings');
const settingsCollection = await models.Settings.populateDefaults();
SettingsCache.init(events, settingsCollection, this.getCalculatedFields(), cacheStore);
const settingsOverrides = config.get('hostSettings:settingsOverrides') || {};
SettingsCache.init(events, settingsCollection, this.getCalculatedFields(), cacheStore, settingsOverrides);
},
/**

View file

@ -19,6 +19,7 @@ class CacheManager {
constructor({publicSettings}) {
// settingsCache holds cached settings, keyed by setting.key, contains the JSON version of the model
this.settingsCache;
this.settingsOverrides = {};
this.publicSettings = publicSettings;
this.calculatedFields = [];
@ -53,8 +54,20 @@ class CacheManager {
if (!this.settingsCache) {
return;
}
let override;
if (this.settingsOverrides && Object.keys(this.settingsOverrides).includes(key)) {
// Wrap the override value in an object in case it's a boolean
override = {value: this.settingsOverrides[key]};
}
const cacheEntry = this.settingsCache.get(key);
if (override) {
cacheEntry.value = override.value;
cacheEntry.is_read_only = true;
}
if (!cacheEntry) {
return;
}
@ -142,7 +155,7 @@ class CacheManager {
const all = {};
keys.forEach((key) => {
all[key] = _.cloneDeep(this.settingsCache.get(key));
all[key] = _.cloneDeep(this.get(key, {resolve: false}));
});
return all;
@ -172,10 +185,12 @@ class CacheManager {
* @param {Bookshelf.Collection<Settings>} settingsCollection
* @param {Array} calculatedFields
* @param {Object} cacheStore - cache storage instance base on Cache Base Adapter
* @param {Object} settingsOverrides - key/value pairs of settings which are overridden (i.e. via config)
* @return {Object} - filled out instance for Cache Base Adapter
*/
init(events, settingsCollection, calculatedFields, cacheStore) {
init(events, settingsCollection, calculatedFields, cacheStore, settingsOverrides) {
this.settingsCache = cacheStore;
this.settingsOverrides = settingsOverrides;
// First, reset the cache and
this.reset(events);

View file

@ -5749,6 +5749,755 @@ Object {
}
`;
exports[`Settings API Settings overrides prevents modification of overridden settings via API 1: [body] 1`] = `
Object {
"meta": Object {},
"settings": Array [
Object {
"key": "members_track_sources",
"value": true,
},
Object {
"key": "title",
"value": null,
},
Object {
"key": "description",
"value": "Thoughts, stories and ideas",
},
Object {
"key": "logo",
"value": "",
},
Object {
"key": "cover_image",
"value": "https://static.ghost.org/v5.0.0/images/publication-cover.jpg",
},
Object {
"key": "icon",
"value": "http://127.0.0.1:2369/content/images/size/w256h256/2019/07/icon.png",
},
Object {
"key": "accent_color",
"value": "#FF1A75",
},
Object {
"key": "locale",
"value": "ua",
},
Object {
"key": "timezone",
"value": "Pacific/Auckland",
},
Object {
"key": "codeinjection_head",
"value": null,
},
Object {
"key": "codeinjection_foot",
"value": "",
},
Object {
"key": "facebook",
"value": "ghost",
},
Object {
"key": "twitter",
"value": "@ghost",
},
Object {
"key": "navigation",
"value": "[{\\"label\\":\\"label1\\"}]",
},
Object {
"key": "secondary_navigation",
"value": "[{\\"label\\":\\"Data & privacy\\",\\"url\\":\\"/privacy/\\"},{\\"label\\":\\"Contact\\",\\"url\\":\\"/contact/\\"},{\\"label\\":\\"Contribute →\\",\\"url\\":\\"/contribute/\\"}]",
},
Object {
"key": "meta_title",
"value": "SEO title",
},
Object {
"key": "meta_description",
"value": "SEO description",
},
Object {
"key": "og_image",
"value": "http://127.0.0.1:2369/content/images/2019/07/facebook.png",
},
Object {
"key": "og_title",
"value": "facebook title",
},
Object {
"key": "og_description",
"value": "facebook description",
},
Object {
"key": "twitter_image",
"value": "http://127.0.0.1:2369/content/images/2019/07/twitter.png",
},
Object {
"key": "twitter_title",
"value": "twitter title",
},
Object {
"key": "twitter_description",
"value": "twitter description",
},
Object {
"key": "active_theme",
"value": "source",
},
Object {
"key": "is_private",
"value": false,
},
Object {
"key": "password",
"value": "",
},
Object {
"key": "public_hash",
"value": StringMatching /\\[a-z0-9\\]\\{30\\}/,
},
Object {
"key": "default_content_visibility",
"value": "public",
},
Object {
"key": "default_content_visibility_tiers",
"value": "[]",
},
Object {
"key": "members_signup_access",
"value": "all",
},
Object {
"key": "members_support_address",
"value": "default@email.com",
},
Object {
"key": "stripe_secret_key",
"value": null,
},
Object {
"key": "stripe_publishable_key",
"value": null,
},
Object {
"key": "stripe_plans",
"value": "[]",
},
Object {
"key": "stripe_connect_publishable_key",
"value": null,
},
Object {
"key": "stripe_connect_secret_key",
"value": null,
},
Object {
"key": "stripe_connect_livemode",
"value": null,
},
Object {
"key": "stripe_connect_display_name",
"value": null,
},
Object {
"key": "stripe_connect_account_id",
"value": null,
},
Object {
"key": "members_monthly_price_id",
"value": null,
},
Object {
"key": "members_yearly_price_id",
"value": null,
},
Object {
"key": "portal_name",
"value": true,
},
Object {
"key": "portal_button",
"value": true,
},
Object {
"key": "portal_plans",
"value": "[\\"free\\"]",
},
Object {
"key": "portal_default_plan",
"value": "yearly",
},
Object {
"key": "portal_products",
"value": "[]",
},
Object {
"key": "portal_button_style",
"value": "icon-and-text",
},
Object {
"key": "portal_button_icon",
"value": null,
},
Object {
"key": "portal_button_signup_text",
"value": "Subscribe",
},
Object {
"key": "portal_signup_terms_html",
"value": null,
},
Object {
"key": "portal_signup_checkbox_required",
"value": false,
},
Object {
"key": "mailgun_domain",
"value": null,
},
Object {
"key": "mailgun_api_key",
"value": null,
},
Object {
"key": "mailgun_base_url",
"value": null,
},
Object {
"key": "email_track_opens",
"value": true,
},
Object {
"is_read_only": true,
"key": "email_track_clicks",
"value": false,
},
Object {
"key": "email_verification_required",
"value": false,
},
Object {
"key": "amp",
"value": false,
},
Object {
"key": "amp_gtag_id",
"value": null,
},
Object {
"key": "firstpromoter",
"value": false,
},
Object {
"key": "firstpromoter_id",
"value": null,
},
Object {
"key": "labs",
"value": StringMatching /\\\\\\{\\[\\^\\\\s\\]\\+\\\\\\}/,
},
Object {
"key": "slack_url",
"value": "",
},
Object {
"key": "slack_username",
"value": "New Slack Username",
},
Object {
"key": "unsplash",
"value": false,
},
Object {
"key": "shared_views",
"value": "[]",
},
Object {
"key": "editor_default_email_recipients",
"value": "visibility",
},
Object {
"key": "editor_default_email_recipients_filter",
"value": "all",
},
Object {
"key": "announcement_content",
"value": "<p>Great news coming soon!</p>",
},
Object {
"key": "announcement_visibility",
"value": "[\\"visitors\\",\\"free_members\\"]",
},
Object {
"key": "announcement_background",
"value": "dark",
},
Object {
"key": "comments_enabled",
"value": "off",
},
Object {
"key": "outbound_link_tagging",
"value": true,
},
Object {
"key": "pintura",
"value": true,
},
Object {
"key": "pintura_js_url",
"value": null,
},
Object {
"key": "pintura_css_url",
"value": null,
},
Object {
"key": "donations_currency",
"value": "USD",
},
Object {
"key": "donations_suggested_amount",
"value": "500",
},
Object {
"key": "recommendations_enabled",
"value": false,
},
Object {
"key": "members_enabled",
"value": true,
},
Object {
"key": "members_invite_only",
"value": false,
},
Object {
"key": "allow_self_signup",
"value": true,
},
Object {
"key": "paid_members_enabled",
"value": false,
},
Object {
"key": "firstpromoter_account",
"value": null,
},
Object {
"key": "donations_enabled",
"value": false,
},
Object {
"key": "default_email_address",
"value": "noreply@127.0.0.1",
},
Object {
"key": "support_email_address",
"value": "default@email.com",
},
Object {
"key": "all_blocked_email_domains",
"value": Array [],
},
],
}
`;
exports[`Settings API Settings overrides prevents modification of overridden settings via API 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": StringMatching /\\\\d\\+/,
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-cache-invalidate": "/*",
"x-powered-by": "Express",
}
`;
exports[`Settings API Settings overrides respects settings overrides defined in hostSettings:settingsOverrides 1: [body] 1`] = `
Object {
"meta": Object {},
"settings": Array [
Object {
"key": "members_track_sources",
"value": true,
},
Object {
"key": "title",
"value": null,
},
Object {
"key": "description",
"value": "Thoughts, stories and ideas",
},
Object {
"key": "logo",
"value": "",
},
Object {
"key": "cover_image",
"value": "https://static.ghost.org/v5.0.0/images/publication-cover.jpg",
},
Object {
"key": "icon",
"value": "http://127.0.0.1:2369/content/images/size/w256h256/2019/07/icon.png",
},
Object {
"key": "accent_color",
"value": "#FF1A75",
},
Object {
"key": "locale",
"value": "ua",
},
Object {
"key": "timezone",
"value": "Pacific/Auckland",
},
Object {
"key": "codeinjection_head",
"value": null,
},
Object {
"key": "codeinjection_foot",
"value": "",
},
Object {
"key": "facebook",
"value": "ghost",
},
Object {
"key": "twitter",
"value": "@ghost",
},
Object {
"key": "navigation",
"value": "[{\\"label\\":\\"label1\\"}]",
},
Object {
"key": "secondary_navigation",
"value": "[{\\"label\\":\\"Data & privacy\\",\\"url\\":\\"/privacy/\\"},{\\"label\\":\\"Contact\\",\\"url\\":\\"/contact/\\"},{\\"label\\":\\"Contribute →\\",\\"url\\":\\"/contribute/\\"}]",
},
Object {
"key": "meta_title",
"value": "SEO title",
},
Object {
"key": "meta_description",
"value": "SEO description",
},
Object {
"key": "og_image",
"value": "http://127.0.0.1:2369/content/images/2019/07/facebook.png",
},
Object {
"key": "og_title",
"value": "facebook title",
},
Object {
"key": "og_description",
"value": "facebook description",
},
Object {
"key": "twitter_image",
"value": "http://127.0.0.1:2369/content/images/2019/07/twitter.png",
},
Object {
"key": "twitter_title",
"value": "twitter title",
},
Object {
"key": "twitter_description",
"value": "twitter description",
},
Object {
"key": "active_theme",
"value": "source",
},
Object {
"key": "is_private",
"value": false,
},
Object {
"key": "password",
"value": "",
},
Object {
"key": "public_hash",
"value": StringMatching /\\[a-z0-9\\]\\{30\\}/,
},
Object {
"key": "default_content_visibility",
"value": "public",
},
Object {
"key": "default_content_visibility_tiers",
"value": "[]",
},
Object {
"key": "members_signup_access",
"value": "all",
},
Object {
"key": "members_support_address",
"value": "default@email.com",
},
Object {
"key": "stripe_secret_key",
"value": null,
},
Object {
"key": "stripe_publishable_key",
"value": null,
},
Object {
"key": "stripe_plans",
"value": "[]",
},
Object {
"key": "stripe_connect_publishable_key",
"value": null,
},
Object {
"key": "stripe_connect_secret_key",
"value": null,
},
Object {
"key": "stripe_connect_livemode",
"value": null,
},
Object {
"key": "stripe_connect_display_name",
"value": null,
},
Object {
"key": "stripe_connect_account_id",
"value": null,
},
Object {
"key": "members_monthly_price_id",
"value": null,
},
Object {
"key": "members_yearly_price_id",
"value": null,
},
Object {
"key": "portal_name",
"value": true,
},
Object {
"key": "portal_button",
"value": true,
},
Object {
"key": "portal_plans",
"value": "[\\"free\\"]",
},
Object {
"key": "portal_default_plan",
"value": "yearly",
},
Object {
"key": "portal_products",
"value": "[]",
},
Object {
"key": "portal_button_style",
"value": "icon-and-text",
},
Object {
"key": "portal_button_icon",
"value": null,
},
Object {
"key": "portal_button_signup_text",
"value": "Subscribe",
},
Object {
"key": "portal_signup_terms_html",
"value": null,
},
Object {
"key": "portal_signup_checkbox_required",
"value": false,
},
Object {
"key": "mailgun_domain",
"value": null,
},
Object {
"key": "mailgun_api_key",
"value": null,
},
Object {
"key": "mailgun_base_url",
"value": null,
},
Object {
"key": "email_track_opens",
"value": true,
},
Object {
"is_read_only": true,
"key": "email_track_clicks",
"value": false,
},
Object {
"key": "email_verification_required",
"value": false,
},
Object {
"key": "amp",
"value": false,
},
Object {
"key": "amp_gtag_id",
"value": null,
},
Object {
"key": "firstpromoter",
"value": false,
},
Object {
"key": "firstpromoter_id",
"value": null,
},
Object {
"key": "labs",
"value": StringMatching /\\\\\\{\\[\\^\\\\s\\]\\+\\\\\\}/,
},
Object {
"key": "slack_url",
"value": "",
},
Object {
"key": "slack_username",
"value": "New Slack Username",
},
Object {
"key": "unsplash",
"value": false,
},
Object {
"key": "shared_views",
"value": "[]",
},
Object {
"key": "editor_default_email_recipients",
"value": "visibility",
},
Object {
"key": "editor_default_email_recipients_filter",
"value": "all",
},
Object {
"key": "announcement_content",
"value": "<p>Great news coming soon!</p>",
},
Object {
"key": "announcement_visibility",
"value": "[\\"visitors\\",\\"free_members\\"]",
},
Object {
"key": "announcement_background",
"value": "dark",
},
Object {
"key": "comments_enabled",
"value": "off",
},
Object {
"key": "outbound_link_tagging",
"value": true,
},
Object {
"key": "pintura",
"value": true,
},
Object {
"key": "pintura_js_url",
"value": null,
},
Object {
"key": "pintura_css_url",
"value": null,
},
Object {
"key": "donations_currency",
"value": "USD",
},
Object {
"key": "donations_suggested_amount",
"value": "500",
},
Object {
"key": "recommendations_enabled",
"value": false,
},
Object {
"key": "members_enabled",
"value": true,
},
Object {
"key": "members_invite_only",
"value": false,
},
Object {
"key": "allow_self_signup",
"value": true,
},
Object {
"key": "paid_members_enabled",
"value": false,
},
Object {
"key": "firstpromoter_account",
"value": null,
},
Object {
"key": "donations_enabled",
"value": false,
},
Object {
"key": "default_email_address",
"value": "noreply@127.0.0.1",
},
Object {
"key": "support_email_address",
"value": "default@email.com",
},
Object {
"key": "all_blocked_email_domains",
"value": Array [],
},
],
}
`;
exports[`Settings API Settings overrides respects settings overrides defined in hostSettings:settingsOverrides 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": StringMatching /\\\\d\\+/,
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Settings API deprecated can do updateMembersEmail 1: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",

View file

@ -968,7 +968,7 @@ describe('Members API', function () {
await agent.delete(`/members/${memberFailVerification.id}`);
await configUtils.restore();
settingsCache.set('email_verification_required', false);
settingsCache.set('email_verification_required', {value: false});
});
it('Can add and send a signup confirmation email', async function () {

View file

@ -659,4 +659,47 @@ describe('Settings API', function () {
mockManager.assert.sentEmailCount(0);
});
});
describe('Settings overrides', function () {
this.beforeEach(async function () {
const settingsOverrides = {
email_track_clicks: false
};
configUtils.set('hostSettings:settingsOverrides', settingsOverrides);
await fixtureManager.init();
});
it('respects settings overrides defined in hostSettings:settingsOverrides', async function () {
await agent.get('settings/')
.expectStatus(200)
.matchBodySnapshot({
settings: matchSettingsArray(CURRENT_SETTINGS_COUNT)
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
'content-length': anyContentLength,
etag: anyEtag
});
});
it('prevents modification of overridden settings via API', async function () {
await agent.put('settings/')
.body({
settings: [{
key: 'email_track_clicks',
value: true
}]
})
.expectStatus(200)
.matchBodySnapshot({
settings: matchSettingsArray(CURRENT_SETTINGS_COUNT)
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
'content-length': anyContentLength,
etag: anyEtag
});
});
});
});

View file

@ -10,15 +10,20 @@ const InMemoryCache = require('../../../core/server/adapters/cache/MemoryCache')
should.equal(true, true);
function createCacheManager(settingsOverrides = {}) {
const cacheStore = new InMemoryCache();
const cache = new CacheManager({
publicSettings
});
cache.init(events, {}, [], cacheStore, settingsOverrides);
return cache;
}
describe('UNIT: settings cache', function () {
let cache;
beforeEach(function () {
let cacheStore = new InMemoryCache();
cache = new CacheManager({
publicSettings
});
cache.init(events, {}, [], cacheStore);
cache = createCacheManager();
});
afterEach(function () {
@ -93,12 +98,57 @@ describe('UNIT: settings cache', function () {
should(cache.get('stringarr')).eql([]);
});
it('.get() respects settingsOverrides', function () {
cache = createCacheManager({
email_track_clicks: false
});
cache.set('email_track_clicks', {value: true});
should(cache.get('email_track_clicks')).eql(false);
should(cache.get('email_track_clicks', {resolve: false})).eql({value: false, is_read_only: true});
});
it('.get() only returns an override if the key is set to begin with', function () {
should(cache.get('email_track_clicks', {resolve: false})).eql(undefined);
});
it('.getAll() returns all values', function () {
cache.set('key1', {value: '1'});
cache.get('key1').should.eql('1');
cache.getAll().should.eql({key1: {value: '1'}});
});
it('.getAll() respects settingsOverrides', function () {
cache = createCacheManager({
email_track_clicks: false
});
cache.set('email_track_clicks', {
id: '67996cef430e5905ab385357',
group: 'email',
key: 'email_track_clicks',
value: true,
type: 'boolean'
});
cache.getAll().should.eql({email_track_clicks: {
id: '67996cef430e5905ab385357',
group: 'email',
key: 'email_track_clicks',
value: false,
is_read_only: true,
type: 'boolean'
}});
});
it('handles multiple settingsOverrides correctly', function () {
cache = createCacheManager({
setting1: false,
setting2: 'test'
});
cache.set('setting1', {value: true});
cache.set('setting2', {value: 'original'});
should(cache.get('setting1')).eql(false);
should(cache.get('setting2')).eql('test');
});
it('.getPublic() correctly filters and formats public values', function () {
cache.set('key1', {value: 'something'});
cache.set('title', {value: 'hello world'});