UNPKG

@flowfuse/flowfuse

Version:

An open source low-code development platform

304 lines (292 loc) • 13.4 kB
const { BUILT_IN_MODULES } = require('../../lib/builtInModules') const { templateFields, defaultTemplateValues, defaultTemplatePolicy } = require('../../lib/templates') const { hash } = require('../utils') function getTemplateValue (template, path) { const parts = path.split('_') let p = template while (parts.length > 0) { const part = parts.shift() if (p[part] === undefined) { return } else { p = p[part] } } return p } function setTemplateValue (template, path, value) { const parts = path.split('_') let p = template while (parts.length > 1) { const part = parts.shift() if (p[part] === undefined) { p[part] = {} } p = p[part] } const lastPart = parts.shift() p[lastPart] = value } module.exports = { /** * For a given template, check the project settings are valid. This consists of: * 1. ensure the template policy allows the settings to be provided - drop * any that are blocked by policy * 2. do any setting-specific validation and cleansing of the value * @param {*} app the forge app * @param {*} settings the project settings to validate. * @param {*} template the template to validate against * @returns the validated and cleansed object */ validateSettings: function (app, settings, template, importing = false) { const result = {} // First pass - copy over only the known and policy-permitted settings templateFields.forEach((name) => { const value = getTemplateValue(settings, name) if (value !== undefined) { let policy = !template || getTemplateValue(template.policy, name) if (policy === undefined) { policy = defaultTemplatePolicy[name] } if (!template || policy) { setTemplateValue(result, name, value) } } }) if (settings.env) { result.env = [] const templateEnvPolicyMap = {} const templateEnv = template?.settings.env if (templateEnv) { templateEnv.forEach((envVar) => { templateEnvPolicyMap[envVar.name] = envVar.policy }) } settings.env.forEach((envVar) => { if (templateEnvPolicyMap[envVar.name] !== false && !/ /.test(envVar.name)) { if (!envVar.name.match(/^[a-zA-Z_]+[a-zA-Z0-9_]*$/)) { throw new Error(`Invalid Env Var name '${envVar.name}'`) } // removed because it breaks snapshot rollback test // if (envVar.name.match(/^FF_/)) { // throw new Error(`Illegal Env Var name ${envVar.name}`) // } result.env.push(envVar) } }) // find duplicates const seen = new Set() const dups = result.env.some(item => { return seen.size === seen.add(item.name).size }) if (dups) { throw new Error('Duplicate Env Var names provided') } } // Validate palette modules: // * No duplicates // * No invalid names // * No invalid versions if (settings.palette?.modules) { // ensure names and version are valid // NOTE: `validateModuleName` and `validateModuleVersion` have frontend counterparts // in `/frontend/src/pages/admin/Template/sections/PaletteModules.vue` and should be kept in sync const validateModuleName = (name) => !BUILT_IN_MODULES.includes(name) && /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(name) const validateModuleVersion = (version) => /^\*$|x|(?:[\^~]?(0|[1-9]\d*)\.(x$|0|[1-9]\d*)(?:\.(x$|0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)?)$/.test(version) const moduleMap = {} for (let i = 0; i < settings.palette.modules.length; i++) { const module = settings.palette.modules[i] // ensure there are no duplicates if (moduleMap[module.name]) { throw new Error(`Duplicate module: ${module.name}`) } moduleMap[module.name] = true // ensure names and version are valid if (!validateModuleName(module.name)) { throw new Error(`Invalid module name: ${module.name}`) } if (!validateModuleVersion(module.version)) { throw new Error(`Invalid module version: ${module.version}`) } } } // Validate individual settings if (result.httpAdminRoot !== undefined) { let httpAdminRoot = result.httpAdminRoot delete result.httpAdminRoot if (typeof httpAdminRoot === 'string') { httpAdminRoot = httpAdminRoot.trim() if (httpAdminRoot.length > 0) { if (httpAdminRoot[0] !== '/') { httpAdminRoot = `/${httpAdminRoot}` } if (httpAdminRoot[httpAdminRoot.length - 1] === '/') { httpAdminRoot = httpAdminRoot.substring( 0, httpAdminRoot.length - 1 ) } if (!/^[0-9a-z_\-\\/]*$/i.test(httpAdminRoot)) { throw new Error('Invalid settings.httpAdminRoot') } } result.httpAdminRoot = httpAdminRoot } } if (result.dashboardUI !== undefined) { let dashboardUI = result.dashboardUI delete result.dashboardUI if (typeof dashboardUI === 'string') { dashboardUI = dashboardUI.trim() if (dashboardUI.length > 0) { if (dashboardUI[0] !== '/') { dashboardUI = `/${dashboardUI}` } if (dashboardUI[dashboardUI.length - 1] === '/') { dashboardUI = dashboardUI.substring( 0, dashboardUI.length - 1 ) } if (!/^[0-9a-z_\-\\/]*$/i.test(dashboardUI)) { throw new Error('Invalid settings.dashboardUI') } } result.dashboardUI = dashboardUI } } if (result.palette?.nodesExcludes !== undefined) { const paletteNodeExcludes = result.palette.nodesExcludes delete result.palette.nodesExcludes if ( typeof paletteNodeExcludes === 'string' && paletteNodeExcludes.length > 0 ) { const parts = paletteNodeExcludes .split(',') .map((fn) => fn.trim()) .filter((fn) => fn.length > 0) if (parts.length > 0) { for (let i = 0; i < parts.length; i++) { const fn = parts[i] if (!/^[a-z0-9\-._]+\.js$/i.test(fn)) { throw new Error('Invalid settings.palette.nodesExcludes') } } result.palette.nodesExcludes = parts.join(',') } } } if (result.palette?.denyList !== undefined) { const paletteDenyList = result.palette.denyList delete result.palette.denyList if (Array.isArray(paletteDenyList)) { if (paletteDenyList.length > 0) { for (let i = 0; i < paletteDenyList.length; i++) { const fn = paletteDenyList[i] if (!/^((@[a-z0-9-~][a-z0-9-._~]*\/)?([a-z0-9-~][a-z0-9-._~]*|\*))(@([~^><]|<=|>=)?((0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?))?$/i.test(fn)) { throw new Error('Invalid settings.palette.denyList') } } result.palette.denyList = paletteDenyList } else { result.palette.denyList = [] } } } if (!importing && typeof result.httpNodeAuth?.pass === 'string' && result.httpNodeAuth.pass.length > 0) { result.httpNodeAuth.pass = hash(result.httpNodeAuth.pass) } if (result.apiMaxLength) { if (!/^\d+(?:kb|mb)$/.test(result.apiMaxLength)) { throw new Error('Invalid settings.apiMaxLength') } } if (result.debugMaxLenth) { if (!Number.isInteger(result.debugMaxLength) || result.debugMaxLength < 0) { throw new Error('Invalid settings.debugMaxLength') } } return result }, /** * For a given project, merge in the provided settings. This will update * settings that have a new value provided, whilst leaving others untouched * * TODO: This probably doesn't belong in the ProjectTemplate controller * as it doesn't do anything with the template itself. However it makes use of * templateFields/getTemplateValue/setTemplateValue from this file which * aren't otherwise exposed. * @param {*} app the forge app * @param {*} existingSettings the existing project settings * @param {*} settings the new settings to merge in * @param {*} options options for the merge * @param {boolean} options.mergeEnvVars if true, merge the env vars (new keys added, existing keys untouched, removed keys untouched) * @param {boolean} options.mergeEditorSettings if false (default), will overwrite all settings. If true, it will merge the settings together - only overwriting certain ones. * @param {object} options.targetTemplate - a template object containing its `policy`. When this parameter is provided, any items that are in the policy and are `locked`, will be skipped. */ mergeSettings: function (app, existingSettings, settings, { mergeEnvVars = false, mergeEditorSettings = false, targetTemplate = undefined } = {}) { // Quick deep clone that is safe as we know settings are JSON-safe const result = JSON.parse(JSON.stringify(existingSettings)) const skipList = ['disableEditor', 'page_title', 'header_title', 'theme'] templateFields.forEach((name) => { if (mergeEditorSettings && skipList.includes(name)) { return } // skip if locked in target template if (targetTemplate) { // explicit test for false to allow for things not in the policy if (getTemplateValue(targetTemplate.policy, name) === false) { return } } const value = getTemplateValue(settings, name) if (value !== undefined) { setTemplateValue(result, name, value) } }) if (result.httpNodeAuth?.type && result.httpNodeAuth.type !== 'basic') { result.httpNodeAuth.user = '' result.httpNodeAuth.pass = '' } if (result.page?.title === 'FlowForge') { result.page.title = 'FlowFuse' } if (settings.env) { if (mergeEnvVars) { // As objects for stable merge const existingEnvVars = Object.fromEntries((existingSettings.env || []).map(envVar => [envVar.name, envVar.value])) const newEnvVars = Object.fromEntries((settings.env || []).map(envVar => [envVar.name, envVar.value])) // copy new over old, then old over the merge (to keep the order), existing have precedence over new const mergedEnvVars = { ...existingEnvVars, ...newEnvVars, ...existingEnvVars } // Convert back to an array result.env = [] Object.entries(mergedEnvVars).forEach(entry => { const [name, value] = entry result.env.push({ name, value }) }) } else { result.env = settings.env } } return result }, createDefaultTemplate: async function (app, user) { const settings = {} const policy = {} templateFields.forEach((name) => { setTemplateValue(settings, name, defaultTemplateValues[name]) setTemplateValue(policy, name, defaultTemplatePolicy[name]) }) const [template] = await app.db.models.ProjectTemplate.findOrCreate({ where: { name: 'Default', active: true, ownerId: user.id }, defaults: { settings, policy } }) await template.setOwner(user) return template } }