import type { ISetting, ISettingGroup, Optional, SettingValue } from '@rocket.chat/core-typings'; import { isSettingEnterprise } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import type { ISettingsModel } from '@rocket.chat/model-typings'; import { isEqual } from 'underscore'; import type { ICachedSettings } from './CachedSettings'; import { getSettingDefaults } from './functions/getSettingDefaults'; import { overrideSetting } from './functions/overrideSetting'; import { overwriteSetting } from './functions/overwriteSetting'; import { registerSettingSchema } from './functions/settingSchemas'; import { validateSetting } from './functions/validateSetting'; import { SystemLogger } from '../lib/logger/system'; const blockedSettings = new Set(); const hiddenSettings = new Set(); const wizardRequiredSettings = new Set(); if (process.env.SETTINGS_BLOCKED) { process.env.SETTINGS_BLOCKED.split(',').forEach((settingId) => blockedSettings.add(settingId.trim())); } if (process.env.SETTINGS_HIDDEN) { process.env.SETTINGS_HIDDEN.split(',').forEach((settingId) => hiddenSettings.add(settingId.trim())); } if (process.env.SETTINGS_REQUIRED_ON_WIZARD) { process.env.SETTINGS_REQUIRED_ON_WIZARD.split(',').forEach((settingId) => wizardRequiredSettings.add(settingId.trim())); } const IS_DEVELOPMENT = process.env.NODE_ENV === 'development'; /* * @deprecated * please do not use event emitter to mutate values */ /** The fields the enterprise value-masking listener needs; `fetchSettings` projects exactly these. */ export type FetchedSetting = Pick; export const SettingsEvents = new Emitter<{ 'store-setting-value': [ISetting, { value: SettingValue }]; 'fetch-settings': FetchedSetting[]; 'remove-setting-value': ISetting; }>(); const getGroupDefaults = (_id: string, options: ISettingAddGroupOptions = {}): ISettingGroup => ({ _id, i18nLabel: _id, i18nDescription: `${_id}_Description`, ...options, sorter: options.sorter || 0, blocked: blockedSettings.has(_id), hidden: hiddenSettings.has(_id), type: 'group', ...(options.displayQuery && { displayQuery: JSON.stringify(options.displayQuery) }), }); type ISettingAddGroupOptions = Partial; type addSectionCallback = (this: { add(id: string, value: SettingValue, options: ISettingAddOptions): Promise; with(options: ISettingAddOptions, cb: addSectionCallback): Promise; }) => Promise; type addGroupCallback = (this: { add(id: string, value: SettingValue, options: ISettingAddOptions): Promise; section(section: string, cb: addSectionCallback): Promise; with(options: ISettingAddOptions, cb: addGroupCallback): Promise; }) => Promise; type ISettingAddOptions = Partial & { schema?: Record }; const compareSettingsIgnoringKeys = (keys: Array) => (a: ISetting, b: ISetting): boolean => [...new Set([...Object.keys(a), ...Object.keys(b)])] .filter((key) => !keys.includes(key as keyof ISetting)) .every((key) => isEqual(a[key as keyof ISetting], b[key as keyof ISetting])); export const compareSettings = compareSettingsIgnoringKeys([ 'value', 'ts', 'createdAt', 'valueSource', 'packageValue', 'processEnvValue', '_updatedAt', ]); const hasOverwrittenValueChanged = (stored: ISetting, overwritten: ISetting): boolean => !isEqual(stored.value, overwritten.value) || stored.valueSource !== overwritten.valueSource || !isEqual(stored.processEnvValue, overwritten.processEnvValue); export class SettingsRegistry { private model: ISettingsModel; private store: ICachedSettings; private _sorter: { [key: string]: number } = {}; constructor({ store, model }: { store: ICachedSettings; model: ISettingsModel }) { this.store = store; this.model = model; } /* * Add a setting */ async add(_id: string, value: SettingValue, { sorter, section, group, schema, ...options }: ISettingAddOptions = {}): Promise { if (!_id || value == null) { throw new Error('Invalid arguments'); } if (schema) { registerSettingSchema(_id, schema); } const sorterKey = group && section ? `${group}_${section}` : group; if (sorterKey && this._sorter[sorterKey] == null) { if (group && section) { const currentGroupValue = this._sorter[group] ?? 0; this._sorter[sorterKey] = currentGroupValue * 1000; } } if (sorterKey) { this._sorter[sorterKey] = this._sorter[sorterKey] ?? -1; } const settingFromCode = getSettingDefaults( { _id, type: 'string', value, sorter: sorter ?? (sorterKey?.length && this._sorter[sorterKey]++), group, section, ...options, }, blockedSettings, hiddenSettings, wizardRequiredSettings, ); if (isSettingEnterprise(settingFromCode) && !('invalidValue' in settingFromCode)) { SystemLogger.error({ msg: 'Enterprise setting is missing the invalidValue option', _id }); throw new Error(`Enterprise setting ${_id} is missing the invalidValue option`); } const settingFromCodeOverwritten = overwriteSetting(settingFromCode); const settingStored = this.store.getSetting(_id); const settingStoredOverwritten = settingStored && overwriteSetting(settingStored); try { validateSetting(settingFromCode._id, settingFromCode.type, settingFromCode.value); } catch (e) { IS_DEVELOPMENT && SystemLogger.error({ msg: 'Invalid setting code', _id, err: e as Error }); } const isOverwritten = settingFromCode !== settingFromCodeOverwritten || (settingStored && settingStored !== settingStoredOverwritten); const { _id: _, ...settingProps } = settingFromCodeOverwritten; if (settingStored && !compareSettings(settingStored, settingFromCodeOverwritten)) { const { value: _value, ...settingOverwrittenProps } = settingFromCodeOverwritten; const overwrittenKeys = Object.keys(settingFromCodeOverwritten); const removedKeys = Object.keys(settingStored).filter((key) => !['_updatedAt'].includes(key) && !overwrittenKeys.includes(key)); const updatedProps = (() => { return { ...settingOverwrittenProps, ...(settingStoredOverwritten && settingStored.value !== settingStoredOverwritten.value && { value: settingStoredOverwritten.value }), }; })(); await this.saveUpdatedSetting(_id, updatedProps, removedKeys); if ('value' in updatedProps) { this.store.set(updatedProps as ISetting); } return; } if (settingStored && isOverwritten) { if (hasOverwrittenValueChanged(settingStored, settingFromCodeOverwritten)) { const overwrittenKeys = Object.keys(settingFromCodeOverwritten); const removedKeys = Object.keys(settingStored).filter((key) => !['_updatedAt'].includes(key) && !overwrittenKeys.includes(key)); await this.saveUpdatedSetting(_id, settingProps, removedKeys); this.store.set(settingFromCodeOverwritten); } return; } if (settingStored) { try { validateSetting(settingFromCode._id, settingFromCode.type, settingStored?.value); } catch (e) { IS_DEVELOPMENT && SystemLogger.error({ msg: 'Invalid setting stored', _id, err: e as Error }); } return; } const settingOverwrittenDefault = overrideSetting(settingFromCode); const setting = isOverwritten ? settingFromCodeOverwritten : settingOverwrittenDefault; await this.model.insertOne(setting); // no need to emit unless we remove the oplog this.store.set(setting); } /* * Add a setting group */ async addGroup(_id: string, cb?: addGroupCallback): Promise; async addGroup(_id: string, groupOptions: ISettingAddGroupOptions | addGroupCallback = {}, cb?: addGroupCallback): Promise { if (!_id || (groupOptions instanceof Function && cb)) { throw new Error('Invalid arguments'); } const callback = groupOptions instanceof Function ? groupOptions : cb; const options = groupOptions instanceof Function ? getGroupDefaults(_id, { sorter: this._sorter[_id] }) : getGroupDefaults(_id, { sorter: this._sorter[_id], ...groupOptions }); if (!this.store.has(_id)) { options.ts = new Date(); this.store.set(options as ISetting); await this.model.insertOne(options as ISetting); } if (!callback) { return; } const addWith = (preset: ISettingAddOptions) => (id: string, value: SettingValue, options: ISettingAddOptions = {}): Promise => { const mergedOptions = { ...preset, ...options }; return this.add(id, value, mergedOptions); }; const sectionSetWith = (preset: ISettingAddOptions) => (options: ISettingAddOptions, cb: addSectionCallback): Promise => { const mergedOptions = { ...preset, ...options }; return cb.call({ add: addWith(mergedOptions), with: sectionSetWith(mergedOptions), }); }; const sectionWith = (preset: ISettingAddOptions) => (section: string, cb: addSectionCallback): Promise => { const mergedOptions = { ...preset, section }; return cb.call({ add: addWith(mergedOptions), with: sectionSetWith(mergedOptions), }); }; const groupSetWith = (preset: ISettingAddOptions) => (options: ISettingAddOptions, cb: addGroupCallback): Promise => { const mergedOptions = { ...preset, ...options }; return cb.call({ add: addWith(mergedOptions), section: sectionWith(mergedOptions), with: groupSetWith(mergedOptions), }); }; return groupSetWith({ group: _id })({}, callback); } private async saveUpdatedSetting( _id: string, settingProps: Omit, '_id'>, removedKeys?: string[], ): Promise { await this.model.updateOne( { _id }, { $set: settingProps, ...(removedKeys?.length && { $unset: Object.fromEntries(removedKeys.map((key) => [key, 1])), }), }, { upsert: true }, ); } }