8859d6298c
refs https://github.com/TryGhost/Team/issues/1666 - it seems like we may have a situation where `.activateTheme()` can be called simultaneously resulting in unexpected behaviour in the sync such as duplicate theme setting records - adjusted behaviour to keep track of the currently running activation within the service and if `.activateTheme()` is called again whilst it's in progress it will wait for completion of the first sync before exiting early or continuing with a new activation **Note:** There is a known edge-case if there are _more_ than 2 parallel `.activateTheme()` calls. We don't believe that will be an issue but calling it out in case we do still see duplicated custom setting records being created. Co-authored-by: Kevin Ansfield <kevin@lookingsideways.co.uk>
268 lines
10 KiB
JavaScript
268 lines
10 KiB
JavaScript
const _ = require('lodash');
|
|
const BREAD = require('./bread');
|
|
const tpl = require('@tryghost/tpl');
|
|
const {ValidationError} = require('@tryghost/errors');
|
|
const debug = require('@tryghost/debug')('custom-theme-settings-service');
|
|
|
|
const messages = {
|
|
problemFindingSetting: 'Unknown setting: {key}.',
|
|
unallowedValueForSetting: 'Unallowed value for \'{key}\'. Allowed values: {allowedValues}.',
|
|
invalidValueForSetting: 'Invalid value for \'{key}\'. The value must follow this format: {format}.'
|
|
};
|
|
|
|
module.exports = class CustomThemeSettingsService {
|
|
/**
|
|
* @param {Object} options
|
|
* @param {any} options.model - Bookshelf-like model instance for storing theme setting key/value pairs
|
|
* @param {import('./cache')} options.cache - Instance of a custom key/value pair cache
|
|
*/
|
|
constructor({model, cache}) {
|
|
this.activeThemeName = null;
|
|
|
|
/** @private */
|
|
this._activatingPromise = null;
|
|
this._activatingName = null;
|
|
this._activatingSettings = null;
|
|
|
|
this._repository = new BREAD({model});
|
|
this._valueCache = cache;
|
|
this._activeThemeSettings = {};
|
|
}
|
|
|
|
/**
|
|
* The service only deals with one theme at a time,
|
|
* that theme is changed by calling this method with the output from gscan.
|
|
*
|
|
* To avoid syncing issues with activateTheme being called in quick succession,
|
|
* any previous/still-running activation promise is awaited before re-starting
|
|
* if necessary.
|
|
*
|
|
* @param {string} name - the name of the theme (Ghost has different names to themes with duplicate package.json names)
|
|
* @param {Object} theme - checked theme output from gscan
|
|
*/
|
|
async activateTheme(name, theme) {
|
|
const activate = async () => {
|
|
this.activeThemeName = name;
|
|
|
|
// add/remove/edit key/value records in the respository to match theme settings
|
|
const settings = await this._syncRepositoryWithTheme(name, theme);
|
|
|
|
// populate the shared cache with all key/value pairs for this theme
|
|
this._populateValueCacheForTheme(theme, settings);
|
|
// populate the cache used for exposing full setting details for editing
|
|
this._populateInternalCacheForTheme(theme, settings);
|
|
};
|
|
|
|
if (this._activatingPromise) {
|
|
// NOTE: must be calculated before awaiting promise as the promise finishing will clear the properties
|
|
const isSameName = name === this._activatingName;
|
|
const isSameSettings = JSON.stringify(theme.customSettings) === this._activatingSettings;
|
|
|
|
// wait for previous activation to finish
|
|
await this._activatingPromise;
|
|
|
|
// skip sync if we're re-activating exactly the same theme settings
|
|
if (isSameName && isSameSettings) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
this._activatingName = name;
|
|
this._activatingSettings = JSON.stringify(theme.customSettings);
|
|
this._activatingPromise = activate();
|
|
|
|
await this._activatingPromise;
|
|
} finally {
|
|
this._activatingPromise = null;
|
|
this._activatingName = null;
|
|
this._activatingSettings = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert the key'd internal cache object to an array suitable for use with Ghost's API
|
|
*/
|
|
listSettings() {
|
|
const settingObjects = Object.entries(this._activeThemeSettings).map(([key, setting]) => {
|
|
return Object.assign({}, setting, {key});
|
|
});
|
|
|
|
return settingObjects;
|
|
}
|
|
|
|
/**
|
|
* @param {Array} settings - array of setting objects with at least key and value properties
|
|
*/
|
|
async updateSettings(settings) {
|
|
// abort if any settings do not match known settings
|
|
const firstUnknownSetting = settings.find(setting => !this._activeThemeSettings[setting.key]);
|
|
|
|
if (firstUnknownSetting) {
|
|
throw new ValidationError({
|
|
message: tpl(messages.problemFindingSetting, {key: firstUnknownSetting.key})
|
|
});
|
|
}
|
|
|
|
settings.forEach((setting) => {
|
|
const definition = this._activeThemeSettings[setting.key];
|
|
switch (definition.type) {
|
|
case 'select':
|
|
if (!definition.options.includes(setting.value)) {
|
|
throw new ValidationError({
|
|
message: tpl(messages.unallowedValueForSetting, {key: setting.key, allowedValues: definition.options.join(', ')})
|
|
});
|
|
}
|
|
break;
|
|
case 'boolean':
|
|
if (![true, false].includes(setting.value)) {
|
|
throw new ValidationError({
|
|
message: tpl(messages.unallowedValueForSetting, {key: setting.key, allowedValues: [true, false].join(', ')})
|
|
});
|
|
}
|
|
break;
|
|
case 'color':
|
|
if (!/^#[0-9a-f]{6}$/i.test(setting.value)) {
|
|
throw new ValidationError({
|
|
message: tpl(messages.invalidValueForSetting, {key: setting.key, format: '#1234AF'})
|
|
});
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
});
|
|
|
|
// save the new values
|
|
for (const setting of settings) {
|
|
const theme = this.activeThemeName;
|
|
const {key, value} = setting;
|
|
|
|
const settingRecord = await this._repository.read({theme, key});
|
|
|
|
settingRecord.set('value', value);
|
|
|
|
if (settingRecord.hasChanged()) {
|
|
await settingRecord.save(null);
|
|
}
|
|
|
|
// update the internal cache
|
|
this._activeThemeSettings[setting.key].value = setting.value;
|
|
}
|
|
|
|
// update the public cache
|
|
this._valueCache.populate(this.listSettings());
|
|
|
|
// return full setting objects
|
|
return this.listSettings();
|
|
}
|
|
|
|
// Private -----------------------------------------------------------------
|
|
|
|
/**
|
|
* @param {Object} theme - checked theme output from gscan
|
|
* @returns {Array} - list of stored theme record objects
|
|
* @private
|
|
*/
|
|
async _syncRepositoryWithTheme(name, theme) {
|
|
const themeSettings = theme.customSettings || {};
|
|
|
|
const settingsCollection = await this._repository.browse({filter: `theme:'${name}'`});
|
|
let knownSettings = settingsCollection.toJSON();
|
|
|
|
// exit early if there's nothing to sync for this theme
|
|
if (knownSettings.length === 0 && _.isEmpty(themeSettings)) {
|
|
return [];
|
|
}
|
|
|
|
let removedIds = [];
|
|
|
|
// sync any knownSettings that have changed in the theme
|
|
for (const knownSetting of knownSettings) {
|
|
const themeSetting = themeSettings[knownSetting.key];
|
|
|
|
const hasBeenRemoved = !themeSetting;
|
|
const hasChangedType = themeSetting && themeSetting.type !== knownSetting.type;
|
|
|
|
if (hasBeenRemoved || hasChangedType) {
|
|
debug(`Removing custom theme setting '${name}.${knownSetting.key}' - ${hasBeenRemoved ? 'not found in theme' : 'type changed'}`);
|
|
await this._repository.destroy({id: knownSetting.id});
|
|
removedIds.push(knownSetting.id);
|
|
continue;
|
|
}
|
|
|
|
// replace value with default if it's not a valid select option
|
|
if (themeSetting.options && !themeSetting.options.includes(knownSetting.value)) {
|
|
debug(`Resetting custom theme setting value '${name}.${themeSetting.key}' - "${knownSetting.value}" is not a valid option`);
|
|
await this._repository.edit({value: themeSetting.default}, {id: knownSetting.id});
|
|
}
|
|
}
|
|
|
|
// clean up any removed knownSettings now that we've finished looping over them
|
|
knownSettings = knownSettings.filter(setting => !removedIds.includes(setting.id));
|
|
|
|
// add any new settings found in theme (or re-add settings that were removed due to type change)
|
|
const knownSettingsKeys = knownSettings.map(setting => setting.key);
|
|
|
|
for (const [key, setting] of Object.entries(themeSettings)) {
|
|
if (!knownSettingsKeys.includes(key)) {
|
|
const newSettingValues = {
|
|
theme: name,
|
|
key,
|
|
type: setting.type,
|
|
value: setting.default
|
|
};
|
|
|
|
debug(`Adding custom theme setting '${name}.${key}'`);
|
|
await this._repository.add(newSettingValues);
|
|
}
|
|
}
|
|
|
|
const updatedSettingsCollection = await this._repository.browse({filter: `theme:'${name}'`});
|
|
return updatedSettingsCollection.toJSON();
|
|
}
|
|
|
|
/**
|
|
* @param {Object} theme - checked theme output from gscan
|
|
* @param {Array} settings - theme settings fetched from repository
|
|
* @private
|
|
*/
|
|
_populateValueCacheForTheme(theme, settings) {
|
|
if (_.isEmpty(theme.customSettings)) {
|
|
this._valueCache.populate([]);
|
|
return;
|
|
}
|
|
|
|
this._valueCache.populate(settings);
|
|
}
|
|
|
|
/**
|
|
* @param {Object} theme - checked theme output from gscan
|
|
* @param {Array} settings - theme settings fetched from repository
|
|
* @private
|
|
*/
|
|
_populateInternalCacheForTheme(theme, settings) {
|
|
if (_.isEmpty(theme.customSettings)) {
|
|
this._activeThemeSettings = new Map();
|
|
return;
|
|
}
|
|
|
|
const settingValues = settings.reduce((acc, setting) => {
|
|
acc[setting.key] = setting;
|
|
return acc;
|
|
}, new Object());
|
|
|
|
const activeThemeSettings = new Object();
|
|
|
|
for (const [key, setting] of Object.entries(theme.customSettings)) {
|
|
// value comes from the stored key/value pairs rather than theme, we don't need the ID - theme name + key is enough
|
|
activeThemeSettings[key] = Object.assign({}, setting, {
|
|
id: settingValues[key].id,
|
|
value: settingValues[key].value
|
|
});
|
|
}
|
|
|
|
this._activeThemeSettings = activeThemeSettings;
|
|
}
|
|
};
|