/**
* @module config-model
*/
const config = require('../../config/default-config');
const pkg = require('../../package.json');
const mergeWith = require('lodash/mergeWith');
const path = require('path');
const fs = require('fs');
const url = require('url');
const themePath = path.join(__dirname, '../../public/css');
const languagePath = path.join(__dirname, '../../locales/src');
const pkgDir = require('pkg-dir');
const { execSync } = require('child_process');
// var debug = require( 'debug' )( 'config-model' );
// Merge default and local config files if a local config.json file exists
try {
const localConfigJSON = String(
fs.readFileSync(path.resolve(process.cwd(), './config/config.json'))
);
const localConfig = JSON.parse(localConfigJSON);
mergeWith(config, localConfig, (objValue, srcValue) => {
if (Array.isArray(srcValue)) {
// Overwrite completely if value in localConfig is an array (do not merge arrays)
return srcValue;
}
});
} catch (err) {
// Override default config with environment variables if a local config.json does not exist
console.warn(
'No local config.json found. Will check environment variables instead.'
);
_updateConfigFromEnv(config);
_setRedisConfigFromEnv();
}
/**
* Updates all configuration items for which an environment variable was set.
*/
function _updateConfigFromEnv() {
const envVarNames = [];
for (const envVarName in process.env) {
if (
Object.prototype.hasOwnProperty.call(process.env, envVarName) &&
envVarName.indexOf('ENKETO_') === 0
) {
envVarNames.push(envVarName);
}
}
envVarNames.sort().forEach(_updateConfigItemFromEnv);
}
/**
* Updates a configuration item that corresponds to the provided environment variable name.
*
* @param { string } envVarName - environment variable name
*/
function _updateConfigItemFromEnv(envVarName) {
const parts = envVarName.split('_').slice(1).map(_convertNumbers);
let nextNumberIndex = _findNumberIndex(parts);
let proceed = true;
let part;
let settingArr;
let setting;
let propName;
while (proceed) {
proceed = false;
part = parts.slice(0, nextNumberIndex).join('_');
settingArr = _findSetting(config, part);
if (settingArr) {
setting = settingArr[0];
propName = settingArr[1];
if (!Array.isArray(setting[propName])) {
setting[propName] = _convertType(process.env[envVarName]);
} else if (nextNumberIndex === parts.length - 1) {
// simple populate array item (simple value)
setting[propName][parts[nextNumberIndex]] =
process.env[envVarName];
} else if (
typeof setting[propName][parts[nextNumberIndex]] !== 'undefined'
) {
// this array item (object) already exists
nextNumberIndex = _findNumberIndex(parts, nextNumberIndex + 1);
proceed = true;
} else {
// clone previous array item (object) and empty all property values
setting[propName][parts[nextNumberIndex]] = _getEmptyClone(
setting[propName][parts[nextNumberIndex] - 1]
);
proceed = true;
}
}
}
}
/**
* Converts stringified booleans and `null` to original types
*
* @param { string } str - A thing to be converted.
* @return {string|boolean|null} an un-stringified value or input value itself
*/
function _convertType(str) {
switch (str) {
case 'true':
return true;
case 'false':
return false;
case 'null':
return null;
default:
return str;
}
}
/**
* Searches the configuration object to find a match for an environment variable,
* or the first part of such a variable.
*
* @param { object } obj - Configuration object
* @param { string } envName - Environment variable name or the first part of one
* @param { string } prefix - Prefix to use (for nested objects)
* @return {{0: object, 1: string}} 2-item array of object and property name
*/
function _findSetting(obj, envName, prefix = '') {
for (const prop in obj) {
if (Object.prototype.hasOwnProperty.call(obj, prop)) {
const propEnvStyle = prefix + prop.replace(/ /g, '_').toUpperCase();
if (propEnvStyle === envName) {
return [obj, prop];
}
if (typeof obj[prop] === 'object' && obj[prop] !== null) {
const found = _findSetting(
obj[prop],
envName,
`${propEnvStyle}_`
);
if (found) {
return found;
}
}
}
}
}
/**
* Convert a non-empty string number to a number.
*
* @param { string } str - A stringified number
* @return {string|number} an input value or unstrigified number
*/
function _convertNumbers(str) {
if (!str) {
return str;
}
const converted = Number(str);
return !isNaN(converted) ? converted : str;
}
/**
* Finds the index of the first array item that is a number.
*
* @param {Array<string|number>} arr - Array of strings and numbers
* @param { number } [start] - Start index
* @return {number|undefined} The found index
*/
function _findNumberIndex(arr, start = 0) {
let i;
arr.some((val, index) => {
if (typeof val === 'number' && index >= start) {
i = index;
return true;
}
});
return i;
}
/**
* Returns an empty clone of the provided simple object
*
* @param { object } obj - A simple object
* @return { object } Clone of input object with emptied properties
*/
function _getEmptyClone(obj) {
const clone = JSON.parse(JSON.stringify(obj));
_emptyObjectProperties(clone);
return clone;
}
/**
* Replaces all non-null and non-object property values with empty string.
*
* @param { object } obj - A simple object
*/
function _emptyObjectProperties(obj) {
for (const prop in obj) {
// if a simple array of string values
if (Array.isArray(obj[prop]) && typeof obj[prop][0] === 'string') {
obj[prop] = [];
} else if (typeof obj[prop] === 'object' && obj[prop] !== null) {
_emptyObjectProperties(obj[prop]);
} else if (obj[prop]) {
obj[prop] = ''; // let's hope this has no side-effects
}
}
}
/**
* Overrides any redis settings if a special enviroment URL variable is set.
*/
function _setRedisConfigFromEnv() {
const redisMainUrl = process.env.ENKETO_REDIS_MAIN_URL;
const redisCacheUrl = process.env.ENKETO_REDIS_CACHE_URL;
if (redisMainUrl) {
config.redis.main = _extractRedisConfigFromUrl(redisMainUrl);
}
if (redisCacheUrl) {
config.redis.cache = _extractRedisConfigFromUrl(redisCacheUrl);
}
}
/**
* Parses a redis URL and returns an object with `host`, `port` and `password` properties.
*
* @param { string } redisUrl - A compliant redis url
* @return {{host: string, port: string, password: string|null}} config object
*/
function _extractRedisConfigFromUrl(redisUrl) {
const parsedUrl = url.parse(redisUrl);
const password =
parsedUrl.auth && parsedUrl.auth.split(':')[1]
? parsedUrl.auth.split(':')[1]
: null;
return {
host: parsedUrl.hostname,
port: parsedUrl.port,
password,
};
}
/**
* Returns a list of supported themes,
* in case a list is provided only the ones that exists are returned.
*
* @static
* @param {Array<string>} themeList - A list of themes e.g `['formhub', 'grid']`
* @return {Array<string>} An list of supported theme names
*/
function getThemesSupported(themeList) {
const themes = [];
if (fs.existsSync(themePath)) {
fs.readdirSync(themePath).forEach((file) => {
const matches = file.match(/^theme-([A-z-]+)\.css$/);
if (matches && matches.length > 1) {
if (themeList !== undefined && themeList.length) {
if (themeList.indexOf(matches[1]) !== -1) {
themes.push(matches[1]);
}
} else {
themes.push(matches[1]);
}
}
});
}
return themes;
}
try {
// need to be in the correct directory to run git describe --tags
config.version = String(
execSync(`cd ${__dirname}; git describe --tags`, {
encoding: 'utf-8',
})
).trim();
} catch (e) {
// Probably not deployed with git, try special .tag.txt file
try {
config.version = `${String(execSync('head -1 .tag.txt')).trim()}-r`;
} catch (e) {
// no .tag.txt present, use package.json version
config.version = `${pkg.version}-p`;
}
}
// detect supported themes
config['themes supported'] = getThemesSupported(config['themes supported']);
// detect supported languages
config['languages supported'] = fs
.readdirSync(languagePath)
.filter(
(file) =>
file.indexOf('.') !== 0 &&
fs.statSync(path.join(languagePath, file)).isDirectory()
);
// if necessary, correct the base path to use for all routing
if (config['base path'] && config['base path'].indexOf('/') !== 0) {
config['base path'] = `/${config['base path']}`;
}
if (
config['base path'] &&
config['base path'].lastIndexOf('/') === config['base path'].length - 1
) {
config['base path'] = config['base path'].substring(
0,
config['base path'].length - 1
);
}
config['offline path'] = '/x';
config.root = pkgDir.sync(__dirname);
// ensure backwards compatibility of old external authentication configurations
const { authentication } = config['linked form and data server'];
if (
authentication['managed by enketo'] === false &&
authentication['external login url that sets cookie']
) {
authentication.type = 'cookie';
authentication.url = authentication['external login url that sets cookie'];
}
delete authentication['external login url that sets cookie'];
delete authentication['managed by enketo'];
if (config['id length'] < 4) {
config['id length'] = 4;
} else if (config['id length'] > 31) {
config['id length'] = 31;
}
module.exports = {
/**
* @type { object }
*/
server: config,
/**
* @type { object }
*/
client: {
googleApiKey: config.google['api key'],
maps: config.maps,
modernBrowsersURL: 'modern-browsers',
supportEmail: config.support.email,
themesSupported: config['themes supported'],
defaultTheme: config['default theme'],
languagesSupported: config['languages supported'],
timeout: config.timeout,
submissionParameter: {
name: config['query parameter to pass to submission'],
},
basePath: config['base path'],
repeatOrdinals: config['repeat ordinals'],
validateContinuously: config['validate continuously'],
validatePage: config['validate page'],
swipePage: config['swipe page'],
textMaxChars: config['text field character limit'],
csrfCookieName: config['csrf cookie name'],
excludeNonRelevant: config['exclude non-relevant'],
experimentalOptimizations: config['experimental optimizations'],
},
getThemesSupported,
};