From bf7cd11b59b38cfeb2890baa99d2be41d72c9cb5 Mon Sep 17 00:00:00 2001 From: SamTV12345 <40429738+SamTV12345@users.noreply.github.com> Date: Thu, 21 Mar 2024 18:50:02 +0100 Subject: [PATCH] Added env configuration for plugins * Added env. * Added tree config. * Added tree. * Fixed settings test. * Added test cases. --- src/node/utils/Settings.ts | 898 +++++++++++++++------------- src/node/utils/SettingsTree.ts | 112 ++++ src/tests/backend/specs/settings.ts | 127 ++-- 3 files changed, 667 insertions(+), 470 deletions(-) create mode 100644 src/node/utils/SettingsTree.ts diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index 8f1e058ea..0fd964872 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -27,6 +27,10 @@ * limitations under the License. */ +import {MapArrayType} from "../types/MapType"; +import {SettingsNode, SettingsTree} from "./SettingsTree"; +import {coerce} from "semver"; + const absolutePaths = require('./AbsolutePaths'); const deepEqual = require('fast-deep-equal/es6'); const fs = require('fs'); @@ -44,28 +48,30 @@ const logger = log4js.getLogger('settings'); // Exported values that settings.json and credentials.json cannot override. const nonSettings = [ - 'credentialsFilename', - 'settingsFilename', + 'credentialsFilename', + 'settingsFilename', ]; // This is a function to make it easy to create a new instance. It is important to not reuse a // config object after passing it to log4js.configure() because that method mutates the object. :( -const defaultLogConfig = (level:string) => ({appenders: {console: {type: 'console'}}, - categories: { - default: {appenders: ['console'], level}, - }}); +const defaultLogConfig = (level: string) => ({ + appenders: {console: {type: 'console'}}, + categories: { + default: {appenders: ['console'], level}, + } +}); const defaultLogLevel = 'INFO'; -const initLogging = (config:any) => { - // log4js.configure() modifies exports.logconfig so check for equality first. - log4js.configure(config); - log4js.getLogger('console'); +const initLogging = (config: any) => { + // log4js.configure() modifies exports.logconfig so check for equality first. + log4js.configure(config); + log4js.getLogger('console'); - // Overwrites for console output methods - console.debug = logger.debug.bind(logger); - console.log = logger.info.bind(logger); - console.warn = logger.warn.bind(logger); - console.error = logger.error.bind(logger); + // Overwrites for console output methods + console.debug = logger.debug.bind(logger); + console.log = logger.info.bind(logger); + console.warn = logger.warn.bind(logger); + console.error = logger.error.bind(logger); }; // Initialize logging as early as possible with reasonable defaults. Logging will be re-initialized @@ -129,15 +135,15 @@ exports.ssl = false; exports.socketTransportProtocols = ['websocket', 'polling']; exports.socketIo = { - /** - * Maximum permitted client message size (in bytes). - * - * All messages from clients that are larger than this will be rejected. Large values make it - * possible to paste large amounts of text, and plugins may require a larger value to work - * properly, but increasing the value increases susceptibility to denial of service attacks - * (malicious clients can exhaust memory). - */ - maxHttpBufferSize: 10000, + /** + * Maximum permitted client message size (in bytes). + * + * All messages from clients that are larger than this will be rejected. Large values make it + * possible to paste large amounts of text, and plugins may require a larger value to work + * properly, but increasing the value increases susceptibility to denial of service attacks + * (malicious clients can exhaust memory). + */ + maxHttpBufferSize: 10000, }; /* @@ -153,77 +159,77 @@ exports.dbSettings = {filename: path.join(exports.root, 'var/dirty.db')}; * The default Text of a new pad */ exports.defaultPadText = [ - 'Welcome to Etherpad!', - '', - 'This pad text is synchronized as you type, so that everyone viewing this page sees the same ' + - 'text. This allows you to collaborate seamlessly on documents!', - '', - 'Etherpad on Github: https://github.com/ether/etherpad-lite', + 'Welcome to Etherpad!', + '', + 'This pad text is synchronized as you type, so that everyone viewing this page sees the same ' + + 'text. This allows you to collaborate seamlessly on documents!', + '', + 'Etherpad on Github: https://github.com/ether/etherpad-lite', ].join('\n'); /** * The default Pad Settings for a user (Can be overridden by changing the setting */ exports.padOptions = { - noColors: false, - showControls: true, - showChat: true, - showLineNumbers: true, - useMonospaceFont: false, - userName: null, - userColor: null, - rtl: false, - alwaysShowChat: false, - chatAndUsers: false, - lang: null, + noColors: false, + showControls: true, + showChat: true, + showLineNumbers: true, + useMonospaceFont: false, + userName: null, + userColor: null, + rtl: false, + alwaysShowChat: false, + chatAndUsers: false, + lang: null, }; /** * Whether certain shortcut keys are enabled for a user in the pad */ exports.padShortcutEnabled = { - altF9: true, - altC: true, - delete: true, - cmdShift2: true, - return: true, - esc: true, - cmdS: true, - tab: true, - cmdZ: true, - cmdY: true, - cmdB: true, - cmdI: true, - cmdU: true, - cmd5: true, - cmdShiftL: true, - cmdShiftN: true, - cmdShift1: true, - cmdShiftC: true, - cmdH: true, - ctrlHome: true, - pageUp: true, - pageDown: true, + altF9: true, + altC: true, + delete: true, + cmdShift2: true, + return: true, + esc: true, + cmdS: true, + tab: true, + cmdZ: true, + cmdY: true, + cmdB: true, + cmdI: true, + cmdU: true, + cmd5: true, + cmdShiftL: true, + cmdShiftN: true, + cmdShift1: true, + cmdShiftC: true, + cmdH: true, + ctrlHome: true, + pageUp: true, + pageDown: true, }; /** * The toolbar buttons and order. */ exports.toolbar = { - left: [ - ['bold', 'italic', 'underline', 'strikethrough'], - ['orderedlist', 'unorderedlist', 'indent', 'outdent'], - ['undo', 'redo'], - ['clearauthorship'], - ], - right: [ - ['importexport', 'timeslider', 'savedrevision'], - ['settings', 'embed'], - ['showusers'], - ], - timeslider: [ - ['timeslider_export', 'timeslider_settings', 'timeslider_returnToPad'], - ], + left: [ + ['bold', 'italic', 'underline', 'strikethrough'], + ['orderedlist', 'unorderedlist', 'indent', 'outdent'], + ['undo', 'redo'], + ['clearauthorship'], + ], + right: [ + ['importexport', 'timeslider', 'savedrevision'], + ['settings', 'embed'], + ['showusers'], + ], + timeslider: [ + ['timeslider_export', 'timeslider_settings', 'timeslider_returnToPad'], + ], }; /** @@ -310,21 +316,21 @@ exports.trustProxy = false; * Settings controlling the session cookie issued by Etherpad. */ exports.cookie = { - keyRotationInterval: 1 * 24 * 60 * 60 * 1000, - /* - * Value of the SameSite cookie property. "Lax" is recommended unless - * Etherpad will be embedded in an iframe from another site, in which case - * this must be set to "None". Note: "None" will not work (the browser will - * not send the cookie to Etherpad) unless https is used to access Etherpad - * (either directly or via a reverse proxy with "trustProxy" set to true). - * - * "Strict" is not recommended because it has few security benefits but - * significant usability drawbacks vs. "Lax". See - * https://stackoverflow.com/q/41841880 for discussion. - */ - sameSite: 'Lax', - sessionLifetime: 10 * 24 * 60 * 60 * 1000, - sessionRefreshInterval: 1 * 24 * 60 * 60 * 1000, + keyRotationInterval: 1 * 24 * 60 * 60 * 1000, + /* + * Value of the SameSite cookie property. "Lax" is recommended unless + * Etherpad will be embedded in an iframe from another site, in which case + * this must be set to "None". Note: "None" will not work (the browser will + * not send the cookie to Etherpad) unless https is used to access Etherpad + * (either directly or via a reverse proxy with "trustProxy" set to true). + * + * "Strict" is not recommended because it has few security benefits but + * significant usability drawbacks vs. "Lax". See + * https://stackoverflow.com/q/41841880 for discussion. + */ + sameSite: 'Lax', + sessionLifetime: 10 * 24 * 60 * 60 * 1000, + sessionRefreshInterval: 1 * 24 * 60 * 60 * 1000, }; /* @@ -346,31 +352,31 @@ exports.showSettingsInAdminPage = true; * height needed to make this line visible. */ exports.scrollWhenFocusLineIsOutOfViewport = { - /* - * Percentage of viewport height to be additionally scrolled. - */ - percentage: { - editionAboveViewport: 0, - editionBelowViewport: 0, - }, + /* + * Percentage of viewport height to be additionally scrolled. + */ + percentage: { + editionAboveViewport: 0, + editionBelowViewport: 0, + }, - /* - * Time (in milliseconds) used to animate the scroll transition. Set to 0 to - * disable animation - */ - duration: 0, + /* + * Time (in milliseconds) used to animate the scroll transition. Set to 0 to + * disable animation + */ + duration: 0, - /* - * Percentage of viewport height to be additionally scrolled when user presses arrow up - * in the line of the top of the viewport. - */ - percentageToScrollWhenUserPressesArrowUp: 0, + /* + * Percentage of viewport height to be additionally scrolled when user presses arrow up + * in the line of the top of the viewport. + */ + percentageToScrollWhenUserPressesArrowUp: 0, - /* - * Flag to control if it should scroll when user places the caret in the last - * line of the viewport - */ - scrollWhenCaretIsInTheLastLineOfViewport: false, + /* + * Flag to control if it should scroll when user places the caret in the last + * line of the viewport + */ + scrollWhenCaretIsInTheLastLineOfViewport: false, }; /* @@ -395,11 +401,11 @@ exports.customLocaleStrings = {}; * See https://github.com/nfriedly/express-rate-limit for more options */ exports.importExportRateLimiting = { - // duration of the rate limit window (milliseconds) - windowMs: 90000, + // duration of the rate limit window (milliseconds) + windowMs: 90000, - // maximum number of requests per IP to allow during the rate limit window - max: 10, + // maximum number of requests per IP to allow during the rate limit window + max: 10, }; /* @@ -411,11 +417,11 @@ exports.importExportRateLimiting = { * See https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#websocket-single-connection-prevent-flooding for more options */ exports.commitRateLimiting = { - // duration of the rate limit window (seconds) - duration: 1, + // duration of the rate limit window (seconds) + duration: 1, - // maximum number of chanes per IP to allow during the rate limit window - points: 10, + // maximum number of chanes per IP to allow during the rate limit window + points: 10, }; /* @@ -439,58 +445,58 @@ exports.lowerCasePadIds = false; // checks if abiword is avaiable exports.abiwordAvailable = () => { - if (exports.abiword != null) { - return os.type().indexOf('Windows') !== -1 ? 'withoutPDF' : 'yes'; - } else { - return 'no'; - } + if (exports.abiword != null) { + return os.type().indexOf('Windows') !== -1 ? 'withoutPDF' : 'yes'; + } else { + return 'no'; + } }; exports.sofficeAvailable = () => { - if (exports.soffice != null) { - return os.type().indexOf('Windows') !== -1 ? 'withoutPDF' : 'yes'; - } else { - return 'no'; - } + if (exports.soffice != null) { + return os.type().indexOf('Windows') !== -1 ? 'withoutPDF' : 'yes'; + } else { + return 'no'; + } }; exports.exportAvailable = () => { - const abiword = exports.abiwordAvailable(); - const soffice = exports.sofficeAvailable(); + const abiword = exports.abiwordAvailable(); + const soffice = exports.sofficeAvailable(); - if (abiword === 'no' && soffice === 'no') { - return 'no'; - } else if ((abiword === 'withoutPDF' && soffice === 'no') || - (abiword === 'no' && soffice === 'withoutPDF')) { - return 'withoutPDF'; - } else { - return 'yes'; - } + if (abiword === 'no' && soffice === 'no') { + return 'no'; + } else if ((abiword === 'withoutPDF' && soffice === 'no') || + (abiword === 'no' && soffice === 'withoutPDF')) { + return 'withoutPDF'; + } else { + return 'yes'; + } }; // Provide git version if available exports.getGitCommit = () => { - let version = ''; - try { - let rootPath = exports.root; - if (fs.lstatSync(`${rootPath}/.git`).isFile()) { - rootPath = fs.readFileSync(`${rootPath}/.git`, 'utf8'); - rootPath = rootPath.split(' ').pop().trim(); - } else { - rootPath += '/.git'; + let version = ''; + try { + let rootPath = exports.root; + if (fs.lstatSync(`${rootPath}/.git`).isFile()) { + rootPath = fs.readFileSync(`${rootPath}/.git`, 'utf8'); + rootPath = rootPath.split(' ').pop().trim(); + } else { + rootPath += '/.git'; + } + const ref = fs.readFileSync(`${rootPath}/HEAD`, 'utf-8'); + if (ref.startsWith('ref: ')) { + const refPath = `${rootPath}/${ref.substring(5, ref.indexOf('\n'))}`; + version = fs.readFileSync(refPath, 'utf-8'); + } else { + version = ref; + } + version = version.substring(0, 7); + } catch (e: any) { + logger.warn(`Can't get git version for server header\n${e.message}`); } - const ref = fs.readFileSync(`${rootPath}/HEAD`, 'utf-8'); - if (ref.startsWith('ref: ')) { - const refPath = `${rootPath}/${ref.substring(5, ref.indexOf('\n'))}`; - version = fs.readFileSync(refPath, 'utf-8'); - } else { - version = ref; - } - version = version.substring(0, 7); - } catch (e:any) { - logger.warn(`Can't get git version for server header\n${e.message}`); - } - return version; + return version; }; // Return etherpad version from package.json @@ -503,31 +509,31 @@ exports.getEpVersion = () => require('../../package.json').version; * This code refactors a previous version that copied & pasted the same code for * both "settings.json" and "credentials.json". */ -const storeSettings = (settingsObj:any) => { - for (const i of Object.keys(settingsObj || {})) { - if (nonSettings.includes(i)) { - logger.warn(`Ignoring setting: '${i}'`); - continue; - } +const storeSettings = (settingsObj: any) => { + for (const i of Object.keys(settingsObj || {})) { + if (nonSettings.includes(i)) { + logger.warn(`Ignoring setting: '${i}'`); + continue; + } - // test if the setting starts with a lowercase character - if (i.charAt(0).search('[a-z]') !== 0) { - logger.warn(`Settings should start with a lowercase character: '${i}'`); - } + // test if the setting starts with a lowercase character + if (i.charAt(0).search('[a-z]') !== 0) { + logger.warn(`Settings should start with a lowercase character: '${i}'`); + } - // we know this setting, so we overwrite it - // or it's a settings hash, specific to a plugin - if (exports[i] !== undefined || i.indexOf('ep_') === 0) { - if (_.isObject(settingsObj[i]) && !Array.isArray(settingsObj[i])) { - exports[i] = _.defaults(settingsObj[i], exports[i]); - } else { - exports[i] = settingsObj[i]; - } - } else { - // this setting is unknown, output a warning and throw it away - logger.warn(`Unknown Setting: '${i}'. This setting doesn't exist or it was removed`); + // we know this setting, so we overwrite it + // or it's a settings hash, specific to a plugin + if (exports[i] !== undefined || i.indexOf('ep_') === 0) { + if (_.isObject(settingsObj[i]) && !Array.isArray(settingsObj[i])) { + exports[i] = _.defaults(settingsObj[i], exports[i]); + } else { + exports[i] = settingsObj[i]; + } + } else { + // this setting is unknown, output a warning and throw it away + logger.warn(`Unknown Setting: '${i}'. This setting doesn't exist or it was removed`); + } } - } }; /* @@ -542,24 +548,29 @@ const storeSettings = (settingsObj:any) => { * short syntax "${ABIWORD}", and not "${ABIWORD:null}": the latter would result * in the literal string "null", instead. */ -const coerceValue = (stringValue:string) => { - // cooked from https://stackoverflow.com/questions/175739/built-in-way-in-javascript-to-check-if-a-string-is-a-valid-number - // @ts-ignore - const isNumeric = !isNaN(stringValue) && !isNaN(parseFloat(stringValue) && isFinite(stringValue)); +const coerceValue = (stringValue: string) => { + // cooked from https://stackoverflow.com/questions/175739/built-in-way-in-javascript-to-check-if-a-string-is-a-valid-number + // @ts-ignore + const isNumeric = !isNaN(stringValue) && !isNaN(parseFloat(stringValue) && isFinite(stringValue)); - if (isNumeric) { - // detected numeric string. Coerce to a number + if (isNumeric) { + // detected numeric string. Coerce to a number - return +stringValue; - } + return +stringValue; + } - switch (stringValue) { - case 'true': return true; - case 'false': return false; - case 'undefined': return undefined; - case 'null': return null; - default: return stringValue; - } + switch (stringValue) { + case 'true': + return true; + case 'false': + return false; + case 'undefined': + return undefined; + case 'null': + return null; + default: + return stringValue; + } }; /** @@ -598,86 +609,131 @@ const coerceValue = (stringValue:string) => { * * see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#The_replacer_parameter */ -const lookupEnvironmentVariables = (obj: object) => { - const stringifiedAndReplaced = JSON.stringify(obj, (key, value) => { - /* - * the first invocation of replacer() is with an empty key. Just go on, or - * we would zap the entire object. - */ - if (key === '') { - return value; +const lookupEnvironmentVariables = (obj: MapArrayType) => { + const replaceEnvs = (obj: MapArrayType) => { + for (let [key, value] of Object.entries(obj)) { + /* + * the first invocation of replacer() is with an empty key. Just go on, or + * we would zap the entire object. + */ + if (key === '') { + obj[key] = value; + continue + } + + /* + * If we received from the configuration file a number, a boolean or + * something that is not a string, we can be sure that it was a literal + * value. No need to perform any variable substitution. + * + * The environment variable expansion syntax "${ENV_VAR}" is just a string + * of specific form, after all. + */ + + if(key === 'undefined' || value === undefined) { + delete obj[key] + continue + } + + if ((typeof value !== 'string' && typeof value !== 'object') || value === null) { + obj[key] = value; + continue + } + + if (typeof obj[key] === "object") { + replaceEnvs(obj[key]); + continue + } + + + /* + * Let's check if the string value looks like a variable expansion (e.g.: + * "${ENV_VAR}" or "${ENV_VAR:default_value}") + */ + // MUXATOR 2019-03-21: we could use named capture groups here once we migrate to nodejs v10 + const match = value.match(/^\$\{([^:]*)(:((.|\n)*))?\}$/); + + if (match == null) { + // no match: use the value literally, without any substitution + obj[key] = value; + continue + } + + /* + * We found the name of an environment variable. Let's read its actual value + * and its default value, if given + */ + const envVarName = match[1]; + const envVarValue = process.env[envVarName]; + const defaultValue = match[3]; + + if ((envVarValue === undefined) && (defaultValue === undefined)) { + logger.warn(`Environment variable "${envVarName}" does not contain any value for ` + + `configuration key "${key}", and no default was given. Using null. ` + + 'THIS BEHAVIOR MAY CHANGE IN A FUTURE VERSION OF ETHERPAD; you should ' + + 'explicitly use "null" as the default if you want to continue to use null.'); + + /* + * We have to return null, because if we just returned undefined, the + * configuration item "key" would be stripped from the returned object. + */ + obj[key] = null; + continue + } + + if ((envVarValue === undefined) && (defaultValue !== undefined)) { + logger.debug(`Environment variable "${envVarName}" not found for ` + + `configuration key "${key}". Falling back to default value.`); + + obj[key] = coerceValue(defaultValue); + continue + } + + // envVarName contained some value. + + /* + * For numeric and boolean strings let's convert it to proper types before + * returning it, in order to maintain backward compatibility. + */ + logger.debug( + `Configuration key "${key}" will be read from environment variable "${envVarName}"`); + + obj[key] = coerceValue(envVarValue!); + } + return obj } - /* - * If we received from the configuration file a number, a boolean or - * something that is not a string, we can be sure that it was a literal - * value. No need to perform any variable substitution. - * - * The environment variable expansion syntax "${ENV_VAR}" is just a string - * of specific form, after all. + replaceEnvs(obj); + + // Add plugin ENV variables + + /** + * If the key contains a double underscore, it's a plugin variable + * E.g. */ - if (typeof value !== 'string') { - return value; + let treeEntries = new Map + const root = new SettingsNode("EP") + + for (let [env, envVal] of Object.entries(process.env)) { + if (!env.startsWith("EP")) continue + treeEntries.set(env, envVal) } + treeEntries.forEach((value, key) => { + let pathToKey = key.split("__") + let currentNode = root + let depth = 0 + depth++ + currentNode.addChild(pathToKey, value!) + }) - /* - * Let's check if the string value looks like a variable expansion (e.g.: - * "${ENV_VAR}" or "${ENV_VAR:default_value}") - */ - // MUXATOR 2019-03-21: we could use named capture groups here once we migrate to nodejs v10 - const match = value.match(/^\$\{([^:]*)(:((.|\n)*))?\}$/); - - if (match == null) { - // no match: use the value literally, without any substitution - - return value; - } - - /* - * We found the name of an environment variable. Let's read its actual value - * and its default value, if given - */ - const envVarName = match[1]; - const envVarValue = process.env[envVarName]; - const defaultValue = match[3]; - - if ((envVarValue === undefined) && (defaultValue === undefined)) { - logger.warn(`Environment variable "${envVarName}" does not contain any value for ` + - `configuration key "${key}", and no default was given. Using null. ` + - 'THIS BEHAVIOR MAY CHANGE IN A FUTURE VERSION OF ETHERPAD; you should ' + - 'explicitly use "null" as the default if you want to continue to use null.'); - - /* - * We have to return null, because if we just returned undefined, the - * configuration item "key" would be stripped from the returned object. - */ - return null; - } - - if ((envVarValue === undefined) && (defaultValue !== undefined)) { - logger.debug(`Environment variable "${envVarName}" not found for ` + - `configuration key "${key}". Falling back to default value.`); - - return coerceValue(defaultValue); - } - - // envVarName contained some value. - - /* - * For numeric and boolean strings let's convert it to proper types before - * returning it, in order to maintain backward compatibility. - */ - logger.debug( - `Configuration key "${key}" will be read from environment variable "${envVarName}"`); - - return coerceValue(envVarValue!); - }); - - const newSettings = JSON.parse(stringifiedAndReplaced); - - return newSettings; + //console.log(root.collectFromLeafsUpwards()) + const rooting = root.collectFromLeafsUpwards() + console.log("Rooting is", rooting.ADMIN) + obj = Object.assign(obj, rooting) + return obj; }; + /** * - reads the JSON configuration file settingsFilename from disk * - strips the comments @@ -686,188 +742,186 @@ const lookupEnvironmentVariables = (obj: object) => { * * The isSettings variable only controls the error logging. */ -const parseSettings = (settingsFilename:string, isSettings:boolean) => { - let settingsStr = ''; +const parseSettings = (settingsFilename: string, isSettings: boolean) => { + let settingsStr = ''; - let settingsType, notFoundMessage, notFoundFunction; + let settingsType, notFoundMessage, notFoundFunction; - if (isSettings) { - settingsType = 'settings'; - notFoundMessage = 'Continuing using defaults!'; - notFoundFunction = logger.warn.bind(logger); - } else { - settingsType = 'credentials'; - notFoundMessage = 'Ignoring.'; - notFoundFunction = logger.info.bind(logger); - } + if (isSettings) { + settingsType = 'settings'; + notFoundMessage = 'Continuing using defaults!'; + notFoundFunction = logger.warn.bind(logger); + } else { + settingsType = 'credentials'; + notFoundMessage = 'Ignoring.'; + notFoundFunction = logger.info.bind(logger); + } - try { - // read the settings file - settingsStr = fs.readFileSync(settingsFilename).toString(); - } catch (e) { - notFoundFunction(`No ${settingsType} file found in ${settingsFilename}. ${notFoundMessage}`); + try { + // read the settings file + settingsStr = fs.readFileSync(settingsFilename).toString(); + } catch (e) { + notFoundFunction(`No ${settingsType} file found in ${settingsFilename}. ${notFoundMessage}`); - // or maybe undefined! - return null; - } + // or maybe undefined! + return null; + } - try { - settingsStr = jsonminify(settingsStr).replace(',]', ']').replace(',}', '}'); + try { + settingsStr = jsonminify(settingsStr).replace(',]', ']').replace(',}', '}'); - const settings = JSON.parse(settingsStr); + const settings = JSON.parse(settingsStr); - logger.info(`${settingsType} loaded from: ${settingsFilename}`); + logger.info(`${settingsType} loaded from: ${settingsFilename}`); - const replacedSettings = lookupEnvironmentVariables(settings); + return lookupEnvironmentVariables(settings); + } catch (e: any) { + logger.error(`There was an error processing your ${settingsType} ` + + `file from ${settingsFilename}: ${e.message}`); - return replacedSettings; - } catch (e:any) { - logger.error(`There was an error processing your ${settingsType} ` + - `file from ${settingsFilename}: ${e.message}`); - - process.exit(1); - } + process.exit(1); + } }; exports.reloadSettings = () => { - const settings = parseSettings(exports.settingsFilename, true); - const credentials = parseSettings(exports.credentialsFilename, false); - storeSettings(settings); - storeSettings(credentials); + const settings = parseSettings(exports.settingsFilename, true); + const credentials = parseSettings(exports.credentialsFilename, false); + storeSettings(settings); + storeSettings(credentials); - // Init logging config - exports.logconfig = defaultLogConfig(exports.loglevel ? exports.loglevel : defaultLogLevel); - initLogging(exports.logconfig); + // Init logging config + exports.logconfig = defaultLogConfig(exports.loglevel ? exports.loglevel : defaultLogLevel); + initLogging(exports.logconfig); - if (!exports.skinName) { - logger.warn('No "skinName" parameter found. Please check out settings.json.template and ' + - 'update your settings.json. Falling back to the default "colibris".'); - exports.skinName = 'colibris'; - } - - // checks if skinName has an acceptable value, otherwise falls back to "colibris" - if (exports.skinName) { - const skinBasePath = path.join(exports.root, 'src', 'static', 'skins'); - const countPieces = exports.skinName.split(path.sep).length; - - if (countPieces !== 1) { - logger.error(`skinName must be the name of a directory under "${skinBasePath}". This is ` + - `not valid: "${exports.skinName}". Falling back to the default "colibris".`); - - exports.skinName = 'colibris'; + if (!exports.skinName) { + logger.warn('No "skinName" parameter found. Please check out settings.json.template and ' + + 'update your settings.json. Falling back to the default "colibris".'); + exports.skinName = 'colibris'; } - // informative variable, just for the log messages - let skinPath = path.join(skinBasePath, exports.skinName); + // checks if skinName has an acceptable value, otherwise falls back to "colibris" + if (exports.skinName) { + const skinBasePath = path.join(exports.root, 'src', 'static', 'skins'); + const countPieces = exports.skinName.split(path.sep).length; - // what if someone sets skinName == ".." or "."? We catch him! - if (absolutePaths.isSubdir(skinBasePath, skinPath) === false) { - logger.error(`Skin path ${skinPath} must be a subdirectory of ${skinBasePath}. ` + - 'Falling back to the default "colibris".'); + if (countPieces !== 1) { + logger.error(`skinName must be the name of a directory under "${skinBasePath}". This is ` + + `not valid: "${exports.skinName}". Falling back to the default "colibris".`); - exports.skinName = 'colibris'; - skinPath = path.join(skinBasePath, exports.skinName); - } - - if (fs.existsSync(skinPath) === false) { - logger.error(`Skin path ${skinPath} does not exist. Falling back to the default "colibris".`); - exports.skinName = 'colibris'; - skinPath = path.join(skinBasePath, exports.skinName); - } - - logger.info(`Using skin "${exports.skinName}" in dir: ${skinPath}`); - } - - if (exports.abiword) { - // Check abiword actually exists - if (exports.abiword != null) { - fs.exists(exports.abiword, (exists: boolean) => { - if (!exists) { - const abiwordError = 'Abiword does not exist at this path, check your settings file.'; - if (!exports.suppressErrorsInPadText) { - exports.defaultPadText += `\nError: ${abiwordError}${suppressDisableMsg}`; - } - logger.error(`${abiwordError} File location: ${exports.abiword}`); - exports.abiword = null; + exports.skinName = 'colibris'; } - }); + + // informative variable, just for the log messages + let skinPath = path.join(skinBasePath, exports.skinName); + + // what if someone sets skinName == ".." or "."? We catch him! + if (absolutePaths.isSubdir(skinBasePath, skinPath) === false) { + logger.error(`Skin path ${skinPath} must be a subdirectory of ${skinBasePath}. ` + + 'Falling back to the default "colibris".'); + + exports.skinName = 'colibris'; + skinPath = path.join(skinBasePath, exports.skinName); + } + + if (fs.existsSync(skinPath) === false) { + logger.error(`Skin path ${skinPath} does not exist. Falling back to the default "colibris".`); + exports.skinName = 'colibris'; + skinPath = path.join(skinBasePath, exports.skinName); + } + + logger.info(`Using skin "${exports.skinName}" in dir: ${skinPath}`); } - } - if (exports.soffice) { - fs.exists(exports.soffice, (exists: boolean) => { - if (!exists) { - const sofficeError = - 'soffice (libreoffice) does not exist at this path, check your settings file.'; + if (exports.abiword) { + // Check abiword actually exists + if (exports.abiword != null) { + fs.exists(exports.abiword, (exists: boolean) => { + if (!exists) { + const abiwordError = 'Abiword does not exist at this path, check your settings file.'; + if (!exports.suppressErrorsInPadText) { + exports.defaultPadText += `\nError: ${abiwordError}${suppressDisableMsg}`; + } + logger.error(`${abiwordError} File location: ${exports.abiword}`); + exports.abiword = null; + } + }); + } + } + if (exports.soffice) { + fs.exists(exports.soffice, (exists: boolean) => { + if (!exists) { + const sofficeError = + 'soffice (libreoffice) does not exist at this path, check your settings file.'; + + if (!exports.suppressErrorsInPadText) { + exports.defaultPadText += `\nError: ${sofficeError}${suppressDisableMsg}`; + } + logger.error(`${sofficeError} File location: ${exports.soffice}`); + exports.soffice = null; + } + }); + } + + const sessionkeyFilename = absolutePaths.makeAbsolute(argv.sessionkey || './SESSIONKEY.txt'); + if (!exports.sessionKey) { + try { + exports.sessionKey = fs.readFileSync(sessionkeyFilename, 'utf8'); + logger.info(`Session key loaded from: ${sessionkeyFilename}`); + } catch (err) { /* ignored */ + } + const keyRotationEnabled = exports.cookie.keyRotationInterval && exports.cookie.sessionLifetime; + if (!exports.sessionKey && !keyRotationEnabled) { + logger.info( + `Session key file "${sessionkeyFilename}" not found. Creating with random contents.`); + exports.sessionKey = randomString(32); + fs.writeFileSync(sessionkeyFilename, exports.sessionKey, 'utf8'); + } + } else { + logger.warn('Declaring the sessionKey in the settings.json is deprecated. ' + + 'This value is auto-generated now. Please remove the setting from the file. -- ' + + 'If you are seeing this error after restarting using the Admin User ' + + 'Interface then you can ignore this message.'); + } + if (exports.sessionKey) { + logger.warn(`The sessionKey setting and ${sessionkeyFilename} file are deprecated; ` + + 'use automatic key rotation instead (see the cookie.keyRotationInterval setting).'); + } + + if (exports.dbType === 'dirty') { + const dirtyWarning = 'DirtyDB is used. This is not recommended for production.'; if (!exports.suppressErrorsInPadText) { - exports.defaultPadText += `\nError: ${sofficeError}${suppressDisableMsg}`; + exports.defaultPadText += `\nWarning: ${dirtyWarning}${suppressDisableMsg}`; } - logger.error(`${sofficeError} File location: ${exports.soffice}`); - exports.soffice = null; - } - }); - } - const sessionkeyFilename = absolutePaths.makeAbsolute(argv.sessionkey || './SESSIONKEY.txt'); - if (!exports.sessionKey) { - try { - exports.sessionKey = fs.readFileSync(sessionkeyFilename, 'utf8'); - logger.info(`Session key loaded from: ${sessionkeyFilename}`); - } catch (err) { /* ignored */ } - const keyRotationEnabled = exports.cookie.keyRotationInterval && exports.cookie.sessionLifetime; - if (!exports.sessionKey && !keyRotationEnabled) { - logger.info( - `Session key file "${sessionkeyFilename}" not found. Creating with random contents.`); - exports.sessionKey = randomString(32); - fs.writeFileSync(sessionkeyFilename, exports.sessionKey, 'utf8'); - } - } else { - logger.warn('Declaring the sessionKey in the settings.json is deprecated. ' + - 'This value is auto-generated now. Please remove the setting from the file. -- ' + - 'If you are seeing this error after restarting using the Admin User ' + - 'Interface then you can ignore this message.'); - } - if (exports.sessionKey) { - logger.warn(`The sessionKey setting and ${sessionkeyFilename} file are deprecated; ` + - 'use automatic key rotation instead (see the cookie.keyRotationInterval setting).'); - } - - if (exports.dbType === 'dirty') { - const dirtyWarning = 'DirtyDB is used. This is not recommended for production.'; - if (!exports.suppressErrorsInPadText) { - exports.defaultPadText += `\nWarning: ${dirtyWarning}${suppressDisableMsg}`; + exports.dbSettings.filename = absolutePaths.makeAbsolute(exports.dbSettings.filename); + logger.warn(`${dirtyWarning} File location: ${exports.dbSettings.filename}`); } - exports.dbSettings.filename = absolutePaths.makeAbsolute(exports.dbSettings.filename); - logger.warn(`${dirtyWarning} File location: ${exports.dbSettings.filename}`); - } + if (exports.ip === '') { + // using Unix socket for connectivity + logger.warn('The settings file contains an empty string ("") for the "ip" parameter. The ' + + '"port" parameter will be interpreted as the path to a Unix socket to bind at.'); + } - if (exports.ip === '') { - // using Unix socket for connectivity - logger.warn('The settings file contains an empty string ("") for the "ip" parameter. The ' + - '"port" parameter will be interpreted as the path to a Unix socket to bind at.'); - } - - /* - * At each start, Etherpad generates a random string and appends it as query - * parameter to the URLs of the static assets, in order to force their reload. - * Subsequent requests will be cached, as long as the server is not reloaded. - * - * For the rationale behind this choice, see - * https://github.com/ether/etherpad-lite/pull/3958 - * - * ACHTUNG: this may prevent caching HTTP proxies to work - * TODO: remove the "?v=randomstring" parameter, and replace with hashed filenames instead - */ - exports.randomVersionString = randomString(4); - logger.info(`Random string used for versioning assets: ${exports.randomVersionString}`); + /* + * At each start, Etherpad generates a random string and appends it as query + * parameter to the URLs of the static assets, in order to force their reload. + * Subsequent requests will be cached, as long as the server is not reloaded. + * + * For the rationale behind this choice, see + * https://github.com/ether/etherpad-lite/pull/3958 + * + * ACHTUNG: this may prevent caching HTTP proxies to work + * TODO: remove the "?v=randomstring" parameter, and replace with hashed filenames instead + */ + exports.randomVersionString = randomString(4); + logger.info(`Random string used for versioning assets: ${exports.randomVersionString}`); }; exports.exportedForTestingOnly = { - parseSettings, + parseSettings, }; // initially load settings exports.reloadSettings(); - diff --git a/src/node/utils/SettingsTree.ts b/src/node/utils/SettingsTree.ts new file mode 100644 index 000000000..c505f2ebb --- /dev/null +++ b/src/node/utils/SettingsTree.ts @@ -0,0 +1,112 @@ +import {MapArrayType} from "../types/MapType"; + +export class SettingsTree { + private children: Map; + constructor() { + this.children = new Map(); + } + + public addChild(key: string, value: string) { + this.children.set(key, new SettingsNode(key, value)); + } + + public removeChild(key: string) { + this.children.delete(key); + } + + public getChild(key: string) { + return this.children.get(key); + } + + public hasChild(key: string) { + return this.children.has(key); + } +} + + +export class SettingsNode { + private readonly key: string; + private value: string | number | boolean | null | undefined; + private children: MapArrayType; + + constructor(key: string, value?: string | number | boolean | null | undefined) { + this.key = key; + this.value = value; + this.children = {} + } + + public addChild(path: string[], value: string) { + let currentNode:SettingsNode = this; + for (let i = 0; i < path.length; i++) { + const key = path[i]; + /* + Skip the current node if the key is the same as the current node's key + */ + if (key === this.key ) { + continue + } + /* + If the current node does not have a child with the key, create a new node with the key + */ + if (!currentNode.hasChild(key)) { + currentNode = currentNode.children[key] = new SettingsNode(key, this.coerceValue(value)); + } else { + /* + Else move to the child node + */ + currentNode = currentNode.getChild(key); + } + } + } + + + public collectFromLeafsUpwards() { + let collected:MapArrayType = {}; + for (const key in this.children) { + const child = this.children[key]; + if (child.hasChildren()) { + collected[key] = child.collectFromLeafsUpwards(); + } else { + collected[key] = child.value; + } + } + return collected; + } + + coerceValue = (stringValue: string) => { + // cooked from https://stackoverflow.com/questions/175739/built-in-way-in-javascript-to-check-if-a-string-is-a-valid-number + // @ts-ignore + const isNumeric = !isNaN(stringValue) && !isNaN(parseFloat(stringValue) && isFinite(stringValue)); + + if (isNumeric) { + // detected numeric string. Coerce to a number + + return +stringValue; + } + + switch (stringValue) { + case 'true': + return true; + case 'false': + return false; + case 'undefined': + return undefined; + case 'null': + return null; + default: + return stringValue; + } + }; + + public hasChildren() { + return Object.keys(this.children).length > 0; + } + + public getChild(key: string) { + return this.children[key]; + } + + public hasChild(key: string) { + return this.children[key] !== undefined; + } +} diff --git a/src/tests/backend/specs/settings.ts b/src/tests/backend/specs/settings.ts index 4ed447931..d9bfe4f6d 100644 --- a/src/tests/backend/specs/settings.ts +++ b/src/tests/backend/specs/settings.ts @@ -6,56 +6,87 @@ import path from 'path'; import process from 'process'; describe(__filename, function () { - describe('parseSettings', function () { - let settings:any; - const envVarSubstTestCases = [ - {name: 'true', val: 'true', var: 'SET_VAR_TRUE', want: true}, - {name: 'false', val: 'false', var: 'SET_VAR_FALSE', want: false}, - {name: 'null', val: 'null', var: 'SET_VAR_NULL', want: null}, - {name: 'undefined', val: 'undefined', var: 'SET_VAR_UNDEFINED', want: undefined}, - {name: 'number', val: '123', var: 'SET_VAR_NUMBER', want: 123}, - {name: 'string', val: 'foo', var: 'SET_VAR_STRING', want: 'foo'}, - {name: 'empty string', val: '', var: 'SET_VAR_EMPTY_STRING', want: ''}, - ]; + describe('parseSettings', function () { + let settings: any; + const envVarSubstTestCases = [ + {name: 'true', val: 'true', var: 'SET_VAR_TRUE', want: true}, + {name: 'false', val: 'false', var: 'SET_VAR_FALSE', want: false}, + {name: 'null', val: 'null', var: 'SET_VAR_NULL', want: null}, + {name: 'undefined', val: 'undefined', var: 'SET_VAR_UNDEFINED', want: undefined}, + {name: 'number', val: '123', var: 'SET_VAR_NUMBER', want: 123}, + {name: 'string', val: 'foo', var: 'SET_VAR_STRING', want: 'foo'}, + {name: 'empty string', val: '', var: 'SET_VAR_EMPTY_STRING', want: ''}, + ]; - before(async function () { - for (const tc of envVarSubstTestCases) process.env[tc.var] = tc.val; - delete process.env.UNSET_VAR; - settings = parseSettings(path.join(__dirname, 'settings.json'), true); - assert(settings != null); - }); - - describe('environment variable substitution', function () { - describe('set', function () { - for (const tc of envVarSubstTestCases) { - it(tc.name, async function () { - const obj = settings['environment variable substitution'].set; - if (tc.name === 'undefined') { - assert(!(tc.name in obj)); - } else { - assert.equal(obj[tc.name], tc.want); - } - }); - } - }); - - describe('unset', function () { - it('no default', async function () { - const obj = settings['environment variable substitution'].unset; - assert.equal(obj['no default'], null); + before(async function () { + for (const tc of envVarSubstTestCases) process.env[tc.var] = tc.val; + delete process.env.UNSET_VAR; + settings = parseSettings(path.join(__dirname, 'settings.json'), true); + assert(settings != null); }); - for (const tc of envVarSubstTestCases) { - it(tc.name, async function () { - const obj = settings['environment variable substitution'].unset; - if (tc.name === 'undefined') { - assert(!(tc.name in obj)); - } else { - assert.equal(obj[tc.name], tc.want); - } - }); - } - }); + describe('environment variable substitution', function () { + describe('set', function () { + for (const tc of envVarSubstTestCases) { + it(tc.name, async function () { + const obj = settings['environment variable substitution'].set; + if (tc.name === 'undefined') { + assert(!(tc.name in obj)); + } else { + assert.equal(obj[tc.name], tc.want); + } + }); + } + }); + + describe('unset', function () { + it('no default', async function () { + const obj = settings['environment variable substitution'].unset; + assert.equal(obj['no default'], null); + }); + + for (const tc of envVarSubstTestCases) { + it(tc.name, async function () { + const obj = settings['environment variable substitution'].unset; + if (tc.name === 'undefined') { + assert(!(tc.name in obj)); + } else { + assert.equal(obj[tc.name], tc.want); + } + }); + } + }); + }); }); - }); + + + describe("Parse plugin settings", function () { + + before(async function () { + process.env["EP__ADMIN__PASSWORD"] = "test" + }) + + it('should parse plugin settings', async function () { + let settings = parseSettings(path.join(__dirname, 'settings.json'), true); + assert.equal(settings.ADMIN.PASSWORD, "test"); + }) + + it('should bundle settings with same path', async function () { + process.env["EP__ADMIN__USERNAME"] = "test" + let settings = parseSettings(path.join(__dirname, 'settings.json'), true); + assert.deepEqual(settings.ADMIN, {PASSWORD: "test", USERNAME: "test"}); + }) + + it("Can set the ep themes", async function () { + process.env["EP__ep_themes__default_theme"] = "hacker" + let settings = parseSettings(path.join(__dirname, 'settings.json'), true); + assert.deepEqual(settings.ep_themes, {"default_theme": "hacker"}); + }) + + it("can set the ep_webrtc settings", async function () { + process.env["EP__ep_webrtc__enabled"] = "true" + let settings = parseSettings(path.join(__dirname, 'settings.json'), true); + assert.deepEqual(settings.ep_webrtc, {"enabled": true}); + }) + }) });