app/lib/utils.js

/**
 * @module utils
 */

const crypto = require('crypto');
const evpBytesToKey = require('evp_bytestokey');
const validUrl = require('valid-url');
// var debug = require( 'debug' )( 'utils' );

/**
 * Returns a unique, predictable openRosaKey from a survey oject
 *
 * @static
 * @param {module:survey-model~SurveyObject} survey - survey object
 * @param { string } [prefix] - prefix
 * @return {string|null} openRosaKey
 */
function getOpenRosaKey(survey, prefix) {
    if (!survey || !survey.openRosaServer || !survey.openRosaId) {
        return null;
    }
    prefix = prefix || 'or:';

    // Server URL is not case sensitive, form ID is case-sensitive
    return `${
        prefix + cleanUrl(survey.openRosaServer)
    },${survey.openRosaId.trim()}`;
}

/**
 * Returns a XForm manifest hash.
 *
 * @static
 * @param {Array} manifest - hash of XForm manifest
 * @param { string } type - Webform type
 * @return { string } Hash
 */
function getXformsManifestHash(manifest, type) {
    const hash = '';

    if (!manifest || manifest.length === 0) {
        return hash;
    }
    if (type === 'all') {
        return md5(JSON.stringify(manifest));
    }
    if (type) {
        const filtered = manifest.map((mediaFile) => mediaFile[type]);

        return md5(JSON.stringify(filtered));
    }

    return hash;
}

/**
 * Cleans a Server URL so it becomes useful as a db key
 * It strips the protocol, removes a trailing slash, removes www, and converts to lowercase
 *
 * @static
 * @param { string } url - Url to be cleaned up
 * @return { string } Cleaned up url
 */
function cleanUrl(url) {
    url = url.trim();
    if (url.lastIndexOf('/') === url.length - 1) {
        url = url.substring(0, url.length - 1);
    }
    const matches = url.match(/https?:\/\/(www\.)?(.+)/);
    if (matches && matches.length > 2) {
        return matches[2].toLowerCase();
    }

    return url;
}

/**
 * The name of this function is deceiving. It checks for a valid server URL and therefore doesn't approve of:
 * - fragment identifiers
 * - query strings
 *
 * @static
 * @param { string } url - Url to be validated
 * @return { boolean } Whether the url is valid
 */
function isValidUrl(url) {
    return !!validUrl.isWebUri(url) && !/\?/.test(url) && !/#/.test(url);
}

/**
 * Returns md5 hash of given message
 *
 * @static
 * @param { string } message - Message to be hashed
 * @return { string } Hash string
 */
function md5(message) {
    const hash = crypto.createHash('md5');
    hash.update(message);

    return hash.digest('hex');
}

/**
 * This is not secure encryption as it doesn't use a random cipher. Therefore the result is
 * always the same for each text & pw (which is desirable in this case).
 * This means the password is vulnerable to be cracked,
 * and we should use a dedicated low-importance password for this.
 *
 * @static
 * @param { string } text - The text to be encrypted
 * @param { string } pw - The password to use for encryption
 * @return { string } The encrypted result
 */
function insecureAes192Encrypt(text, pw) {
    let encrypted;
    const stuff = _getKeyIv(pw);
    const cipher = crypto.createCipheriv('aes192', stuff.key, stuff.iv);
    encrypted = cipher.update(text, 'utf8', 'hex');
    encrypted += cipher.final('hex');

    return encrypted;
}

/**
 * Decrypts encrypted text.
 *
 * @static
 * @param { object } encrypted - The text to be decrypted
 * @param { object } pw - The password to use for decryption
 * @return { string } The decrypted result
 */
function insecureAes192Decrypt(encrypted, pw) {
    let decrypted;
    const stuff = _getKeyIv(pw);
    const decipher = crypto.createDecipheriv('aes192', stuff.key, stuff.iv);
    decrypted = decipher.update(encrypted, 'hex', 'utf8');
    decrypted += decipher.final('utf8');

    return decrypted;
}

/**
 * Returns random howMany-lengthed string from provided characters.
 *
 * @static
 * @param { number } [howMany] - Desired length of string
 * @param { string } [chars] - Characters to use
 * @return { string } Random string
 */
function randomString(
    howMany = 8,
    chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
) {
    const rnd = crypto.randomBytes(howMany);

    return new Array(howMany)
        .fill() // create indices, so map can iterate
        .map((val, i) => chars[rnd[i] % chars.length])
        .join('');
}

/**
 * Not secure, but used for backward compatibility with deprecated crypto.createCipher
 * It's okay to use for this purpose.
 *
 * @param { string } pw - password
 */
function _getKeyIv(pw) {
    return evpBytesToKey(pw, null, 192, 16);
}

/**
 * Returns random item from array.
 *
 * @static
 * @param {Array} array - Target array
 * @return {*|null} Random array item
 */
function pickRandomItemFromArray(array) {
    if (!Array.isArray(array) || array.length === 0) {
        return null;
    }
    const random = Math.floor(Math.random() * array.length);
    if (!array[random]) {
        return null;
    }

    return array[random];
}

/**
 * Compares two objects by shallow properties.
 *
 * @static
 * @param { object } a - First object to be compared
 * @param { object } b - Second object to be compared
 * @return {null|boolean} Whether objects are equal (`null` for invalid arguments)
 */
function areOwnPropertiesEqual(a, b) {
    let prop;
    const results = [];

    if (typeof a !== 'object' || typeof b !== 'object') {
        return null;
    }

    for (prop in a) {
        if (Object.prototype.hasOwnProperty.call(a, prop)) {
            if (a[prop] !== b[prop]) {
                return false;
            }
            results[prop] = true;
        }
    }
    for (prop in b) {
        if (!results[prop] && Object.prototype.hasOwnProperty.call(b, prop)) {
            if (b[prop] !== a[prop]) {
                return false;
            }
        }
    }

    return true;
}

module.exports = {
    getOpenRosaKey,
    getXformsManifestHash,
    cleanUrl,
    isValidUrl,
    md5,
    randomString,
    pickRandomItemFromArray,
    areOwnPropertiesEqual,
    insecureAes192Decrypt,
    insecureAes192Encrypt,
};