/* eslint no-console: 0 */ 'use strict'; const urllib = require('url'); const util = require('util'); const fs = require('fs'); const nmfetch = require('../fetch'); const dns = require('dns'); const net = require('net'); const os = require('os'); const DNS_TTL = 5 * 60 * 1000; let networkInterfaces; try { networkInterfaces = os.networkInterfaces(); } catch (err) { // fails on some systems } module.exports.networkInterfaces = networkInterfaces; const isFamilySupported = (family, allowInternal) => { let networkInterfaces = module.exports.networkInterfaces; if (!networkInterfaces) { // hope for the best return true; } const familySupported = // crux that replaces Object.values(networkInterfaces) as Object.values is not supported in nodejs v6 Object.keys(networkInterfaces) .map(key => networkInterfaces[key]) // crux that replaces .flat() as it is not supported in older Node versions (v10 and older) .reduce((acc, val) => acc.concat(val), []) .filter(i => !i.internal || allowInternal) .filter(i => i.family === 'IPv' + family || i.family === family).length > 0; return familySupported; }; const resolver = (family, hostname, options, callback) => { options = options || {}; const familySupported = isFamilySupported(family, options.allowInternalNetworkInterfaces); if (!familySupported) { return callback(null, []); } const resolver = dns.Resolver ? new dns.Resolver(options) : dns; resolver['resolve' + family](hostname, (err, addresses) => { if (err) { switch (err.code) { case dns.NODATA: case dns.NOTFOUND: case dns.NOTIMP: case dns.SERVFAIL: case dns.CONNREFUSED: case dns.REFUSED: case 'EAI_AGAIN': return callback(null, []); } return callback(err); } return callback(null, Array.isArray(addresses) ? addresses : [].concat(addresses || [])); }); }; const dnsCache = (module.exports.dnsCache = new Map()); const formatDNSValue = (value, extra) => { if (!value) { return Object.assign({}, extra || {}); } return Object.assign( { servername: value.servername, host: !value.addresses || !value.addresses.length ? null : value.addresses.length === 1 ? value.addresses[0] : value.addresses[Math.floor(Math.random() * value.addresses.length)] }, extra || {} ); }; module.exports.resolveHostname = (options, callback) => { options = options || {}; if (!options.host && options.servername) { options.host = options.servername; } if (!options.host || net.isIP(options.host)) { // nothing to do here let value = { addresses: [options.host], servername: options.servername || false }; return callback( null, formatDNSValue(value, { cached: false }) ); } let cached; if (dnsCache.has(options.host)) { cached = dnsCache.get(options.host); if (!cached.expires || cached.expires >= Date.now()) { return callback( null, formatDNSValue(cached.value, { cached: true }) ); } } resolver(4, options.host, options, (err, addresses) => { if (err) { if (cached) { // ignore error, use expired value return callback( null, formatDNSValue(cached.value, { cached: true, error: err }) ); } return callback(err); } if (addresses && addresses.length) { let value = { addresses, servername: options.servername || options.host }; dnsCache.set(options.host, { value, expires: Date.now() + (options.dnsTtl || DNS_TTL) }); return callback( null, formatDNSValue(value, { cached: false }) ); } resolver(6, options.host, options, (err, addresses) => { if (err) { if (cached) { // ignore error, use expired value return callback( null, formatDNSValue(cached.value, { cached: true, error: err }) ); } return callback(err); } if (addresses && addresses.length) { let value = { addresses, servername: options.servername || options.host }; dnsCache.set(options.host, { value, expires: Date.now() + (options.dnsTtl || DNS_TTL) }); return callback( null, formatDNSValue(value, { cached: false }) ); } try { dns.lookup(options.host, { all: true }, (err, addresses) => { if (err) { if (cached) { // ignore error, use expired value return callback( null, formatDNSValue(cached.value, { cached: true, error: err }) ); } return callback(err); } let address = addresses ? addresses .filter(addr => isFamilySupported(addr.family)) .map(addr => addr.address) .shift() : false; if (addresses && addresses.length && !address) { // there are addresses but none can be used let err = new Error(`Can not use IPv${addresses[0].family} addresses with current network`); return callback(err); } if (!address && cached) { // nothing was found, fallback to cached value return callback( null, formatDNSValue(cached.value, { cached: true }) ); } let value = { addresses: address ? [address] : [options.host], servername: options.servername || options.host }; dnsCache.set(options.host, { value, expires: Date.now() + (options.dnsTtl || DNS_TTL) }); return callback( null, formatDNSValue(value, { cached: false }) ); }); } catch (err) { if (cached) { // ignore error, use expired value return callback( null, formatDNSValue(cached.value, { cached: true, error: err }) ); } return callback(err); } }); }); }; /** * Parses connection url to a structured configuration object * * @param {String} str Connection url * @return {Object} Configuration object */ module.exports.parseConnectionUrl = str => { str = str || ''; let options = {}; [urllib.parse(str, true)].forEach(url => { let auth; switch (url.protocol) { case 'smtp:': options.secure = false; break; case 'smtps:': options.secure = true; break; case 'direct:': options.direct = true; break; } if (!isNaN(url.port) && Number(url.port)) { options.port = Number(url.port); } if (url.hostname) { options.host = url.hostname; } if (url.auth) { auth = url.auth.split(':'); if (!options.auth) { options.auth = {}; } options.auth.user = auth.shift(); options.auth.pass = auth.join(':'); } Object.keys(url.query || {}).forEach(key => { let obj = options; let lKey = key; let value = url.query[key]; if (!isNaN(value)) { value = Number(value); } switch (value) { case 'true': value = true; break; case 'false': value = false; break; } // tls is nested object if (key.indexOf('tls.') === 0) { lKey = key.substr(4); if (!options.tls) { options.tls = {}; } obj = options.tls; } else if (key.indexOf('.') >= 0) { // ignore nested properties besides tls return; } if (!(lKey in obj)) { obj[lKey] = value; } }); }); return options; }; module.exports._logFunc = (logger, level, defaults, data, message, ...args) => { let entry = {}; Object.keys(defaults || {}).forEach(key => { if (key !== 'level') { entry[key] = defaults[key]; } }); Object.keys(data || {}).forEach(key => { if (key !== 'level') { entry[key] = data[key]; } }); logger[level](entry, message, ...args); }; /** * Returns a bunyan-compatible logger interface. Uses either provided logger or * creates a default console logger * * @param {Object} [options] Options object that might include 'logger' value * @return {Object} bunyan compatible logger */ module.exports.getLogger = (options, defaults) => { options = options || {}; let response = {}; let levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal']; if (!options.logger) { // use vanity logger levels.forEach(level => { response[level] = () => false; }); return response; } let logger = options.logger; if (options.logger === true) { // create console logger logger = createDefaultLogger(levels); } levels.forEach(level => { response[level] = (data, message, ...args) => { module.exports._logFunc(logger, level, defaults, data, message, ...args); }; }); return response; }; /** * Wrapper for creating a callback that either resolves or rejects a promise * based on input * * @param {Function} resolve Function to run if callback is called * @param {Function} reject Function to run if callback ends with an error */ module.exports.callbackPromise = (resolve, reject) => function () { let args = Array.from(arguments); let err = args.shift(); if (err) { reject(err); } else { resolve(...args); } }; /** * Resolves a String or a Buffer value for content value. Useful if the value * is a Stream or a file or an URL. If the value is a Stream, overwrites * the stream object with the resolved value (you can't stream a value twice). * * This is useful when you want to create a plugin that needs a content value, * for example the `html` or `text` value as a String or a Buffer but not as * a file path or an URL. * * @param {Object} data An object or an Array you want to resolve an element for * @param {String|Number} key Property name or an Array index * @param {Function} callback Callback function with (err, value) */ module.exports.resolveContent = (data, key, callback) => { let promise; if (!callback) { promise = new Promise((resolve, reject) => { callback = module.exports.callbackPromise(resolve, reject); }); } let content = (data && data[key] && data[key].content) || data[key]; let contentStream; let encoding = ((typeof data[key] === 'object' && data[key].encoding) || 'utf8') .toString() .toLowerCase() .replace(/[-_\s]/g, ''); if (!content) { return callback(null, content); } if (typeof content === 'object') { if (typeof content.pipe === 'function') { return resolveStream(content, (err, value) => { if (err) { return callback(err); } // we can't stream twice the same content, so we need // to replace the stream object with the streaming result if (data[key].content) { data[key].content = value; } else { data[key] = value; } callback(null, value); }); } else if (/^https?:\/\//i.test(content.path || content.href)) { contentStream = nmfetch(content.path || content.href); return resolveStream(contentStream, callback); } else if (/^data:/i.test(content.path || content.href)) { let parts = (content.path || content.href).match(/^data:((?:[^;]*;)*(?:[^,]*)),(.*)$/i); if (!parts) { return callback(null, Buffer.from(0)); } return callback(null, /\bbase64$/i.test(parts[1]) ? Buffer.from(parts[2], 'base64') : Buffer.from(decodeURIComponent(parts[2]))); } else if (content.path) { return resolveStream(fs.createReadStream(content.path), callback); } } if (typeof data[key].content === 'string' && !['utf8', 'usascii', 'ascii'].includes(encoding)) { content = Buffer.from(data[key].content, encoding); } // default action, return as is setImmediate(() => callback(null, content)); return promise; }; /** * Copies properties from source objects to target objects */ module.exports.assign = function (/* target, ... sources */) { let args = Array.from(arguments); let target = args.shift() || {}; args.forEach(source => { Object.keys(source || {}).forEach(key => { if (['tls', 'auth'].includes(key) && source[key] && typeof source[key] === 'object') { // tls and auth are special keys that need to be enumerated separately // other objects are passed as is if (!target[key]) { // ensure that target has this key target[key] = {}; } Object.keys(source[key]).forEach(subKey => { target[key][subKey] = source[key][subKey]; }); } else { target[key] = source[key]; } }); }); return target; }; module.exports.encodeXText = str => { // ! 0x21 // + 0x2B // = 0x3D // ~ 0x7E if (!/[^\x21-\x2A\x2C-\x3C\x3E-\x7E]/.test(str)) { return str; } let buf = Buffer.from(str); let result = ''; for (let i = 0, len = buf.length; i < len; i++) { let c = buf[i]; if (c < 0x21 || c > 0x7e || c === 0x2b || c === 0x3d) { result += '+' + (c < 0x10 ? '0' : '') + c.toString(16).toUpperCase(); } else { result += String.fromCharCode(c); } } return result; }; /** * Streams a stream value into a Buffer * * @param {Object} stream Readable stream * @param {Function} callback Callback function with (err, value) */ function resolveStream(stream, callback) { let responded = false; let chunks = []; let chunklen = 0; stream.on('error', err => { if (responded) { return; } responded = true; callback(err); }); stream.on('readable', () => { let chunk; while ((chunk = stream.read()) !== null) { chunks.push(chunk); chunklen += chunk.length; } }); stream.on('end', () => { if (responded) { return; } responded = true; let value; try { value = Buffer.concat(chunks, chunklen); } catch (E) { return callback(E); } callback(null, value); }); } /** * Generates a bunyan-like logger that prints to console * * @returns {Object} Bunyan logger instance */ function createDefaultLogger(levels) { let levelMaxLen = 0; let levelNames = new Map(); levels.forEach(level => { if (level.length > levelMaxLen) { levelMaxLen = level.length; } }); levels.forEach(level => { let levelName = level.toUpperCase(); if (levelName.length < levelMaxLen) { levelName += ' '.repeat(levelMaxLen - levelName.length); } levelNames.set(level, levelName); }); let print = (level, entry, message, ...args) => { let prefix = ''; if (entry) { if (entry.tnx === 'server') { prefix = 'S: '; } else if (entry.tnx === 'client') { prefix = 'C: '; } if (entry.sid) { prefix = '[' + entry.sid + '] ' + prefix; } if (entry.cid) { prefix = '[#' + entry.cid + '] ' + prefix; } } message = util.format(message, ...args); message.split(/\r?\n/).forEach(line => { console.log('[%s] %s %s', new Date().toISOString().substr(0, 19).replace(/T/, ' '), levelNames.get(level), prefix + line); }); }; let logger = {}; levels.forEach(level => { logger[level] = print.bind(null, level); }); return logger; }