'use strict'
const crypto = require('crypto')
const os = require('os')
const url = require('url')
const tweetnacl = require('tweetnacl')
const omitBy = require('lodash/fp/omitBy')
const isUndefined = require('lodash/isUndefined')
const isEmpty = require('lodash/isEmpty')
const get = require('lodash/get')
const merge = require('lodash/merge')
const trimCharsEnd = require('lodash/fp/trimCharsEnd')
const some = require('lodash/fp/some')
const map = require('lodash/fp/map')
const forEach = require('lodash/fp/forEach')
const mapValues = require('lodash/fp/mapValues')
const startsWith = require('lodash/fp/startsWith')
const fs = require('fs')
const ServerError = require('../errors/server-error')
const secretNames = {} // { prefix ⇒ { name ⇒ true } }
function isRunningTests () {
return process.argv[0].endsWith('mocha') ||
(process.argv.length > 1 && process.argv[0].endsWith('node') &&
process.argv[1].endsWith('mocha'))
}
function useTestConfig () {
return !castBool(process.env.UNIT_TEST_OVERRIDE) && isRunningTests()
}
const removeUndefined = omitBy(isUndefined)
const removeEmpty = omitBy(isEmpty)
function ensureLeadingSlash (path) {
return path[0] !== '/' ? '/' + path : path
}
const removeTrailingSlashes = trimCharsEnd('/')
/**
* Parse a boolean config variable.
*
* Environment variables are passed in as strings, but this function can turn
* values like `undefined`, `''`, `'0'` and `'false'` into `false`.
*
* If a default value is provided, `undefined` and `''` will return the
* default value.
*
* Values other than `undefined`, `''`, `'1'`, `'0'`, `'true'`, and `'false'` will throw.
*
* @param {String} value Config value
* @param {Boolean} defaultValue Value to be returned for undefined or empty inputs
* @param {Boolean} Same config value intelligently cast to bool
*/
function castBool (value, defaultValue) {
value = value && value.trim()
if (value === undefined || value === '') return Boolean(defaultValue)
if (value === 'true' || value === '1') return true
if (value === 'false' || value === '0') return false
throw new ServerError('castBool unexpected value: ' + value)
}
/**
* Get a config value from the environment.
*
* Applies the config prefix defined in the constructor.
*
*
* @param {String} prefix prefix
* @param {String} name Config key (will be prefixed)
* @return {String} Config value or undefined
*
* getEnv('example', 'my_setting') === process.env.EXAMPLE_MY_SETTING
*/
function getEnv (prefix, name) {
let envVar
if (name && prefix) envVar = `${prefix}_${name}`
else if (name && !prefix) envVar = name
else if (!name && prefix) envVar = prefix
else throw new ServerError('Invalid environment variable')
return process.env[envVar.toUpperCase().replace(/-/g, '_')]
}
/**
* Generate a secret based on {prefix}_SECRET.
*
* @param {String} prefix
* @param {String} name
* @returns {Buffer}
*/
function generateSecret (prefix, name) {
const names = secretNames[prefix] = secretNames[prefix] || {}
if (names[name]) throw new Error('Secret "' + name + '" already exists')
names[name] = true
const secret = parseSecret(prefix)
return crypto.createHmac('sha256', secret)
.update(name)
.digest()
}
function parseSecret (prefix) {
const secret = getEnv(prefix, 'SECRET')
if (secret) return secret
if (process.env.NODE_ENV === 'production') {
throw new ServerError('No ' + (prefix ? prefix.toUpperCase() + '_' : '') + 'SECRET provided.')
}
return crypto.randomBytes(32).toString('base64')
}
function parsePublicURI (prefix, port, secure) {
const names = ['PUBLIC_HTTPS', 'PUBLIC_PORT', 'PUBLIC_PATH']
if (some((name) => getEnv(prefix, name), names)) {
const _prefix = prefix ? prefix.toUpperCase() + '_' : ''
const addPrefix = (name) => _prefix + name
const vars = map(addPrefix, names).join(', ')
console.log('DEPRECATION WARNING: Use ' + _prefix +
'PUBLIC_URI instead of ' + vars)
}
const uri = getEnv(prefix, 'PUBLIC_URI')
if (uri) {
const parsed = url.parse(uri)
return {
secure: parsed.protocol === 'https:',
host: parsed.hostname,
port: parseInt(parsed.port, 10) ||
(parsed.protocol === 'https:' ? 443 : 80),
path: ensureLeadingSlash(removeTrailingSlashes(parsed.path))
}
} else {
return {
secure: castBool(getEnv(prefix, 'PUBLIC_HTTPS'), secure),
host: getEnv(prefix, 'HOSTNAME') || os.hostname(),
port: parseInt(getEnv(prefix, 'PUBLIC_PORT'), 10) || port,
path: ensureLeadingSlash(removeTrailingSlashes(
getEnv(prefix, 'PUBLIC_PATH') || ''
))
}
}
}
/**
* Parse the server configuration settings from the environment.
*/
function parseServerConfig (prefix) {
const secure = castBool(getEnv(prefix, 'USE_HTTPS'))
const bindIp = getEnv(prefix, 'BIND_IP') || '0.0.0.0'
let port = parseInt(getEnv(prefix, 'PORT'), 10) || 3000
const publicConfig = parsePublicURI(prefix, port, secure)
if (useTestConfig()) {
publicConfig.host = 'localhost'
port = 61337
publicConfig.port = 80
}
// Depends on previously defined config values
const isCustomPort = publicConfig.secure
? +publicConfig.port !== 443
: +publicConfig.port !== 80
const baseHost = publicConfig.host +
(isCustomPort ? ':' + publicConfig.port : '')
const baseUri = removeTrailingSlashes(url.format({
protocol: 'http' + (publicConfig.secure ? 's' : ''),
host: baseHost,
pathname: publicConfig.path
}))
return {
secure,
bind_ip: bindIp,
port,
public_secure: publicConfig.secure,
public_host: publicConfig.host,
public_port: publicConfig.port,
public_path: publicConfig.path,
base_host: baseHost,
base_uri: baseUri
}
}
function parseTLSEnv (prefix) {
const key = getEnv(prefix, 'TLS_KEY')
const cert = getEnv(prefix, 'TLS_CERTIFICATE')
const crl = getEnv(prefix, 'TLS_CRL')
const ca = getEnv(prefix, 'TLS_CA')
return removeUndefined({key, cert, crl, ca})
}
function parseTLSConfig (prefix) {
const tlsEnvConfig = parseTLSEnv(prefix)
const useTLS = !isEmpty(tlsEnvConfig)
if (useTLS) {
return mapValues((file) => fs.readFileSync(file), tlsEnvConfig)
}
return {}
}
/*
* Parse the database configuration settings from the environment.
*/
function parseDatabaseConfig (prefix) {
// Database URI
let uri = getEnv(prefix, 'DB_URI')
if (useTestConfig()) {
// We use a different config parameter for unit tests, because typically one
// wouldn't want to use their production or even dev databases for unit tests
// and it'd be far to easy to do that accidentally by running npm test.
uri = getEnv(prefix, 'UNIT_DB_URI') || 'sqlite://'
}
// Synchronize schema when the application starts
// When using SQLite in-memory database, default to sync enabled
const sync = castBool(getEnv(prefix, 'DB_SYNC'), startsWith('sqlite://', uri))
// User for connecting to DB
const connectionUser = getEnv(prefix, 'DB_CONNECTION_USER')
const connectionPassword = getEnv(prefix, 'DB_CONNECTION_PASSWORD')
return removeUndefined({
uri,
sync,
connection_user: connectionUser,
connection_password: connectionPassword
})
}
function parseED25519 (prefix) {
const seed = generateSecret(prefix, 'ed25519')
const keyPair = tweetnacl.sign.keyPair.fromSeed(seed)
return {
secret: seed.toString('base64'),
public: new Buffer(keyPair.publicKey).toString('base64')
}
}
/**
* Parse keypair configuration from the environment
*/
function parseKeyConfig (prefix, options) {
return removeUndefined({
ed25519: options.ed25519 !== false ? parseED25519(prefix) : undefined
})
}
function parseAuthConfig (prefix) {
return {
basic_enabled: castBool(getEnv(prefix, 'AUTH_BASIC_ENABLED'), true),
http_signature_enabled: castBool(getEnv(prefix, 'AUTH_HTTP_SIGNATURE_ENABLED'), true),
client_certificates_enabled: castBool(getEnv(prefix, 'AUTH_CLIENT_CERT_ENABLED'), false)
}
}
function validateEnvConfig (prefix) {
const authConfig = parseAuthConfig(prefix)
const secureOrClientCertEnabled = castBool(getEnv(prefix, 'USE_HTTPS')) ||
authConfig.client_certificates_enabled
const tls = parseTLSEnv(prefix)
if (secureOrClientCertEnabled && (tls.key === undefined || tls.cert === undefined)) {
const _prefix = prefix ? prefix + '_' : ''
throw new ServerError(
`Missing ${_prefix}TLS_KEY or ${_prefix}TLS_CERTIFICATE`)
}
try {
forEach((file) => fs.accessSync(file, fs.R_OK), tls)
} catch (e) {
throw new ServerError(`Failed to read TLS config: ${e.message}`)
}
}
function deepFreeze (o) {
Object.freeze(o)
Object.getOwnPropertyNames(o).forEach(function (prop) {
if (o[prop] &&
(o[prop].constructor === Object ||
o[prop].constructor === Array ||
typeof o[prop] === 'function') &&
!Object.isFrozen(o[prop])) {
deepFreeze(o[prop])
}
})
return o
}
const configProto = {}
/**
* @this {Object} The config object
* @param {String} propertyPath - The config property path. Follows lodash.get
* syntax https://lodash.com/docs#get
* @param {any} [defaultValue] - A default value to return if config value is undefined
* @returns {any} - The config value at the specified path
*
*/
configProto.get = function (propertyPath, defaultValue) {
return get(this, propertyPath, defaultValue)
}
configProto.getIn = function (propertyList, defaultValue) {
return get(this, propertyList, defaultValue)
}
/**
* @param {String} prefix Prefix to apply to all env variable names. Should be
* in lowercase with dashes as separators. Will automatically be converted
* to uppercase with underscores or other formats as necessary.
*
* @param {Object} [localConfig]
* @param {Object} [options]
* @param {Boolean} [options.ed25519] - 'false' if config should not parse ed25519 keypair
* @returns {Object} - Frozen Config
*
* @example
* const config = loadConfig('prefix', localConfig)
* config.toJS()
* => { foo: {bar: 'baz'} }
*
* config.getIn(['foo', 'bar'])
* => 'baz'
*
* config.get('foo').toJS()
* => {bar: 'baz'}
*
*/
function loadConfig (prefix, localConfig, options) {
secretNames[prefix] = {}
const _options = options || {}
validateEnvConfig(prefix)
const server = parseServerConfig(prefix)
const db = parseDatabaseConfig(prefix)
const keys = parseKeyConfig(prefix, _options)
const auth = parseAuthConfig(prefix)
const tls = parseTLSConfig(prefix)
const commonConfig = removeEmpty({server, db, keys, auth, tls})
const completeConfig = Object.assign(Object.create(configProto), merge(commonConfig, localConfig || {}))
if (!useTestConfig()) {
deepFreeze(completeConfig)
}
return completeConfig
}
module.exports = {
getEnv,
generateSecret,
loadConfig,
castBool
}