UNPKG

node-red-contrib-flow-manager

Version:

Flow Manager separates your flow json to multiple files

1,104 lines (929 loc) 44.7 kB
const path = require('path') , fs = require('fs-extra') , bodyParser = require('body-parser') , log4js = require('log4js') , os = require("os") , jsonata = require('jsonata') , eol = require('eol') , YAML = require('js-yaml') , child_process = require('child_process') , crypto = require('crypto') , debounce = require('./debounce') , axios = require('axios') ; function execShellCommand(cmd) { return new Promise((resolve, reject) => { child_process.exec(cmd, (error, stdout, stderr) => { if (error) { reject(new Error(stderr||stdout)); } resolve(stdout); }); }); } function encodeFileName(origName) { return origName. replace(/\\/g, '%5C'). replace(/\//g, '%2F'). replace(/\:/g, '%3A'). replace(/\*/g, '%2A'). replace(/\?/g, '%3F'). replace(/\""/g, '%22'). replace(/\</g, '%3C'). replace(/\>/g, '%3E'). replace(/\|/g, '%7C') // Characters not allowed: \ / : * ? " < > | } function stringifyFormattedFileJson(nodes) { const str = JSON.stringify(nodes, undefined, 2); return eol.auto(str); } // NOTE(alonam) a little trick to require the same "node-red" API to give private access to our own modulesContext. const PRIVATERED = (function requireExistingNoderedInstance() { for(const child of require.main.children) { if(child.filename.endsWith('red.js')) { return require(child.filename); } } // In case node-red was not required before, just require it return require('node-red'); })(); const nodeName = path.basename(__filename).split('.')[0]; const nodeLogger = log4js.getLogger('NodeRed FlowManager'); let RED; function getNodesEnvConfigForNode(node) { return (node.name && directories.nodesEnvConfig["name:" + node.name]) || (node.id && directories.nodesEnvConfig[node.id]); } let lastFlows = {}; const originalSaveFlows = PRIVATERED.runtime.storage.saveFlows; PRIVATERED.runtime.storage.saveFlows = async function newSaveFlows(data) { if(data.flows && data.flows.length) { function getFlowFilePath(flowDetails) { let folderName; if(flowDetails.type === 'subflow') folderName = 'subflows'; else if(flowDetails.type === 'tab') folderName = 'flows'; else folderName = '.'; const flowsDir = path.resolve(directories.basePath, folderName); const flowName = flowDetails.type === 'global' ? 'config-nodes' : flowDetails.name; // if it's a subflow, then the correct property is 'name' const flowFilePath = path.join(flowsDir, encodeFileName(flowName) + '.' + flowManagerSettings.fileFormat); return flowFilePath; } const loadedFlowAndSubflowNames = {}; const envNodePropsChangedByUser = {}; const allFlows = {}; const orderedNodeIds = []; for(const node of data.flows) { orderedNodeIds.push(node.id); // // Enforce envnodes consistency // const envConfig = getNodesEnvConfigForNode(node); if (envConfig) { for (const key in envConfig) { const val = envConfig[key]; try { if (JSON.stringify(node[key]) !== JSON.stringify(val)) { if (!envNodePropsChangedByUser[node.id]) envNodePropsChangedByUser[node.id] = {}; envNodePropsChangedByUser[node.id][key] = val; node[key] = val; } } catch (e) {} } } // // Fill flows, subflows, config-nodes // if (node.type === 'tab' || node.type === 'subflow') { if (!allFlows[node.id]) allFlows[node.id] = {nodes: []}; if (!allFlows[node.id].name) allFlows[node.id].name = node.label || node.name; if (!allFlows[node.id].type) allFlows[node.id].type = node.type; allFlows[node.id].nodes.push(node); loadedFlowAndSubflowNames[node.id] = allFlows[node.id]; } else if (!node.z) { // global (config-node) if(!allFlows.global) allFlows.global = {name:"config-nodes", type:"global", nodes: []}; allFlows.global.nodes.push(node); } else { // simple node if(!allFlows[node.z]) allFlows[node.z] = {nodes: []}; allFlows[node.z].nodes.push(node); } } // save node ordering file await fs.writeJson(directories.nodesOrderFilePath, orderedNodeIds); // If envnode property changed, send back original values to revert on Node-RED UI as well. if(Object.keys(envNodePropsChangedByUser).length>0) { RED.comms.publish('flow-manager/flow-manager-envnodes-override-attempt', envNodePropsChangedByUser); } deployedFlowNames.clear(); for(const flowId of Object.keys(allFlows)) { const flowName = allFlows[flowId].name; const flowType = allFlows[flowId].type; if(flowType === 'tab' && ( flowManagerSettings.filter.indexOf(flowName) !== -1 || flowManagerSettings.filter.length===0 || onDemandFlowsManager.onDemandFlowsSet.has(flowName) ) ) { deployedFlowNames.add(flowName); } // Potentially we save different flow file than the one we deploy, like "credentials" property is deleted in flow file. const flowNodes = JSON.parse(JSON.stringify(allFlows[flowId].nodes)); for(const node of flowNodes) { // Set properties used by envnodes to mared them as flow-manager managed. const foundNodeEnvConfig = getNodesEnvConfigForNode(node); if(foundNodeEnvConfig) { for(const prop in foundNodeEnvConfig) { node[prop] = "flow-manager-managed"; } } // delete credentials if(node.credentials != null) { delete node.credentials; } } const flowFilePath = getFlowFilePath({type: flowType, name: flowName}); try { if(lastFlows[flowId] && allFlows[flowId] && lastFlows[flowId].name != allFlows[flowId].name) { const flowsDir = path.dirname(flowFilePath); const from = path.resolve(flowsDir, `${encodeFileName(lastFlows[flowId].name)}.${flowManagerSettings.fileFormat}`); const to = path.resolve(flowFilePath); try { // First, try rename with git await execShellCommand(`git mv -f "${from}" "${to}"`); } catch (e) { // if not working with git, do a normal "mv" command try { await fs.move(from, to, {overwrite: true}); } catch (e) {} } } // save flow file const fileStr = await writeFlowFile(flowFilePath, flowNodes); const stat = await fs.stat(flowFilePath); // update revisions const flowRev = calculateRevision(fileStr) switch (flowType) { case 'tab': revisions.byFlowName[flowName] = {rev:flowRev, mtime:stat.mtime}; break case 'subflow': revisions.bySubflowName[flowName] = {rev:flowRev, mtime:stat.mtime}; break case 'global': revisions.global = {rev:flowRev, mtime:stat.mtime}; break } } catch(err) {} } // Check which flows were removed, if any for(const flowId in lastFlows) { const flowName = lastFlows[flowId].name; if(!allFlows[flowId] && ( (flowManagerSettings.filter.indexOf(flowName) !== -1 || flowManagerSettings.filter.length===0) || lastFlows[flowId].type === 'subflow' )) { const flowFilePath = getFlowFilePath(lastFlows[flowId]); await fs.remove(flowFilePath); } } lastFlows = loadedFlowAndSubflowNames; } return originalSaveFlows.apply(PRIVATERED.runtime.storage, arguments); } const flowManagerSettings = {}; async function writeFlowFile(filePath, flowStrOrObject) { let str; async function isReallyChanged(newObj) { try { const oldObj = (await readFlowFile(filePath)).obj; const oldSet = new Set(oldObj.map(item=>JSON.stringify(item))); const newSet = new Set(newObj.map(item=>JSON.stringify(item))); if (oldSet.size !== newSet.size) return true; newSet.forEach(function (item) { if (!oldSet.has(item)) throw 'Found change'; }) } catch (e) { return true; } } let changed; if (typeof flowStrOrObject === 'string' || flowStrOrObject instanceof String) { changed = await isReallyChanged(filePath.endsWith('.yaml')?YAML.safeLoad(flowStrOrObject):JSON.parse(flowStrOrObject)); str = flowStrOrObject; } else if(flowManagerSettings.fileFormat === 'yaml') { changed = await isReallyChanged(flowStrOrObject); str = YAML.safeDump(flowStrOrObject); } else { changed = await isReallyChanged(flowStrOrObject); str = stringifyFormattedFileJson(flowStrOrObject); } if(changed) { await fs.outputFile(filePath, str); } return str; } async function readFlowFile(filePath, ignoreObj) { const retVal = {}; const fileContentsStr = await fs.readFile(filePath, 'utf-8'); retVal.str = fileContentsStr; if(ignoreObj) { retVal.mtime = (await fs.stat(filePath)).mtime return retVal; } const indexOfExtension = filePath.lastIndexOf('.'); const fileExt = filePath.substring(indexOfExtension+1).toLowerCase(); const finalObject = fileExt === 'yaml'? YAML.safeLoad(fileContentsStr) : JSON.parse(fileContentsStr); if(fileExt !== flowManagerSettings.fileFormat) { // File needs conversion const newFilePathWithNewExt = filePath.substring(0, indexOfExtension) + '.' + flowManagerSettings.fileFormat; await writeFlowFile(newFilePathWithNewExt, finalObject); // Delete old file await fs.remove(filePath); filePath = newFilePathWithNewExt; } retVal.mtime = (await fs.stat(filePath)).mtime retVal.obj = finalObject; return retVal; } async function readConfigFlowFile(ignoreObj) { try { return await readFlowFile(directories.configNodesFilePathWithoutExtension+'.json', ignoreObj); } catch (e) { return await readFlowFile(directories.configNodesFilePathWithoutExtension+'.yaml', ignoreObj); } } async function readFlowByNameAndType(type, name, ignoreObj) { const fileNameWithExt = `${name}.${flowManagerSettings.fileFormat}`; if(type === 'flow') { return await readFlowFile(path.join(directories.flowsDir, fileNameWithExt), ignoreObj); } else if(type === 'subflow') { return await readFlowFile(path.join(directories.subflowsDir, fileNameWithExt), ignoreObj); } else if(type === 'global') { return await(readConfigFlowFile(ignoreObj)); } } async function flowFileExists(flowName) { return fs.exists(path.resolve(directories.flowsDir, `${flowName}.${flowManagerSettings.fileFormat}`)); } const onDemandFlowsManager = { onDemandFlowsSet: new Set(), updateOnDemandFlowsSet: async function (newSet) { let totalSet; // // If flow file does not exist, or is already passed filtering settings, we don't consider it an on-demand flow // if(flowManagerSettings.filter.length === 0) { totalSet = new Set(); } else { totalSet = newSet; for(const flowName of Array.from(totalSet)) { if(!await flowFileExists(flowName) || flowManagerSettings.filter.indexOf(flowName) !== -1) { totalSet.delete(flowName); } } } onDemandFlowsManager.onDemandFlowsSet = totalSet; return onDemandFlowsManager.onDemandFlowsSet; } } async function readProject() { try { // newer nodered versions const newCfgPath = path.join(RED.settings.userDir, '.config.projects.json'); const newCfgPath_exists = fs.existsSync(newCfgPath); if(newCfgPath_exists) { const redConfig = await fs.readJson(newCfgPath); return {path: newCfgPath, activeProject: redConfig.activeProject}; } // older nodered versions const oldCfgPath = path.join(RED.settings.userDir, '.config.json'); const oldCfgPath_exists = fs.existsSync(oldCfgPath); if(oldCfgPath_exists) { const redConfig = await fs.readJson(oldCfgPath); return {path: oldCfgPath, activeProject: redConfig.projects.activeProject}; } } catch (e) {} return null; } const revisions = { byFlowName: {}, bySubflowName: {}, global: null // config nodes, etc.. } let deployedFlowNames = new Set(); function calculateRevision(str) { return crypto.createHash('md5').update(str).digest("hex"); } const directories = {}; async function main() { let initialLoadPromise = (()=>{ let res; const p = new Promise((resolve, reject)=>{ res = resolve; }); p.resolve = res; return p })() const originalGetFlows = PRIVATERED.runtime.storage.getFlows; PRIVATERED.runtime.storage.getFlows = async function () { if(initialLoadPromise) await initialLoadPromise; const retVal = await originalGetFlows.apply(PRIVATERED.runtime.storage, arguments); const flowsInfo = await loadFlows(null, true); retVal.flows = flowsInfo.flows; retVal.rev = calculateRevision(JSON.stringify(flowsInfo.flows)); revisions.byFlowName = flowsInfo.flowVersions; revisions.bySubflowName = flowsInfo.subflowVersions; revisions.global = flowsInfo.globalVersion; deployedFlowNames = new Set( Object.values(flowsInfo.loadedFlowAndSubflowNames) .filter(flow=>flow.type==='tab') .map(flow=>flow.name) ); lastFlows = flowsInfo.loadedFlowAndSubflowNames; return retVal; } async function refreshDirectories() { let basePath, project = null; if(RED.settings.editorTheme.projects.enabled) { projectObj = await readProject(); if(projectObj?.activeProject) { project = projectObj.activeProject; const activeProjectPath = path.join(RED.settings.userDir, 'projects', project); basePath = activeProjectPath; } else { basePath = RED.settings.userDir; } } else { basePath = RED.settings.userDir; } Object.assign(directories, { basePath: basePath, subflowsDir: path.resolve(basePath, 'subflows'), flowsDir: path.resolve(basePath, 'flows'), envNodesDir: path.resolve(basePath, 'envnodes'), flowFile: path.resolve(basePath, RED.settings.flowFile || 'flows_'+os.hostname()+'.json'), project: project, flowManagerCfg: path.resolve(basePath, 'flow-manager-cfg.json'), configNodesFilePathWithoutExtension: path.resolve(basePath, 'config-nodes'), nodesOrderFilePath: path.resolve(basePath, 'flow-manager-nodes-order.json'), }); // Read flow-manager settings let needToSaveFlowManagerSettings = false; try { const fileJson = await fs.readJson(directories.flowManagerCfg); // Backwards compatibility if(Array.isArray(fileJson)) { needToSaveFlowManagerSettings = true; flowManagerSettings.filter = fileJson; } else if(typeof fileJson === 'object') { Object.assign(flowManagerSettings, fileJson); } } catch (e) {} finally { if(!Array.isArray(flowManagerSettings.filter)) { needToSaveFlowManagerSettings = true; flowManagerSettings.filter = []; } if(!flowManagerSettings.fileFormat) { needToSaveFlowManagerSettings = true; flowManagerSettings.fileFormat = 'json'; } } if(needToSaveFlowManagerSettings) { await fs.outputFile(directories.flowManagerCfg, stringifyFormattedFileJson(flowManagerSettings)); } directories.configNodesFilePath = directories.configNodesFilePathWithoutExtension + '.' + flowManagerSettings.fileFormat; // Loading ENV configuration for nodes directories.nodesEnvConfig = {}; const envNodeResolutionPromises = []; try { // envnodes dir is optional for(const envNodeFileName of await fs.readdir(directories.envNodesDir)) { const ext = envNodeFileName.substring(envNodeFileName.lastIndexOf('.')+1); const fileExtIndex = ["jsonata", "json", "js"].indexOf(ext); if(fileExtIndex === -1) continue; envNodeResolutionPromises.push((async ()=>{ const absoluteFilePath = path.resolve(directories.envNodesDir, envNodeFileName); let result = null; try { switch (fileExtIndex) { case 0: { // jsonata const fileContents = await fs.readFile(absoluteFilePath, 'UTF-8'); result = jsonata(fileContents).evaluate({ require: require, basePath: directories.basePath }); break } case 1: { const fileContents = await fs.readFile(absoluteFilePath, 'UTF-8'); result = JSON.parse(fileContents); break } case 2: { const jsFile = require(absoluteFilePath); if(isObject(jsFile)) { result = jsFile; } else if(typeof jsFile === 'function') { const returnedVal = jsFile(RED); if(returnedVal instanceof Promise) { result = await returnedVal; } else { result = returnedVal; } } break } } } catch(e) { nodeLogger.error('JSONata parsing failed for env nodes:\n', e); } return result; })()); } } catch (e) {} const results = await Promise.all(envNodeResolutionPromises); results.forEach(result=>{Object.assign(directories.nodesEnvConfig, result)}); await fs.ensureDir(directories.flowsDir); await fs.ensureDir(directories.subflowsDir); } async function readAllFlowFileNames(type='subflow') { const filesUnderFolder = (await fs.readdir(type==='subflow'?directories.subflowsDir:directories.flowsDir)); const relevantItems = filesUnderFolder.filter(item => { const ext = path.extname(item).toLowerCase(); return ext === '.json' || ext === '.yaml'; }); return relevantItems; } async function readAllFlowFileNamesWithoutExt(type) { return (await readAllFlowFileNames(type)).map( file=>file.substring(0, file.lastIndexOf('.')) ); } async function loadFlows(flowsToShow = null, getMode = false) { const retVal = { flows: [], rev: null, flowVersions: {}, subflowVersions: {}, globalVersion: null, loadedFlowAndSubflowNames: {} } let flowJsonSum = { tabs: [], subflows: [], groups: [], global: [], groupedNodes: [], nodes: [], byNodeId: {} }; let items = await readAllFlowFileNames('flow'); if(flowsToShow === null) { flowsToShow = flowManagerSettings.filter; } nodeLogger.info('Flow filtering state:', flowsToShow); // read flows for(const algoFlowFileName of items) { try { const itemWithoutExt = algoFlowFileName.substring(0, algoFlowFileName.lastIndexOf('.')); if(!algoFlowFileName.toLowerCase().match(/.*\.(json)|(yaml)$/g)) continue; const flowJsonFile = await readFlowFile(path.join(directories.flowsDir, algoFlowFileName)); retVal.flowVersions[itemWithoutExt] = { rev: calculateRevision(flowJsonFile.str), mtime: flowJsonFile.mtime }; // Ignore irrelevant flows (filter flows functionality) if(flowsToShow && flowsToShow.length && flowsToShow.indexOf(itemWithoutExt) === -1) { continue; } // find tab node let tab = null; for(let i=flowJsonFile.obj.length-1; i>=0; i--) { const node = flowJsonFile.obj[i]; flowJsonSum.byNodeId[node.id] = node; if(node.type === 'tab') { flowJsonSum.tabs.push(node); flowJsonFile.obj.splice(i, 1); tab = node; } else if(node.type === 'group') { flowJsonFile.obj.splice(i, 1); flowJsonSum.groups.push(node); } else if(typeof node.g === "string") { flowJsonFile.obj.splice(i, 1); flowJsonSum.groupedNodes.push(node); } } if(!tab) {throw new Error("Could not find tab node in flow file")} Array.prototype.push.apply(flowJsonSum.nodes, flowJsonFile.obj); retVal.loadedFlowAndSubflowNames[tab.id] = {type: tab.type, name: tab.label}; } catch (e) { nodeLogger.error('Could not load flow ' + algoFlowFileName + '\r\n' + e.stack||e); } } // read subflows const subflowItems = (await fs.readdir(directories.subflowsDir)).filter(item=>{ const itemLC = item.toLowerCase(); return itemLC.endsWith('.yaml') || itemLC.endsWith('.json'); }); for(const subflowFileName of subflowItems) { try { const flowJsonFile = await readFlowFile(path.join(directories.subflowsDir, subflowFileName)); // find subflow node let subflowNode = false; for(let i=flowJsonFile.obj.length-1; i>=0; i--) { const node = flowJsonFile.obj[i]; flowJsonSum.byNodeId[node.id] = node; if(node.type === 'subflow') { flowJsonSum.subflows.push(node); flowJsonFile.obj.splice(i, 1); subflowNode = node; } else if(node.type === 'group') { flowJsonFile.obj.splice(i, 1); flowJsonSum.groups.push(node); } else if(typeof node.g === "string") { flowJsonFile.obj.splice(i, 1); flowJsonSum.groupedNodes.push(node); } } if(!subflowNode) {throw new Error("Could not find subflow node in flow file")} Array.prototype.push.apply(flowJsonSum.nodes, flowJsonFile.obj); const itemWithoutExt = subflowFileName.substring(0, subflowFileName.lastIndexOf('.')); retVal.subflowVersions[itemWithoutExt] = { rev: calculateRevision(flowJsonFile.str), mtime: flowJsonFile.mtime }; retVal.loadedFlowAndSubflowNames[subflowNode.id] = {type: subflowNode.type, name: subflowNode.name}; } catch (e) { nodeLogger.error('Could not load subflow ' + subflowFileName + '\r\n' + e.stack||e); } } { // read config nodes let configFlowFile; try { configFlowFile = await readConfigFlowFile(); } catch(e) { await writeFlowFile(directories.configNodesFilePathWithoutExtension+'.'+flowManagerSettings.fileFormat, []); configFlowFile = await readConfigFlowFile(); } retVal.globalVersion = { rev: calculateRevision(configFlowFile.str), mtime: configFlowFile.mtime }; for(const node of configFlowFile.obj) { flowJsonSum.byNodeId[node.id] = node; } Array.prototype.push.apply(flowJsonSum.global, configFlowFile.obj); } let orderedNodes = null; try { const unorderedNodesLeft = new Set(Object.keys(flowJsonSum.byNodeId)); const nodesOrderArray = await fs.readJson(directories.nodesOrderFilePath); orderedNodes = [] for(const nodeId of nodesOrderArray) { const theNode = flowJsonSum.byNodeId[nodeId]; if(theNode) { orderedNodes.push(theNode); unorderedNodesLeft.delete(nodeId); } } if(unorderedNodesLeft.size>0) { orderedNodes = null; } } catch (e) {} if(!orderedNodes) { // No ordering exists, at least come up with a similar order orderedNodes = [...flowJsonSum.tabs, ...flowJsonSum.subflows, ...flowJsonSum.groups, ...flowJsonSum.global, ...flowJsonSum.groupedNodes, ...flowJsonSum.nodes]; } retVal.flows = orderedNodes; const nodesEnvConfig = directories.nodesEnvConfig; // If we have node config modifications for this ENV, iterate and apply node updates from ENV config. if(nodesEnvConfig && Object.keys(nodesEnvConfig).length) { for(const node of retVal.flows) { // If no patch exists for this id if(!node) continue; const foundNodeEnvConfig = getNodesEnvConfigForNode(node); if(foundNodeEnvConfig) { Object.assign(node, foundNodeEnvConfig); } } } if(!getMode) { nodeLogger.info('Loading flows:', items); nodeLogger.info('Loading subflows:', subflowItems); try { retVal.rev = await PRIVATERED.nodes.setFlows(retVal.flows, null, 'flows'); revisions.byFlowName = retVal.flowVersions; revisions.bySubflowName = retVal.subflowVersions; revisions.global = retVal.globalVersion; nodeLogger.info('Finished setting node-red nodes successfully.'); } catch (e) { nodeLogger.error('Failed setting node-red nodes\r\n' + e.stack||e); } } return retVal; } async function checkIfMigrationIsRequried() { try { // Check if we need to migrate from "fat" flow json file to managed mode. if( (await fs.readdir(directories.flowsDir)).length===0 && (await fs.readdir(directories.subflowsDir)).length===0 && await fs.exists(directories.flowFile) ) { nodeLogger.info('First boot with flow-manager detected, starting migration process...'); return true; } } catch(e) {} return false; } async function startFlowManager() { await refreshDirectories(); if(await checkIfMigrationIsRequried()) { const masterFlowFile = await fs.readJson(directories.flowFile); const flowNodes = {}; const globalConfigNodes = [], simpleNodes = []; for (const node of masterFlowFile) { if(node.type === 'tab' || node.type === 'subflow') flowNodes[node.id] = [node]; else if(!node.z || node.z.length === 0) globalConfigNodes.push(node); else simpleNodes.push(node); } for(const node of simpleNodes) { if(flowNodes[node.z]) flowNodes[node.z].push(node); } // finally write files const fileWritePromises = [writeFlowFile(directories.configNodesFilePath, globalConfigNodes)]; for(const flowId of Object.keys(flowNodes)) { const nodesInFlow = flowNodes[flowId]; const topNode = nodesInFlow[0]; const flowName = topNode.label || topNode.name; // label on tabs , const destinationFile = path.resolve(directories.basePath, topNode.type === 'tab'? 'flows':'subflows', encodeFileName(flowName)+'.'+flowManagerSettings.fileFormat); fileWritePromises.push( writeFlowFile(destinationFile, nodesInFlow) ); } await Promise.all(fileWritePromises); nodeLogger.info('flow-manager migration complete.'); } } await startFlowManager(); if(RED.settings.editorTheme.projects.enabled) { let projFile = await readProject(); if(!projFile) return; // no cfg file, do nothing. fs.watch(projFile.path, debounce(async () => { const newProjFile = await readProject(); if(!projFile) return; // no cfg, do nothing if(projFile?.activeProject != newProjFile?.activeProject) { projFile = newProjFile; await startFlowManager(); } }, 500)); } RED.httpAdmin.get( '/'+nodeName+'/flow-names', RED.auth.needsPermission("flows.read"), async function (req, res) { try { let flowFiles = await fs.readdir(path.join(directories.basePath, "flows")); flowFiles = flowFiles.filter(file=>file.toLowerCase().match(/.*\.(json)|(yaml)$/g)); res.send(flowFiles); } catch(e) { res.status(404).send(); } }); async function getFlowState(type, flowName) { let flowFile try { flowFile = await readFlowByNameAndType(type, flowName, true); } catch (e) { return null } const retVal = {}; let lastLoadedFlowVersionInfo; if(type==='flow') { lastLoadedFlowVersionInfo = revisions.byFlowName[flowName]; // If flow was not deployed either "on-demend" or passed filtering, we determine that it was not deployed. const wasLoadedOnDemand = type === 'flow' && onDemandFlowsManager.onDemandFlowsSet.has(flowName) retVal.deployed = deployedFlowNames.has(flowName); retVal.onDemand = wasLoadedOnDemand; } else if(type==='subflow') { lastLoadedFlowVersionInfo = revisions.bySubflowName[flowName]; retVal.deployed = true; } else if(type==='global') { lastLoadedFlowVersionInfo = revisions.global; retVal.deployed = true; } else { return null; } const fileRev = calculateRevision(flowFile.str); retVal.rev = fileRev retVal.mtime = flowFile.mtime; retVal.hasUpdate = !lastLoadedFlowVersionInfo || fileRev !== lastLoadedFlowVersionInfo.rev; if(retVal.hasUpdate && lastLoadedFlowVersionInfo) { retVal.oldRev = lastLoadedFlowVersionInfo.rev; retVal.oldMtime = lastLoadedFlowVersionInfo.mtime } return retVal; } async function getFlowStateForType(type) { if(type === 'flow' || type === 'subflow') { const retVal = {}; const flowNames = await readAllFlowFileNamesWithoutExt(type); for(const flowName of flowNames) { retVal[flowName] = await getFlowState(type, flowName); } return retVal; } else if(type === 'global') { return await getFlowState(type); } } RED.httpAdmin.get( '/'+nodeName+'/states/:type/:flowName?', RED.auth.needsPermission("flows.read"), async function (req, res) { try { if(['flow', 'subflow', 'global'].indexOf(req.params.type) !== -1) { let retVal; if(req.params.flowName) { retVal = await getFlowState(req.params.type, req.params.flowName); } else { retVal = await getFlowStateForType(req.params.type); } if(retVal) { return res.send(retVal); } else { return res.status(404).send({error: `No such ${req.params.type} file`}); } } else { return res.status(404).send({error: `Unrecognised flow type: ${req.params.type}`}); } } catch (e) { return res.status(404).send(); } }); RED.httpAdmin.get( '/'+nodeName+'/states', RED.auth.needsPermission("flows.read"), async function (req, res) { try { const retVal = {}; for(const flowType of ['flow', 'subflow', 'global']) { retVal[flowType] = await getFlowStateForType(flowType); } return res.send(retVal); } catch (e) { return res.status(404).send(); } }); function isObject (value) { return value && typeof value === 'object' && value.constructor === Object; } RED.httpAdmin.all('/'+nodeName+'/remotes/:remoteName/**', [RED.auth.needsPermission("flows.read"), RED.auth.needsPermission("flows.write"), bodyParser.text({limit: "50mb", type: '*/*'})], async function (req, res) { try { const remote = flowManagerSettings.remoteDeploy.remotes.find(remote=> remote.name === req.params.remoteName); if(!remote) {throw new Error("Remote not found")} const toCutAfter = `/flow-manager/remotes/${req.params.remoteName}`; const appendUrl = req.url.substring(req.url.indexOf(toCutAfter)+toCutAfter.length); const nrAddressWithSlash = remote.nrAddress.endsWith('/')?remote.nrAddress:(remote.nrAddress+'/'); const remotePathUrl = nrAddressWithSlash+'flow-manager'+appendUrl; const remoteResponse = await axios({ method: req.method, url: remotePathUrl.toString(), headers: req.headers, data: req.body, transformResponse: [] // Disabling force json parsing }) if(req.headers.accept === 'text/plain') { res = res.contentType('text/plain'); } else { res = res.contentType(flowManagerSettings.fileFormat.toLowerCase() === 'yaml'? 'application/x-yaml':'application/json'); } return res.status(remoteResponse.status).send(remoteResponse.data); } catch (e) { try { return res.status(e.response.status).send(e.response.data); } catch (e) { return res.status(400).send({"error": "Unknown communication failure"}); } } }); RED.httpAdmin.post( '/'+nodeName+'/states', RED.auth.needsPermission("flows.write"), async function loadFlowsOnDemand(req, res) { try { if(!isObject(req.body) || !req.body.action) return res.status(400).send({"error": 'missing "action" key'}); const allFlows = await readAllFlowFileNamesWithoutExt('flow'); const filterChosenFlows = (!flowManagerSettings.filter || flowManagerSettings.filter.length === 0) ? allFlows : flowManagerSettings.filter; // calculate which flows to load (null means all flows, no filtering) let flowsToShow; const requestedToLoadAll = req.body.action === 'loadAll'; const reloadOnly = req.body.action === 'reloadOnly'; const removeOndemand = req.body.action === 'removeOndemand' && req.body.flows; const addOndemand = req.body.action === 'addOndemand' && req.body.flows; const replaceOndemand = req.body.action === 'replaceOndemand' && req.body.flows; if(requestedToLoadAll) { flowsToShow = allFlows; } else if(reloadOnly) { flowsToShow = [...filterChosenFlows, ...Array.from(onDemandFlowsManager.onDemandFlowsSet)]; } else if(removeOndemand) { const newSet = new Set(onDemandFlowsManager.onDemandFlowsSet); for(const undeployFlow of removeOndemand) { newSet.delete(undeployFlow); } flowsToShow = [...filterChosenFlows, ...Array.from(newSet)]; } else if(addOndemand || replaceOndemand) { flowsToShow = Array.from( new Set([...filterChosenFlows, ...req.body.flows, ...(addOndemand?Array.from(onDemandFlowsManager.onDemandFlowsSet):[]) ]) ); } else { return res.status(400).send({"error": 'malformed action request, could be missing "flows" array'}) } // calculate which of the loaded flows are "on-demand" flows await onDemandFlowsManager.updateOnDemandFlowsSet(new Set(flowsToShow)); await loadFlows(flowsToShow); res.send({"status": "ok"}); } catch(e) { res.status(404).send({error: e.message}); } }); RED.httpAdmin.get('/'+nodeName+'/cfg', RED.auth.needsPermission("flows.read"), async function (req, res) { res.send(await fs.readJson(directories.flowManagerCfg)); }); RED.httpAdmin.get( '/'+nodeName+'/filter-flows', RED.auth.needsPermission("flows.read"), async function (req, res) { res.send(flowManagerSettings.filter); }); RED.httpAdmin.put( '/'+nodeName+'/filter-flows', RED.auth.needsPermission("flows.read"), async function (req, res) { const filterArray = req.body; try { flowManagerSettings.filter = filterArray; await onDemandFlowsManager.updateOnDemandFlowsSet(new Set()); await fs.outputFile(directories.flowManagerCfg, stringifyFormattedFileJson(flowManagerSettings)); await loadFlows(flowManagerSettings.filter, false); res.send({}); } catch(e) { res.status(404).send(); } }); async function handleFlowFile(req, res) { try { const type = req.params.type; const flowTypes = ['flows','subflows','global']; let indexOfFlowType = flowTypes.indexOf(type); if(indexOfFlowType === -1) { // try singular indexOfFlowType = ['flow','subflow'].indexOf(type); } let pathToWrite = indexOfFlowType===0? directories.flowsDir: indexOfFlowType===1? directories.subflowsDir: indexOfFlowType===2? (directories.configNodesFilePathWithoutExtension+'.'+flowManagerSettings.fileFormat) :''; let fullPath; if(indexOfFlowType === 2) { fullPath = pathToWrite; } else { const fileName = req.params.fileName; if(!fileName || fileName.indexOf('..') !== -1 || fileName.indexOf('/') !== -1 || fileName.indexOf('\\') !== -1) { return res.status(400).send({error:"Flow file illegal"}); } fullPath = path.resolve(pathToWrite, fileName+'.'+flowManagerSettings.fileFormat); } if(indexOfFlowType === -1) { return res.status(400).send({error:"Unrecognized flow type, please use one of "+JSON.stringify(flowTypes).split('"').join("")}); } else if(!fullPath) { return res.status(400).send({error:"malformed flow filename"}); } if(req.method === 'GET') { if(!await fs.pathExists(fullPath)) { return res.status(404).send({error:"Flow file not found"}); } const str= (await readFlowFile(fullPath, true)).str; if(req.headers.accept === 'text/plain') { return res.contentType('text/plain').send(str); } else { return res.contentType(flowManagerSettings.fileFormat.toLowerCase() === 'yaml'? 'application/x-yaml':'application/json').send(str); } } else if(req.method === 'POST') { await writeFlowFile(fullPath, req.body); const mtime = req.query.mtime; const atime = req.query.atime; if(mtime || atime) { await fs.utimes(fullPath, atime?new Date(atime):new Date(), mtime?new Date(mtime):new Date() ); } } else if(req.method === 'DELETE') { if(!await fs.pathExists(fullPath)) { return res.status(404).send({error:"Delete failed, Flow file not found"}); } await fs.remove(fullPath) } else { return res.status(400).send({error:"Unknown method"}); } res.send({status:"ok"}); } catch (e) { res.status(400).send({error:e.message}); } } RED.httpAdmin.delete('/'+nodeName+'/flow-files/:type/:fileName?', RED.auth.needsPermission("flows.write"), handleFlowFile); RED.httpAdmin.post( '/'+nodeName+'/flow-files/:type/:fileName?', [RED.auth.needsPermission("flows.write"), bodyParser.text({limit:"50mb", type: '*/*'})], handleFlowFile); RED.httpAdmin.get( '/'+nodeName+'/flow-files/:type/:fileName?', RED.auth.needsPermission("flows.read"), handleFlowFile); initialLoadPromise.resolve(); initialLoadPromise = null; } module.exports = function(_RED) { RED = _RED; main(); };