diff --git a/docker/README.md b/docker/README.md index 2c5ad028c93..87d6e81eaca 100644 --- a/docker/README.md +++ b/docker/README.md @@ -14,6 +14,8 @@ cp ../settings.json.template settings.json [ further edit your settings.json as needed] ``` +**Each configuration parameter can also be set via an environment variable**, using the syntax `"${ENV_VAR_NAME}"`. For details, refer to `settings.json.template`. + Build the version you prefer: ```bash # builds latest development version diff --git a/settings.json.template b/settings.json.template index a89cc4247cb..50a095bac65 100644 --- a/settings.json.template +++ b/settings.json.template @@ -3,8 +3,41 @@ * * Please edit settings.json, not settings.json.template * - * Please note that since Etherpad 1.6.0 you can store DB credentials in a - * separate file (credentials.json). + * Please note that starting from Etherpad 1.6.0 you can store DB credentials in + * a separate file (credentials.json). + * + * + * ENVIRONMENT VARIABLE SUBSTITUTION + * ================================= + * + * All the configuration values can be read from environment variables using the + * syntax "${ENV_VAR_NAME}". + * This is useful, for example, when running in a Docker container. + * + * EXAMPLE: + * "port": "${PORT}" + * "minify": "${MINIFY}" + * "skinName": "${SKIN_NAME}" + * + * Would read the configuration values for those items from the environment + * variables PORT, MINIFY and SKIN_NAME. + * + * REMARKS: + * Please note that a variable substitution always needs to be quoted. + * "port": 9001, <-- Literal values. When not using substitution, + * "minify": false only strings must be quoted: booleans and + * "skin": "colibris" numbers must not. + * + * "port": ${PORT} <-- ERROR: this is not valid json + * "minify": ${MINIFY} + * "skin": ${SKIN_NAME} + * + * "port": "${PORT}" <-- CORRECT: if you want to use a variable + * "minify": "${MINIFY}" substitution, put quotes around its name, + * "skin": "${SKIN_NAME}" even if the required value is a number or a + * boolean. + * Etherpad will take care of rewriting it to + * the proper type if necessary. */ { /* diff --git a/src/node/utils/Settings.js b/src/node/utils/Settings.js index 508e6148d4c..d013d422294 100644 --- a/src/node/utils/Settings.js +++ b/src/node/utils/Settings.js @@ -287,29 +287,26 @@ exports.scrollWhenFocusLineIsOutOfViewport = { //checks if abiword is avaiable exports.abiwordAvailable = function() { - if(exports.abiword != null) - { + if (exports.abiword != null) { return os.type().indexOf("Windows") != -1 ? "withoutPDF" : "yes"; - } - else - { + } else { return "no"; } }; -exports.sofficeAvailable = function () { - if(exports.soffice != null) { +exports.sofficeAvailable = function() { + if (exports.soffice != null) { return os.type().indexOf("Windows") != -1 ? "withoutPDF": "yes"; } else { return "no"; } }; -exports.exportAvailable = function () { +exports.exportAvailable = function() { var abiword = exports.abiwordAvailable(); var soffice = exports.sofficeAvailable(); - if(abiword == "no" && soffice == "no") { + if (abiword == "no" && soffice == "no") { return "no"; } else if ((abiword == "withoutPDF" && soffice == "no") || (abiword == "no" && soffice == "withoutPDF")) { return "withoutPDF"; @@ -321,8 +318,7 @@ exports.exportAvailable = function () { // Provide git version if available exports.getGitCommit = function() { var version = ""; - try - { + try { var rootPath = path.resolve(npm.dir, '..'); if (fs.lstatSync(rootPath + '/.git').isFile()) { rootPath = fs.readFileSync(rootPath + '/.git', "utf8"); @@ -334,9 +330,7 @@ exports.getGitCommit = function() { var refPath = rootPath + "/" + ref.substring(5, ref.indexOf("\n")); version = fs.readFileSync(refPath, "utf-8"); version = version.substring(0, 7); - } - catch(e) - { + } catch(e) { console.warn("Can't get git version for server header\n" + e.message) } return version; @@ -347,101 +341,209 @@ exports.getEpVersion = function() { return require('ep_etherpad-lite/package.json').version; } -exports.reloadSettings = function reloadSettings() { - // Discover where the settings file lives - var settingsFilename = absolutePaths.makeAbsolute(argv.settings || "settings.json"); - - // Discover if a credential file exists - var credentialsFilename = absolutePaths.makeAbsolute(argv.credentials || "credentials.json"); +/** + * Receives a settingsObj and, if the property name is a valid configuration + * item, stores it in the module's exported properties via a side effect. + * + * This code refactors a previous version that copied & pasted the same code for + * both "settings.json" and "credentials.json". + */ +function storeSettings(settingsObj) { + for (var i in settingsObj) { + // test if the setting starts with a lowercase character + if (i.charAt(0).search("[a-z]") !== 0) { + console.warn(`Settings should start with a lowercase character: '${i}'`); + } - var settingsStr, credentialsStr; - try{ - //read the settings sync - settingsStr = fs.readFileSync(settingsFilename).toString(); - console.info(`Settings loaded from: ${settingsFilename}`); - } catch(e){ - console.warn(`No settings file found in ${settingsFilename}. Continuing using defaults!`); + // 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]) && !_.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 + console.warn(`Unknown Setting: '${i}'. This setting doesn't exist or it was removed`); + } } +} - try{ - //read the credentials sync - credentialsStr = fs.readFileSync(credentialsFilename).toString(); - console.info(`Credentials file read from: ${credentialsFilename}`); - } catch(e){ - // Doesn't matter if no credentials file found.. - console.info(`No credentials file found in ${credentialsFilename}. Ignoring.`); - } +/** + * Takes a javascript object containing Etherpad's configuration, and returns + * another object, in which all the string properties whose name is of the form + * "${ENV_VAR}", got their value replaced with the value of the given + * environment variable. + * + * An environment variable's value is always a string. However, the code base + * makes use of the various json types. To maintain compatiblity, some + * heuristics is applied: + * + * - if ENV_VAR does not exist in the environment, null is returned; + * - if ENV_VAR's value is "true" or "false", it is converted to the js boolean + * values true or false; + * - if ENV_VAR's value looks like a number, it is converted to a js number + * (details in the code). + * + * Variable substitution is performed doing a round trip conversion to/from + * json, using a custom replacer parameter in JSON.stringify(), and parsing the + * JSON back again. This ensures that environment variable replacement is + * performed even on nested objects. + * + * see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#The_replacer_parameter + */ +function lookupEnvironmentVariables(obj) { + 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; + } - // try to parse the settings - var settings; - var credentials; - try { - if(settingsStr) { - settingsStr = jsonminify(settingsStr).replace(",]","]").replace(",}","}"); - settings = JSON.parse(settingsStr); + /* + * 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 (typeof value !== 'string') { + return value; } - }catch(e){ - console.error(`There was an error processing your settings file from ${settingsFilename}:` + e.message); - process.exit(1); - } - if(credentialsStr) { - credentialsStr = jsonminify(credentialsStr).replace(",]","]").replace(",}","}"); - credentials = JSON.parse(credentialsStr); - } + /* + * Let's check if the string value looks like a variable expansion (e.g.: + * "${ENV_VAR}") + */ + const match = value.match(/^\$\{(.*)\}$/); - //loop trough the settings - for(var i in settings) - { - //test if the setting start with a lowercase character - if(i.charAt(0).search("[a-z]") !== 0) - { - console.warn(`Settings should start with a lowercase character: '${i}'`); - } + if (match === null) { + // no match: use the value literally, without any substitution - //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(settings[i]) && !_.isArray(settings[i])) { - exports[i] = _.defaults(settings[i], exports[i]); - } else { - exports[i] = settings[i]; - } - } - //this setting is unkown, output a warning and throw it away - else - { - console.warn(`Unknown Setting: '${i}'. This setting doesn't exist or it was removed`); + return value; } - } - //loop trough the settings - for(var i in credentials) - { - //test if the setting start with a lowercase character - if(i.charAt(0).search("[a-z]") !== 0) - { - console.warn(`Settings should start with a lowercase character: '${i}'`); + // we found the name of an environment variable. Let's read its value. + const envVarName = match[1]; + const envVarValue = process.env[envVarName]; + + if (envVarValue === undefined) { + console.warn(`Configuration key ${key} tried to read its value from environment variable ${envVarName}, but no value was found. Returning null. Please check your configuration and environment settings.`); + + /* + * We have to return null, because if we just returned undefined, the + * configuration item "key" would be stripped from the returned object. + */ + return null; } - //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(credentials[i]) && !_.isArray(credentials[i])) { - exports[i] = _.defaults(credentials[i], exports[i]); - } else { - exports[i] = credentials[i]; - } + // 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. + */ + + // cooked from https://stackoverflow.com/questions/175739/built-in-way-in-javascript-to-check-if-a-string-is-a-valid-number + const isNumeric = !isNaN(envVarValue) && !isNaN(parseFloat(envVarValue) && isFinite(envVarValue)); + + if (isNumeric) { + console.debug(`Configuration key "${key}" will be read from environment variable ${envVarName}. Detected numeric string, that will be coerced to a number`); + + return +envVarValue; } - //this setting is unkown, output a warning and throw it away - else - { - console.warn(`Unknown Setting: '${i}'. This setting doesn't exist or it was removed`); + + // the boolean literal case is easy. + if (envVarValue === "true" || envVarValue === "false") { + console.debug(`Configuration key "${key}" will be read from environment variable ${envVarName}. Detected boolean string, that will be coerced to a boolean`); + + return (envVarValue === "true"); } + + /* + * The only remaining case is that envVarValue is a string with no special + * meaning, and we just return it as-is. + */ + console.debug(`Configuration key "${key}" will be read from environment variable ${envVarName}`); + + return envVarValue; + }); + + const newSettings = JSON.parse(stringifiedAndReplaced); + + return newSettings; +} + +/** + * - reads the JSON configuration file settingsFilename from disk + * - strips the comments + * - replaces environment variables calling lookupEnvironmentVariables() + * - returns a parsed Javascript object + * + * The isSettings variable only controls the error logging. + */ +function parseSettings(settingsFilename, isSettings) { + let settingsStr = ""; + + let settingsType, notFoundMessage, notFoundFunction; + + if (isSettings) { + settingsType = "settings"; + notFoundMessage = "Continuing using defaults!"; + notFoundFunction = console.warn; + } else { + settingsType = "credentials"; + notFoundMessage = "Ignoring."; + notFoundFunction = console.info; + } + + 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; } + try { + settingsStr = jsonminify(settingsStr).replace(",]","]").replace(",}","}"); + + const settings = JSON.parse(settingsStr); + + console.info(`${settingsType} loaded from: ${settingsFilename}`); + + const replacedSettings = lookupEnvironmentVariables(settings); + + return replacedSettings; + } catch(e) { + console.error(`There was an error processing your ${settingsType} file from ${settingsFilename}: ${e.message}`); + + process.exit(1); + } +} + +exports.reloadSettings = function reloadSettings() { + // Discover where the settings file lives + var settingsFilename = absolutePaths.makeAbsolute(argv.settings || "settings.json"); + + // Discover if a credential file exists + var credentialsFilename = absolutePaths.makeAbsolute(argv.credentials || "credentials.json"); + + // try to parse the settings + var settings = parseSettings(settingsFilename, true); + + // try to parse the credentials + var credentials = parseSettings(credentialsFilename, false); + + storeSettings(settings); + storeSettings(credentials); + log4js.configure(exports.logconfig);//Configure the logging appenders log4js.setGlobalLogLevel(exports.loglevel);//set loglevel process.env['DEBUG'] = 'socket.io:' + exports.loglevel; // Used by SocketIO for Debug @@ -483,32 +585,31 @@ exports.reloadSettings = function reloadSettings() { console.info(`Using skin "${exports.skinName}" in dir: ${skinPath}`); } - if(exports.abiword){ + if (exports.abiword) { // Check abiword actually exists - if(exports.abiword != null) - { + if (exports.abiword != null) { fs.exists(exports.abiword, function(exists) { if (!exists) { - var abiwordError = "Abiword does not exist at this path, check your settings file"; - if(!exports.suppressErrorsInPadText){ + var abiwordError = "Abiword does not exist at this path, check your settings file."; + if (!exports.suppressErrorsInPadText) { exports.defaultPadText = exports.defaultPadText + "\nError: " + abiwordError + suppressDisableMsg; } - console.error(abiwordError); + console.error(abiwordError + ` File location: ${exports.abiword}`); exports.abiword = null; } }); } } - if(exports.soffice) { - fs.exists(exports.soffice, function (exists) { - if(!exists) { - var sofficeError = "SOffice does not exist at this path, check your settings file"; + if (exports.soffice) { + fs.exists(exports.soffice, function(exists) { + if (!exists) { + var sofficeError = "soffice (libreoffice) does not exist at this path, check your settings file."; - if(!exports.suppressErrorsInPadText) { + if (!exports.suppressErrorsInPadText) { exports.defaultPadText = exports.defaultPadText + "\nError: " + sofficeError + suppressDisableMsg; } - console.error(sofficeError); + console.error(sofficeError + ` File location: ${exports.soffice}`); exports.soffice = null; } }); @@ -528,9 +629,9 @@ exports.reloadSettings = function reloadSettings() { console.warn("Declaring the sessionKey in the settings.json is deprecated. This value is auto-generated now. Please remove the setting from the file."); } - if(exports.dbType === "dirty"){ + if (exports.dbType === "dirty") { var dirtyWarning = "DirtyDB is used. This is fine for testing but not recommended for production."; - if(!exports.suppressErrorsInPadText){ + if (!exports.suppressErrorsInPadText) { exports.defaultPadText = exports.defaultPadText + "\nWarning: " + dirtyWarning + suppressDisableMsg; } @@ -541,5 +642,3 @@ exports.reloadSettings = function reloadSettings() { // initially load settings exports.reloadSettings(); - -