UNPKG

ghost

Version:

The professional publishing platform

306 lines (269 loc) 12.8 kB
// It's important to keep the requires absolutely minimal here, // As this cache is used in SO many other areas, we may open ourselves to // circular dependency bugs. const debug = require('@tryghost/debug')('settings:cache'); const _ = require('lodash'); /** * Why hasn't this been moved to @tryghost/settings-cache yet? * * - It currently still couples the frontend and server together in a weird way via the event system * - See the notes in core/server/lib/common/events * - There's also a plan to introduce a proper caching layer, and rewrite this on top of that */ /** * @typedef {Object} PublicSettingsCache * @property {string|null} site_uuid - The blog's site UUID * @property {string|null} title - The blog's title * @property {string|null} description - The blog's description * @property {string|null} logo - URL to the blog's logo * @property {string|null} icon - URL to the blog's icon * @property {string|null} accent_color - The blog's accent color * @property {string|null} cover_image - URL to the blog's cover image * @property {string|null} facebook - Facebook page name * @property {string|null} twitter - Twitter username * @property {string|null} lang - The blog's language code * @property {string|null} locale - The blog's locale * @property {string|null} timezone - The blog's timezone * @property {string|null} codeinjection_head - Code injected into head * @property {string|null} codeinjection_foot - Code injected into footer * @property {string|null} navigation - JSON string of navigation items * @property {string|null} secondary_navigation - JSON string of secondary navigation items * @property {string|null} meta_title - Custom meta title * @property {string|null} meta_description - Custom meta description * @property {string|null} og_image - Open Graph image URL * @property {string|null} og_title - Open Graph title * @property {string|null} og_description - Open Graph description * @property {string|null} twitter_image - Twitter card image URL * @property {string|null} twitter_title - Twitter card title * @property {string|null} twitter_description - Twitter card description * @property {string|null} members_support_address - Support email for members * @property {boolean|null} members_enabled - Whether members feature is enabled * @property {boolean|null} allow_self_signup - Whether self signup is allowed * @property {boolean|null} members_invite_only - Whether membership is invite only * @property {string|null} members_signup_access - Member signup access level * @property {boolean|null} paid_members_enabled - Whether paid memberships are enabled * @property {string|null} firstpromoter_account - FirstPromoter account ID * @property {string|null} portal_button_style - Portal button style * @property {string|null} portal_button_signup_text - Portal signup button text * @property {string|null} portal_button_icon - Portal button icon * @property {string|null} portal_signup_terms_html - Portal signup terms HTML * @property {boolean|null} portal_signup_checkbox_required - Whether signup checkbox is required * @property {string|null} portal_plans - JSON string of available portal plans * @property {string|null} portal_default_plan - Default portal plan * @property {boolean|null} portal_name - Whether to show portal names * @property {boolean|null} portal_button - Whether to show the portal button * @property {boolean|null} comments_enabled - Whether comments are enabled * @property {boolean|null} recommendations_enabled - Whether recommendations are enabled * @property {boolean|null} outbound_link_tagging - Whether outbound link tagging is enabled * @property {string|null} default_email_address - Default email address * @property {string|null} support_email_address - Support email address * @property {string|null} editor_default_email_recipients - Default email recipients for editor * @property {string|null} labs - JSON string of enabled labs features * @property {boolean|null} social_web_enabled - Whether social web is enabled * @property {boolean|null} web_analytics_enabled - Whether web analytics is enabled * @property {boolean|null} web_analytics_configured - Whether web analytics is configured * @property {never} [x] - Prevent accessing undefined properties */ class CacheManager { /** * @prop {Object} options * @prop {Object} options.publicSettings - key/value pairs of settings which are publicly accessible */ 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 = []; this.get = this.get.bind(this); this.set = this.set.bind(this); this.getAll = this.getAll.bind(this); this.getPublic = this.getPublic.bind(this); this.reset = this.reset.bind(this); this._doGet = this._doGet.bind(this); this._updateSettingFromModel = this._updateSettingFromModel.bind(this); this._updateCalculatedField = this._updateCalculatedField.bind(this); } // Local function, only ever used for initializing // We deliberately call "set" on each model so that set is a consistent interface _updateSettingFromModel(settingModel) { debug('Auto updating', settingModel.get('key')); this.set(settingModel.get('key'), settingModel.toJSON()); } _updateCalculatedField(field) { return () => { debug('Auto updating', field.key); this.set(field.key, field.getSetting()); }; } _doGet(key, options) { // NOTE: "!this.settingsCache" is for when setting's cache is used // before it had a chance to initialize. Should be fixed when // it is decoupled from the model layer 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; } // Don't try to resolve to the value of the setting if (options && options.resolve === false) { return cacheEntry; } // TODO: I think we should be a little smarter here and deserialize the value based on the type // rather than trying to parse everything as JSON, which is very slow when we do it hundreds // of times per request. // Default behavior is to try to resolve the value and return that try { // CASE: handle literal false if (cacheEntry.value === false || cacheEntry.value === 'false') { return false; } // CASE: hotpath early return for strings which are already strings if (cacheEntry.type === 'string' && typeof cacheEntry.value === 'string') { return cacheEntry.value || null; } // CASE: if a string contains a number e.g. "1", JSON.parse will auto convert into integer if (!isNaN(Number(cacheEntry.value))) { return cacheEntry.value || null; } return JSON.parse(cacheEntry.value) || null; } catch (err) { return cacheEntry.value || null; } } /** * * IMPORTANT: * We store settings with a type and a key in the database. * * { * type: core * key: db_hash * value: ... * } * * But the settings cache does not allow requesting a value by type, only by key. * e.g. settingsCache.get('db_hash') * * Get a key from the this.settingsCache * Will resolve to the value, including parsing JSON, unless {resolve: false} is passed in as an option * In which case the full JSON version of the model will be resolved * * @param {string} key * @param {object} options * @return {*} */ get(key, options) { return this._doGet(key, options); } /** * Set a key on the cache * The only way to get an object into the cache * Uses clone to prevent modifications from being reflected * @param {string} key * @param {object} value json version of settings model */ set(key, value) { this.settingsCache.set(key, _.cloneDeep(value)); } /** * Get the entire cache object * Uses clone to prevent modifications from being reflected * This method is dangerous in case the cache is "lazily" initialized * could result in returning only a partially filled cache * @return {object} cache * @deprecated this method is not "cache-friendly" and should be avoided from further usage * instead using multiple "get" calls */ getAll() { const keys = this.settingsCache.keys(); const all = {}; keys.forEach((key) => { all[key] = _.cloneDeep(this.get(key, {resolve: false})); }); return all; } /** * Get all the publicly accessible cache entries with their correct names * Uses clone to prevent modifications from being reflected * @return {PublicSettingsCache} cache */ getPublic() { // This block correctly builds the type signature for the return value /** @type {PublicSettingsCache} */ let settings = Object.fromEntries( Object.keys(this.publicSettings).map(key => [this.publicSettings[key], null]) ); // This block correctly populates the values from the cache for (const newKey in this.publicSettings) { settings[newKey] = this._doGet(this.publicSettings[newKey]) ?? null; } return settings; } /** * Initialize the cache * * Optionally takes a collection of settings & can populate the cache with these. * * @param {import('events').EventEmitter} events * @param {import('bookshelf').Collection<import('bookshelf').Model>} 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, settingsOverrides) { this.settingsCache = cacheStore; this.settingsOverrides = settingsOverrides; // First, reset the cache and this.reset(events); // // if we have been passed a collection of settings, use this to populate the cache if (settingsCollection && settingsCollection.models) { _.each(settingsCollection.models, this._updateSettingFromModel); } this.calculatedFields = Array.isArray(calculatedFields) ? calculatedFields : []; // Bind to events to automatically keep up-to-date events.on('settings.edited', this._updateSettingFromModel); events.on('settings.added', this._updateSettingFromModel); events.on('settings.deleted', this._updateSettingFromModel); // set and bind calculated fields this.calculatedFields.forEach((field) => { this._updateCalculatedField(field)(); field.dependents.forEach((dependent) => { events.on(`settings.${dependent}.edited`, this._updateCalculatedField(field)); }); }); return this.settingsCache; } /** * Reset both the cache and the listeners, must be called during init * @param {import('events').EventEmitter} events */ reset(events) { if (this.settingsCache) { this.settingsCache.reset(); } events.removeListener('settings.edited', this._updateSettingFromModel); events.removeListener('settings.added', this._updateSettingFromModel); events.removeListener('settings.deleted', this._updateSettingFromModel); //unbind calculated fields this.calculatedFields.forEach((field) => { field.dependents.forEach((dependent) => { events.removeListener(`settings.${dependent}.edited`, this._updateCalculatedField(field)); }); }); this.calculatedFields = []; } } module.exports = CacheManager;