UNPKG

@flowfuse/flowfuse

Version:

An open source low-code development platform

370 lines (347 loc) • 14.5 kB
const crypto = require('crypto') const bcrypt = require('bcrypt') const Hashids = require('hashids/cjs') const { Op, fn, col, where } = require('sequelize') const hashids = {} const URLEncode = str => str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') const base64URLEncode = str => URLEncode(str.toString('base64')) const md5 = str => crypto.createHash('md5').update(str).digest('hex') const sha256 = value => crypto.createHash('sha256').update(value).digest().toString('base64') let app /** @type {typeof import('random-words')} */ let randomWords (async () => { randomWords = (await import('random-words')) })() /** * Generate a properly formed where-object for sequelize findAll, that applies * the required pagination, search and filter logic * * @param {Object} params the pagination options - cursor, query, limit * @param {Object} whereClause any pre-existing where-query clauses to include * @param {Array<String>} columns an array of column names to search. * @param {Object} filterMap pairs of filters to apply * @returns a `where` object that can be passed to sequelize query */ const buildPaginationSearchClause = (params, whereClause = {}, columns = [], filterMap = {}) => { whereClause = { ...whereClause } if (params.cursor) { whereClause.id = { [Op.gt]: params.cursor } } whereClause = { [Op.and]: [ whereClause ] } for (const [key, value] of Object.entries(filterMap)) { if (Object.hasOwn(params, key)) { // A filter has been provided for key let clauseContainer = whereClause[Op.and] let param = params[key] if (Array.isArray(param)) { if (param.length > 1) { clauseContainer = [] whereClause[Op.and].push({ [Op.or]: clauseContainer }) } } else { param = [param] } param.forEach(p => { clauseContainer.push(where(fn('lower', col(value)), p.toLowerCase())) }) } } if (params.query && columns.length) { const searchTerm = `%${params.query.toLowerCase()}%` const searchClauses = columns.map(colName => { return where(fn('lower', col(colName)), { [Op.like]: searchTerm }) }) const query = { [Op.or]: searchClauses } whereClause[Op.and].push(query) } return whereClause } /** * Get canonical email from an email address. * In this implementation, the canonical form of an email is the * address with the following processing applied: * * lower case it * * trim it * * return null if email is null or empty * * remove dots for everything before the @ sign (gmail/googlemail only) * * return sanitised local + @ + domain * KNOWN LIMITATIONS: * * This function does not attempt understand the aliasing rules of each provider * * This function does not attempt to remove tags from the local-part * * This function does not attempt to remove sub-domain tags * @param {String} email Email address to get canonical email from * @param {Object} options Options * @param {Array<String>} options.removeDotsForDomains List of domains for which dots should be removed from the local-part * @returns {String} Canonical email */ function getCanonicalEmail (email, options = { removeDotsForDomains: ['gmail.', 'googlemail.'] }) { // Aliasing is supported by most of the big email providers and implemented in various ways // For example: // Gmail allows dots to be added anywhere in the local-part (it ignores dots in the local-part) // Yahoo supports a "base name" and a "keyword" (basename-keyword@yahoo.com) - upto 500 aliases // Yandex.Mail permits "-". for example, if username includes a period (e.g. alice.the.girl), you automatically receive an alias like alice-the-girl in addition to domain aliases. // This function will not attempt understand the aliasing rules of each provider // Sub-addressing (also known as the + trick) is now supported by the most of the big email providers // https://en.wikipedia.org/wiki/Comparison_of_webmail_providers#Features // <local-part>?<tag>@<domain> // some providers use the + sign, some use the - sign // This function will not attempt to remove tags from the local-part // Sub-domain addressing is less popular (skiff, FastMail, and ProtonMail) but is still a thing. // This function will not attempt to remove tags from the sub-domain if (!email || typeof email !== 'string' || email.trim().length === 0) { return null } email = (email + '').trim().toLocaleLowerCase() email = (email + '').toLowerCase().trim() const [local, domain] = email.split('@') if (domain && options.removeDotsForDomains.length >= 0) { for (const domainToCheck of options.removeDotsForDomains) { if (domain.startsWith(domainToCheck)) { return `${local.replace(/\./g, '')}@${domain}` // gmail ignores dots in the local part so we remove them to make the email canonical } } } return `${local}@${domain}` } /** * Generate a random 1 or more random strings * @param {Number} [count=1] - Number of strings to generate * @param {Number} [minLength=2] - Minimum length of each string * @param {Number} [maxLength=15] - Maximum length of each string * @param {Number} [wordsPerString=1] - Number of words in each string * @returns {Array<String>} - Array of random strings */ function randomStrings (count = 1, minLength = 2, maxLength = 15, wordsPerString = 1) { const words = randomWords.generate({ exactly: count || 1, minLength: minLength || 2, maxLength: maxLength || 15, wordsPerString: wordsPerString || 1 }) words.sort(() => Math.random() - 0.5) // a bit of extra randomness additional to the default behaviour of `random-words` lib method return words } /** * Generate a random phrase * @param {Number} [wordCount=3] - Number of words in the phrase * @param {Number} [minLength=2] - Minimum length of each word * @param {Number} [maxLength=15] - Maximum length of each word * @param {String} [separator='-'] - Separator between words * @returns {String} - Random phrase */ function randomPhrase (wordCount = 3, minLength = 2, maxLength = 15, separator = '-') { return randomStrings(wordCount, minLength, maxLength).join(separator) } /** * @typedef {Object<string, string | { value: string, hidden?: boolean }>} EnvVarObject * An object mapping environment variable names to a string value or a metadata object. * e.g. * `{ VAR1: 'value1', VAR2: { value: 'value2', hidden: true } }` * * @typedef {Array<{ name: string, value: string, hidden?: boolean }>} EnvVarArray * An array of environment variable objects, where each object has a name and value, * and optionally a hidden flag. * e.g. * `[ { name: 'VAR1', value: 'value1' }, { name: 'VAR2', value: 'value2', hidden: true } ]` */ /** * Convert an array of env var objects to a key/value object, with handling of hidden vars that * need to retain their metadata * From: [ { name: 'VAR1', value: 'value1' }, { name: 'VAR2', value: 'value2', hidden: true } ] * To: { VAR1: 'value1', VAR2: { value: 'value2', hidden: true } } * @param {EnvVarArray} envArray * @return {EnvVarObject} * @see {@link mapEnvObjectToArray} for the reverse operation */ function mapEnvArrayToObject (envArray) { /** @type {EnvVarObject} */ const envObject = {} envArray.forEach((envVar) => { const name = envVar.name const value = envVar.value if (envVar.hidden) { envObject[name] = { hidden: true } if (Object.hasOwn(envVar, 'value')) { envObject[name].value = value } if (Object.hasOwn(envVar, '$')) { envObject[name].$ = envVar.$ } } else { envObject[name] = value } }) return envObject } /** * Convert a key/value object of env vars to an array of env var objects * From: { VAR1: 'value1', VAR2: { value: 'value2', hidden: true } } * To: [ { name: 'VAR1', value: 'value1' }, { name: 'VAR2', value: 'value2', hidden: true } ] * @param {EnvVarObject} envObject * @return {EnvVarArray} * @see {@link mapEnvArrayToObject} for the reverse operation */ function mapEnvObjectToArray (envObject) { /** @type {EnvVarArray} */ const envArray = [] for (const [name, value] of Object.entries(envObject)) { if (typeof value === 'object' && value.hidden) { const env = { name, hidden: true } if (Object.hasOwn(value, 'value')) { env.value = value.value } if (Object.hasOwn(value, '$')) { env.$ = value.$ } envArray.push(env) } else { envArray.push({ name, value }) } } return envArray } function decryptValue (secret, encryptedValue) { const key = crypto.createHash('sha256').update(secret).digest() const initVector = Buffer.from(encryptedValue.substring(0, 32), 'hex') encryptedValue = encryptedValue.substring(32) const decipher = crypto.createDecipheriv('aes-256-ctr', key, initVector) const decrypted = decipher.update(encryptedValue, 'base64', 'utf8') + decipher.final('utf8') return decrypted } function encryptValue (secret, plainValue) { const key = crypto.createHash('sha256').update(secret).digest() const initVector = crypto.randomBytes(16) const cipher = crypto.createCipheriv('aes-256-ctr', key, initVector) return initVector.toString('hex') + cipher.update(plainValue, 'utf8', 'base64') + cipher.final('base64') } /** * Takes an env var key/value object and modifies it to contain only the values of the env vars * This handles any env vars flagged as hidden. This strips the hidden metadata - the result * of this function is suitable for passing to a hosted/remote instance to use as-is * From: { VAR1: 'value1', VAR2: { value: 'value2', hidden: true } } * To: { VAR1: 'value1', VAR2: 'value2' } * @param {EnvVarObject} envObject * @return {Object<string, string>} */ function exportEnvVarObject (envObject) { // Check for any hidden env vars. These are objects with a 'hidden' property set to true. // If so, we replace the object with just the value. const result = {} for (const envVar of Object.keys(envObject)) { if (Object.hasOwn(envObject[envVar], 'hidden')) { // The value has metadata - use the value only result[envVar] = envObject[envVar].value } else { // The value is the bare value - use as-is result[envVar] = envObject[envVar] } } return result } /** * Parses an nr-mqtt Node userId or clientId auth string into its components. * Since the client auth can only pass flags via username/clientId, we have to pass the flags in a specific format. * The expected format is: `mq:hosted:teamId:instanceId[:haId]` or `mq:remote:teamId:deviceId` * This function parses and checks the format of a given username or clientId. * @param {String} id - the ID to parse, expected format: `mq:hosted:teamId:instanceId[:haId]` or `mq:remote:teamId:deviceId` * @param {'username'|'clientId'} [kind='username'] - the kind of ID to parse `'username'` or `'clientId'` * @returns Parsed ID object */ function parseNrMqttId (id, kind = 'username') { const minLength = 4 const maxLength = 5 let expectedLength = minLength const result = { kind, teamId: null, ownerId: null, ownerType: null, haId: null, username: null, error: null, valid: false } const parts = id.split(':') if (parts.length < minLength || parts.length > maxLength) { result.error = 'Invalid format' } const [mq, instanceType, teamId, instanceId, part5] = parts if (mq !== 'mq') { result.error = 'Unknown format' } result.teamId = teamId result.ownerId = instanceId if (instanceType === 'hosted') { result.ownerType = 'project' result.username = `instance:${instanceId}` if (kind === 'clientId' && parts.length === 5) { expectedLength = 5 // since a clientId can have a haId, we need to compute the username accordingly result.haId = part5 || null } } else if (instanceType === 'remote') { result.ownerType = 'device' result.username = `device:${instanceId}` } else { result.error = 'Invalid Type' } if (parts.length !== expectedLength) { result.error = 'Invalid format' } result.valid = result.error === null return result } module.exports = { init: _app => { app = _app }, generateToken: (length, prefix) => (prefix ? prefix + '_' : '') + base64URLEncode(crypto.randomBytes(length || 32)), generateNumericToken: () => crypto.randomInt(0, 1000000).toString().padStart(6, '0'), hash: value => bcrypt.hashSync(value, 10), compareHash: (plain, hashed) => bcrypt.compareSync(plain, hashed), md5, sha256, URLEncode, base64URLEncode, generateUserAvatar: key => { const keyHash = Buffer.from(key).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') return `/avatar/${keyHash}` }, generateTeamAvatar: key => { const keyHash = md5(key.trim().toLowerCase()) return `//www.gravatar.com/avatar/${keyHash}?d=identicon` // retro mp }, slugify: str => str.trim().toLowerCase().replace(/ /g, '-').replace(/[^a-z0-9-_]/ig, ''), uppercaseFirst: str => `${str[0].toUpperCase()}${str.substr(1)}`, getHashId: type => { if (!hashids[type]) { // This defers trying to access app.settings until after the // database has been initialised hashids[type] = new Hashids((app.settings.get('instanceId') || '') + type, 10) } return hashids[type] }, buildPaginationSearchClause, getCanonicalEmail, randomStrings, randomPhrase, mapEnvArrayToObject, mapEnvObjectToArray, exportEnvVarObject, encryptValue, decryptValue, parseNrMqttId }