UNPKG

node-red-contrib-uibuilder

Version:

Easily create web UI's for Node-RED using any (or no) front-end library. VueJS and bootstrap-vue included but change as desired.

1,019 lines (908 loc) 85.6 kB
/* eslint-env node es2017 */ /** * Copyright (c) 2017-2021 Julian Knight (Totally Information) * https://it.knightnet.org.uk, https://github.com/TotallyInformation/node-red-contrib-uibuilder * * Licensed under the Apache License, Version 2.0 (the 'License'); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an 'AS IS' BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. **/ 'use strict' //#region --- Type Defs --- // // <reference types="Express" /> /** * @typedef {import('../typedefs.js')} * typedef {import('Express')} Express * typedef {import('node-red')} Red */ //#endregion --- Type Defs --- // //#region ------ Require packages ------ // // uibuilder custom const uiblib = require('./uiblib') // Utility library for uibuilder const tilib = require('./tilib') // General purpose library (by Totally Information) const templateConf = require('../templates/template_dependencies') // Template configuration metadata const sockets = require('./socket.js') // Singleton, only 1 instance of this class will ever exist. So it can be used in other modules within Node-RED. const web = require('./web.js') // Singleton, only 1 instance of this class will ever exist. So it can be used in other modules within Node-RED. // Core node.js const path = require('path') //const events = require('events') const child_process = require('child_process') // 3rd-party const serveStatic = require('serve-static') //const serveIndex = require('serve-index') //const socketio = require('socket.io') const Express = require('express') // eslint-disable-line no-unused-vars const fs = require('fs-extra') // https://github.com/jprichardson/node-fs-extra#nodejs-fs-extra const fg = require('fast-glob') // https://github.com/mrmlnc/fast-glob //#endregion ----- Require packages ----- // //#region ------ uibuilder module-level globals ------ // const uib = { /** Contents of uibuilder's package.json file */ me: fs.readJSONSync(path.join( __dirname, '..', 'package.json' )), /** Module name must match this nodes html file @constant {string} uib.moduleName */ moduleName: 'uibuilder', /** URL path prefix set in settings.js - prefixes all URL's - equiv of httpNodeRoot from settings.js */ nodeRoot: '', /** Track across redeployments @constant {Object} uib.deployments */ deployments: {}, /** When nodeInstance is run, add the node.id as a key with the value being the url * then add processing to ensure that the URL's are unique. * Schema: {'<node.id>': '<url>'} * @constant {Object} uib.uib.instances */ instances: {}, /** File name of the master package list used to check for commonly installed FE libraries */ masterPackageListFilename: 'masterPackageList.json', /** File name of the installed package list */ packageListFilename: 'packageList.json', /** Track the vendor packages installed and their paths - updated by uiblib.checkInstalledPackages() * Populated initially from packageList file once the configFolder is known & master list has been copied. * Schema: {'<npm package name>': {'url': vendorPath, 'path': installFolder, 'version': packageVersion, 'main': mainEntryScript} } * @type {Object.<string, Object>} uib.packageList */ installedPackages: {}, /** Location of master template folders (containing default front-end code) @constant {string} uib.masterTemplateFolder */ masterTemplateFolder: path.join( __dirname, '..', 'templates' ), /** DEFAULT template to use as master? Must match a folder in the masterTemplateFolder * Each instance can have its own template, stored in node.templSel */ masterTemplate: 'vue', /** Location of master dist folder (containing built core front-end code) @constant {string} uib.masterStaticDistFolder */ masterStaticDistFolder: path.join( __dirname, '..', 'front-end', 'dist' ), /** Location of master src folder (containing src core front-end code) @constant {string} uib.masterStaticSrcFolder */ masterStaticSrcFolder: path.join( __dirname, '..', 'front-end', 'src' ), /** root folder (on the server FS) for all uibuilder front-end data * Cannot be set until we have the RED object and know if projects are being used * Name of the fs path used to hold custom files & folders for all uib.instances of uibuilder * @constant {string} uib.rootFolder * @default <userDir>/<uib.moduleName> or <userDir>/projects/<currProject>/<uib.moduleName> **/ rootFolder: null, /** Location for uib config folder - set once rootFolder is finalised */ configFolder: null, /** name of the config folder */ configFolderName: '.config', /** Location for uib common folder - set once rootFolder is finalised */ commonFolder: null, /** Name of the `common` folder for shared resources */ commonFolderName: 'common', /** Name of the Socket.IO Use Middleware */ sioUseMwName: 'sioUse.js', /** The channel names for Socket.IO @type {Object} */ ioChannels: {control: 'uiBuilderControl', client: 'uiBuilderClient', server: 'uiBuilder'}, /** What version of Node.JS are we running under? Impacts some file processing. * @type {Array.<number|string>} */ nodeVersion: process.version.replace('v','').split('.'), /** Options for serveStatic * @see https://expressjs.com/en/resources/middleware/serve-static.html */ staticOpts: {}, //{ maxAge: 31536000, immutable: true, }, /** Array of instances that have requested their local instance folders be deleted on deploy - see html file oneditdelete, updated by admin api */ deleteOnDelete: {}, /** Parameters for custom webserver if required. Port is undefined if using Node-RED's webserver. */ customServer: { /** Optional TCP/IP port number. If defined, uibuilder will use its own ExpressJS server/app * If undefined, uibuilder will use the Node-RED user-facing ExpressJS server * @type {undefined|number} If undefined, means that uibuilder is using Node-RED's webserver */ port: undefined, /** @type {string} Node.js server type. ['http', 'https', 'http2'] */ type: 'http', /** @type {undefined|string} uibuilder Host. sub(domain) name or IP Address */ host: undefined, }, } /** Current module version (taken from package.json) @constant {string} uib.version */ uib.version = uib.me.version /** Dummy logging * @type {Object.<string, function>} */ var dummyLog = { fatal: function(){}, // fatal - only those errors which make the application unusable should be recorded error: function(){}, // error - record errors which are deemed fatal for a particular request + fatal errors warn: function(){}, // warn - record problems which are non fatal + errors + fatal errors info: function(){}, // info - record information about the general running of the application + warn + error + fatal errors debug: function(){}, // debug - record information which is more verbose than info + info + warn + error + fatal errors trace: function(){}, // trace - record very detailed logging + debug + info + warn + error + fatal errors } var log = dummyLog // reset to RED.log or anything else you fancy at any point // Placeholder - set in export var userDir = '' //#endregion ----- uibuilder module-level globals ----- // /** Export the function that defines the node * @type {runtimeRED} */ module.exports = function(/** @type {runtimeRED} */ RED) { //#region ----- Constants for standard setup ----- // /** Folder containing settings.js, installed nodes, etc. @constant {string} userDir */ // @ts-ignore userDir = RED.settings.userDir // Set the root folder uib.rootFolder = path.join(userDir, uib.moduleName) // If projects are enabled - update root folder to `<userDir>/projects/<projectName>/uibuilder/<url>` if ( uiblib.getProps(RED, RED.settings.get('editorTheme'), 'projects.enabled') === true ) { const currProject = uiblib.getProps(RED, RED.settings.get('projects'), 'activeProject', '') if ( currProject !== '' ) uib.rootFolder = path.join(userDir, 'projects', currProject, uib.moduleName) } /** Locations for uib config can common folders */ uib.configFolder = path.join(uib.rootFolder,uib.configFolderName) uib.commonFolder = path.join(uib.rootFolder, uib.commonFolderName) //#endregion -------- Constants -------- // //#region ----- back-end debugging ----- // // @ts-ignore log = RED.log log.trace('[uibuilder:Module] ----------------- uibuilder - module started -----------------') //#endregion ----- back-end debugging ----- // //#region ----- Set up uibuilder root, root/.config & root/common folders ----- // /** Check uib root folder: create if needed, writable? * @since v2.0.0 2019-03-03 */ var uib_rootFolder_OK = true // Try to create root and root/.config - ignore error if it already exists try { fs.ensureDirSync(uib.configFolder) // creates both folders } catch (e) { if ( e.code !== 'EEXIST' ) { // ignore folder exists error RED.log.error(`uibuilder: Custom folder ERROR, path: ${uib.rootFolder}. ${e.message}`) uib_rootFolder_OK = false } } // Try to access the root folder (read/write) - if we can, create and serve the common resource folder try { fs.accessSync( uib.rootFolder, fs.constants.R_OK | fs.constants.W_OK ) // try to access read/write } catch (e) { RED.log.error(`uibuilder: Root folder is not accessible, path: ${uib.rootFolder}. ${e.message}`) uib_rootFolder_OK = false } // Assuming all OK, copy over the master .config folder without overwriting (vendor package list, middleware) if (uib_rootFolder_OK === true) { const fsOpts = {'overwrite': false, 'preserveTimestamps':true} try { fs.copySync( path.join( uib.masterTemplateFolder, uib.configFolderName ), uib.configFolder, fsOpts ) } catch (e) { RED.log.error(`uibuilder: Master .config folder copy ERROR, path: ${uib.masterTemplateFolder}. ${e.message}`) uib_rootFolder_OK = false } // and copy the common folder from template (contains the default blue node-red icon) try { fs.copy( path.join( uib.masterTemplateFolder, uib.commonFolderName ), uib.commonFolder, fsOpts, function(err){ if(err){ log.error(`[uibuilder] Error copying common template folder from ${path.join( uib.masterTemplateFolder, uib.commonFolderName)} to ${uib.commonFolder}`, err) } else { log.trace(`[uibuilder] Copied common template folder to local common folder ${uib.commonFolder} (not overwriting)` ) } }) } catch (e) { // should never happen log.error('[uibuilder] COPY OF COMMON FOLDER FAILED') } // It is served up at the instance level to allow caching to be configured. It is used as a static resource folder (added in nodeInstance() so available for each instance as `./common/`) } // If the root folder setup failed, throw an error and give up completely if (uib_rootFolder_OK !== true) { throw new Error(`uibuilder: Failed to set up uibuilder root folder structure correctly. Check log for additional error messages. Root folder: ${uib.rootFolder}.`) } //#endregion ----- root folder ----- // /** We need an ExpressJS web server to serve the page and vendor packages. * @since 2019-02-04 removed httpAdmin - we only want to use httpNode for web pages * @since v2.0.0 2019-02-23 Moved from instance level (nodeInstance()) to module level * @since v3.3.0 2021-03-16 Allow independent ExpressJS server/app */ web.setup(RED, uib, log) // Singleton wrapper for ExpressJS /** Pass core objects to the Socket.IO handler module */ sockets.setup(RED, uib, log, web.server) // Singleton wrapper for Socket.IO /** Serve up vendor packages. Updates uib.installedPackages * This is the first check, the installed packages are rechecked at various times. * Reads the packageList and masterPackageList files * Adds ExpressJS static paths for each found FE package & saves the details to the vendorPaths variable. */ web.checkInstalledPackages() //#region ---- Output startup info to Node-RED log ---- // RED.log.info('+-----------------------------------------------------') RED.log.info(`| ${uib.moduleName} initialised:`) if ( uib.customServer.port ) RED.log.info(`| Using custom ${uib.customServer.type} webserver on port ${uib.customServer.port}`) else RED.log.info('| Using Node-RED\'s webserver') RED.log.info(`| root folder: ${uib.rootFolder}`) RED.log.info(`| version . .: ${uib.version}`) RED.log.info(`| packages . : ${Object.keys(uib.installedPackages)}`) RED.log.info('+-----------------------------------------------------') //#endregion ------------------------------------------ // /** Run the node instance - called from registerType() * type {runtimeNode} * @param {runtimeNodeConfig & uib} config The configuration object passed from the Admin interface (see the matching HTML file) */ function nodeInstance(config) { // Create the node RED.nodes.createNode(this, config) /** @since 2019-02-02 - the current instance name (url) */ var uibInstance = config.url // for logging log.trace(`[uibuilder:${uibInstance}] ================ instance registered ================`) /** Copy 'this' object in case we need it in context of callbacks of other functions. * @type {uibNode} */ // @ts-ignore const node = this log.trace(`[uibuilder:${uibInstance}] = Keys: this, config =`, {'this': Object.keys(node), 'config': Object.keys(config)}) //#region ====== Create local copies of the node configuration (as defined in the .html file) ====== // // NB: node.id and node.type are also available node.name = config.name || '' node.topic = config.topic || '' node.url = config.url || 'uibuilder' node.oldUrl = config.oldUrl node.fwdInMessages = config.fwdInMessages === undefined ? false : config.fwdInMessages node.allowScripts = config.allowScripts === undefined ? false : config.allowScripts node.allowStyles = config.allowStyles === undefined ? false : config.allowStyles node.copyIndex = config.copyIndex === undefined ? true : config.copyIndex node.templateFolder = config.templateFolder || templateConf.vue.folder node.showfolder = config.showfolder === undefined ? false : config.showfolder node.useSecurity = config.useSecurity node.sessionLength = Number(config.sessionLength) || 120 // in seconds node.jwtSecret = node.credentials.jwtSecret || 'thisneedsreplacingwithacredential' node.tokenAutoExtend = config.tokenAutoExtend === undefined ? false : config.tokenAutoExtend node.reload = config.reload === undefined ? false : config.reload //#endregion ====== Local node config copy ====== // //#region ====== Instance logging/audit ====== // log.trace(`[uibuilder:${uibInstance}] Node instance settings`, {'name': node.name, 'topic': node.topic, 'url': node.url, 'copyIndex': node.copyIndex, 'fwdIn': node.fwdInMessages, 'allowScripts': node.allowScripts, 'allowStyles': node.allowStyles, 'showfolder': node.showfolder }) // Keep a log of the active uib.instances @since 2019-02-02 uib.instances[node.id] = node.url log.trace(`[uibuilder:${uibInstance}] Node uib.Instances Registered`, uib.instances) // Keep track of the number of times each instance is deployed. // The initial deployment = 1 if ( Object.prototype.hasOwnProperty.call(uib.deployments, node.id) ) uib.deployments[node.id]++ else uib.deployments[node.id] = 1 log.trace(`[uibuilder:${uibInstance}] Number of uib.Deployments`, uib.deployments[node.id] ) //#endregion ====== Instance logging/audit ====== // //#region ====== Local folder structure ====== // /** Name of the fs path used to hold custom files & folders for THIS INSTANCE of uibuilder * Files in this folder are also served to URL but take preference * over those in the nodes folders (which act as defaults) @type {string} */ node.customFolder = path.join(uib.rootFolder, node.url) // Check whether the url has been changed. If so, rename the folder if ( node.oldUrl !== undefined && node.url !== node.oldUrl ) { // rename (move) folder if possible - but don't overwrite try { fs.moveSync(path.join(uib.rootFolder, node.oldUrl), node.customFolder, {overwrite: false}) } catch (e) { // Not worried if the source doesn't exist - this will regularly happen when changing the name BEFORE first deploy. if ( e.code !== 'ENOENT' ) log.error(`[uibuilder] RENAME OF INSTANCE FOLDER FAILED. Fatal. url=${node.url}, oldUrl=${node.oldUrl}, Fldr=${node.customFolder}. Error=${e.message}`, e) } // we continue to do the normal checks in case something failed or if this is an initial deploy (so no original folder exists) } var customFoldersOK = true // Check if the folder exists and is accessible to Node-RED try { fs.mkdirSync(node.customFolder) fs.accessSync(node.customFolder, fs.constants.W_OK) } catch (e) { if ( e.code !== 'EEXIST' ) { log.error(`[uibuilder:${uibInstance}] Local custom folder ERROR`, e.message) customFoldersOK = false } } // Then make sure the DIST & SRC folders for this node instance exist try { fs.mkdirSync( path.join(node.customFolder, 'dist') ) fs.mkdirSync( path.join(node.customFolder, 'src') ) } catch (e) { if ( e.code !== 'EEXIST' ) { log.error(`[uibuilder:${uibInstance}] Local custom dist or src folder ERROR`, e.message) customFoldersOK = false } } // We've checked that the custom folder is there and has the correct structure if ( uib_rootFolder_OK === true && customFoldersOK === true ) { // local custom folders are there ... log.trace(`[uibuilder:${uibInstance}] Using local front-end folders in`, node.customFolder) /** Now copy folders and files from the master template folder * Don't copy if copy turned off in admin ui * Note that the template folder is stored in node.templSel */ if ( node.copyIndex ) { const cpyOpts = {'overwrite':false, 'preserveTimestamps':true} try { fs.copy( path.join( uib.masterTemplateFolder, node.templateFolder ), node.customFolder, cpyOpts, function(err){ if(err){ log.error(`[uibuilder:${uibInstance}] Error copying template files from ${path.join( __dirname, 'templates', node.templateFolder)} to ${node.customFolder} Error=${err.message}`, err) } else { log.trace(`[uibuilder:${uibInstance}] Copied template files from ${path.join( __dirname, 'templates', node.templateFolder)} to local src (not overwriting)`, node.customFolder ) } }) } catch (e) { // Should never happen log.error(`[uibuilder] COPY OF TEMPLATE TO INSTANCE FOLDER FAILED. Fatal. Error=${e.message}`, e) } } } else { // Local custom folders are not right! log.error(`[uibuilder:${uibInstance}] Wanted to use local front-end folders in ${node.customFolder} but could not`) } //#endregion ====== End of Local folder structure ====== // // Set up web services for this instance (static folders, middleware, etc) web.instanceSetup(node) /** Socket.IO instance configuration. Each deployed instance has it's own namespace @type {Object.ioNameSpace} */ const ioNs = sockets.addNS(node) // NB: Namespace is set from url log.debug(`uibuilder : ${uibInstance} : URL . . . . . : ${tilib.urlJoin( uib.nodeRoot, node.url )}`) log.debug(`uibuilder : ${uibInstance} : Source files . : ${node.customFolder}`) // We only do the following if io is not already assigned (e.g. after a redeploy) uiblib.setNodeStatus( { fill: 'blue', shape: 'dot', text: 'Node Initialised' }, node ) /** Handler function for node flow input events (when a node instance receives a msg from the flow) * @see https://nodered.org/blog/2019/09/20/node-done * @param {Object} msg The msg object received. * @param {function} send Per msg send function, node-red v1+ * @param {function} done Per msg finish function, node-red v1+ **/ function nodeInputHandler(msg, send, done) { log.trace(`[uibuilder:${uibInstance}] nodeInstance:nodeInputHandler - emit received msg - Namespace: ${node.url}`) //debug // If this is pre-1.0, 'send' will be undefined, so fallback to node.send send = send || function() { node.send.apply(node,arguments) } // If this is pre-1.0, 'done' will be undefined, so fallback to dummy function done = done || function() { if (arguments.length>0) node.done.apply(node,arguments) } // If msg is null, nothing will be sent if ( msg !== null ) { // if msg isn't null and isn't an object // NOTE: This is paranoid and shouldn't be possible! if ( typeof msg !== 'object' ) { // Force msg to be an object with payload of original msg msg = { 'payload': msg } } // Add topic from node config if present and not present in msg if ( !(Object.prototype.hasOwnProperty.call(msg, 'topic')) || msg.topic === '' ) { if ( node.topic !== '' ) msg.topic = node.topic else msg.topic = uib.moduleName } } // Keep this fn small for readability so offload any further, more customised code to another fn msg = uiblib.inputHandler(msg, send, done, node, RED, sockets.io, ioNs, log, uib) } // -- end of flow msg received processing -- // // Process inbound messages node.on('input', nodeInputHandler) // Do something when Node-RED is closing down which includes when this node instance is redeployed node.on('close', function(removed,done) { log.trace(`[uibuilder:${uibInstance}] nodeInstance:on-close: ${removed?'Node Removed':'Node (re)deployed'}`) node.removeListener('input', nodeInputHandler) // Do any complex close processing here if needed - MUST BE LAST //processClose(null, node, RED, ioNs, app) // swap with below if needing async //uiblib.processClose(node, RED, uib, ioNs, sockets.io, web.app, log, done) uiblib.instanceClose(node, RED, uib, sockets, web, log, done) done() }) // Shows an instance details debug page RED.httpAdmin.get(`/uibuilder/instance/${node.url}`, function(/** @type {Express.Request} */ req, /** @type {Express.Response} */ res) { let page = uiblib.showInstanceDetails(req, node, uib, userDir, RED) res.status(200).send( page ) }) } // ---- End of nodeInstance (initialised node instance) ---- // /** Register the node by name. This must be called before overriding any of the * Node functions. */ RED.nodes.registerType(uib.moduleName, nodeInstance, { credentials: { jwtSecret: {type:'password'}, }, settings: { uibuilderNodeEnv: { value: process.env.NODE_ENV, exportable: true }, uibuilderTemplates: { value: templateConf, exportable: true }, uibuilderCustomServer: { value: (uib.customServer), exportable: true }, }, }) //#region ====== Admin API's ====== // /** Validate url query parameter * @param {Object} params The GET (res.query) or POST (res.body) parameters * @param {string} params.url The uibuilder url to check * @return {{statusMessage: string, status: number}} */ function chkParamUrl(params) { const res = {'statusMessage': '', 'status': 0} // We have to have a url to work with - the url defines the start folder if ( params.url === undefined ) { res.statusMessage = 'url parameter not provided' res.status = 500 return res } // URL must not exceed 20 characters if ( params.url.length > 20 ) { res.statusMessage = `url parameter is too long. Max 20 characters: ${params.url}` res.status = 500 return res } // URL must be more than 0 characters if ( params.url.length < 1 ) { res.statusMessage = 'url parameter is empty, please provide a value' res.status = 500 return res } // URL cannot contain .. to prevent escaping sub-folder structure if ( params.url.includes('..') ) { res.statusMessage = `url parameter may not contain "..": ${params.url}` res.status = 500 return res } // TODO: Does the url exist? return res } // ---- End of fn chkParamUrl ---- // /** Validate fname (filename) query parameter * @param {Object} params The GET (res.query) or POST (res.body) parameters * @param {string} params.fname The uibuilder url to check * @return {{statusMessage: string, status: number}} */ function chkParamFname(params) { const res = {'statusMessage': '', 'status': 0} const fname = params.fname // We have to have an fname (file name) to work with if ( fname === undefined ) { res.statusMessage = 'file name not provided' res.status = 500 return res } // Blank file name probably means no files available so we will ignore if ( fname === '' ) { res.statusMessage = 'file name cannot be blank' res.status = 500 return res } // fname must not exceed 255 characters if ( fname.length > 255 ) { res.statusMessage = `file name is too long. Max 255 characters: ${params.fname}` res.status = 500 return res } // fname cannot contain .. to prevent escaping sub-folder structure if ( fname.includes('..') ) { res.statusMessage = `file name may not contain "..": ${params.fname}` res.status = 500 return res } return res } // ---- End of fn chkParamFname ---- // /** Validate folder query parameter * @param {Object} params The GET (res.query) or POST (res.body) parameters * @param {string} params.folder The uibuilder url to check * @return {{statusMessage: string, status: number}} */ function chkParamFldr(params) { const res = {'statusMessage': '', 'status': 0} let folder = params.folder //we have to have a folder name if ( folder === undefined ) { res.statusMessage = 'folder name not provided' res.status = 500 return res } // folder name must be >0 in length if ( folder === '' ) { res.statusMessage = 'folder name cannot be blank' res.status = 500 return res } // folder name must not exceed 255 characters if ( folder.length > 255 ) { res.statusMessage = `folder name is too long. Max 255 characters: ${folder}` res.status = 500 return res } // folder name cannot contain .. to prevent escaping sub-folder structure if ( folder.includes('..') ) { res.statusMessage = `folder name may not contain "..": ${folder}` res.status = 500 return res } return res } // ---- End of fn chkParamFldr ---- // //#region ====== Admin API v3 ====== // /** uibuilder v3 unified Admin API router - new API commands should be added here */ RED.httpAdmin.route('/uibuilder/admin/:url') // For all routes .all(function(/** @type {Express.Request} */ req, /** @type {Express.Response} */ res, /** @type {Express.NextFunction} */ next) { // @ts-ignore const params = res.allparams = Object.assign({}, req.query, req.body, req.params) params.type = 'all' //params.headers = req.headers // Validate URL - params.url const chkUrl = chkParamUrl(params) if ( chkUrl.status !== 0 ) { log.error(`[uibuilder:admin-router:ALL] Admin API. ${chkUrl.statusMessage}`) res.statusMessage = chkUrl.statusMessage res.status(chkUrl.status).end() return } next() }) /** Get something and return it */ .get(function(/** @type {Express.Request} */ req, /** @type {Express.Response} */ res) { // @ts-ignore const params = res.allparams params.type = 'get' // List all folders and files for this uibuilder instance if ( params.cmd === 'listall' ) { log.trace(`[uibuilder:admin-router:GET] Admin API. List all folders and files. url=${params.url}, root fldr=${uib.rootFolder}`) // get list of all (sub)folders (follow symlinks as well) const out = {'root':[]} const root2 = uib.rootFolder.replace(/\\/g, '/') fg.stream([`${root2}/${params.url}/**`], { dot: true, onlyFiles: false, deep: 10, followSymbolicLinks: true, markDirectories: true }) .on('data', entry => { entry = entry.replace(`${root2}/${params.url}/`, '') let fldr if ( entry.endsWith('/') ) { // remove trailing / fldr = entry.slice(0, -1) // For the root folder of the instance, use "root" as the name (matches editor processing) if ( fldr === '' ) fldr = 'root' out[fldr] = [] } else { let splitEntry = entry.split('/') let last = splitEntry.pop() fldr = splitEntry.join('/') if ( fldr === '' ) fldr = 'root' out[fldr].push(last) } }) .on('end', () => { res.statusMessage = 'Folders and Files listed successfully' res.status(200).json(out) }) return // -- end of listall -- // } else if ( params.cmd === 'checkurls' ) { log.trace(`[uibuilder:admin-router:GET:checkurls] Check if URL is already in use. URL: ${params.url}`) /** @returns {boolean} True if the given url exists, else false */ let chkInstances = Object.values(uib.instances).includes(params.url) let chkFolders = fs.existsSync(path.join(uib.rootFolder, params.url)) res.statusMessage = 'Instances and Folders checked' res.status(200).json( chkInstances || chkFolders ) return } else if ( params.cmd === 'listurls' ) { // Return a list of all user urls in use by ExpressJS // TODO Not currently working var route, routes = [] web.app._router.stack.forEach( (middleware) => { if(middleware.route){ // routes registered directly on the app let path = middleware.route.path let methods = middleware.route.methods routes.push({path: path, methods: methods}) } else if(middleware.name === 'router'){ // router middleware middleware.handle.stack.forEach(function(handler){ route = handler.route route && routes.push(route) }) } }) console.log(web.app._router.stack[0]) log.trace('[uibuilder:admin-router:GET:listurls] Admin API. List of all user urls in use.') res.statusMessage = 'URLs listed successfully' //res.status(200).json(routes) res.status(200).json(web.app._router.stack) return } }) /** TODO Write file contents */ .put(function(/** @type {Express.Request} */ req, /** @type {Express.Response} */ res) { // @ts-ignore const params = res.allparams params.type = 'put' let fullname = path.join(uib.rootFolder, params.url) // Tell uibuilder to delete the instance local folder when this instance is deleted - see html file oneditdelete & uiblib.processClose if ( params.cmd && params.cmd === 'deleteondelete' ) { log.trace(`[uibuilder:admin-router:PUT:deleteondelete] Admin API. url=${params.url}`) uib.deleteOnDelete[params.url] = true res.statusMessage = 'PUT successfully' res.status(200).json({}) return } log.trace(`[uibuilder:admin-router:PUT] Admin API. url=${params.url}`) res.statusMessage = 'PUT successfully' res.status(200).json({ 'fullname': fullname, 'params': params, }) }) /** Create a new folder or file */ .post(function(/** @type {Express.Request} */ req, /** @type {Express.Response} */ res) { // @ts-ignore const params = res.allparams params.type = 'post' // Validate folder name - params.folder const chkFldr = chkParamFldr(params) if ( chkFldr.status !== 0 ) { log.error(`[uibuilder:admin-router:POST] Admin API. ${chkFldr.statusMessage}. url=${params.url}`) res.statusMessage = chkFldr.statusMessage res.status(chkFldr.status).end() return } // Validate command - must be present and either be 'newfolder' or 'newfile' if ( ! (params.cmd && (params.cmd === 'newfolder' || params.cmd === 'newfile')) ) { let statusMsg = `cmd parameter not present or wrong value (must be 'newfolder' or 'newfile'). url=${params.url}, cmd=${params.cmd}` log.error(`[uibuilder:admin-router:POST] Admin API. ${statusMsg}`) res.statusMessage = statusMsg res.status(500).end() return } // If newfile, validate file name - params.fname if (params.cmd === 'newfile' ) { const chkFname = chkParamFname(params) if ( chkFname.status !== 0 ) { log.error(`[uibuilder:admin-router:POST] Admin API. ${chkFname.statusMessage}. url=${params.url}`) res.statusMessage = chkFname.statusMessage res.status(chkFname.status).end() return } } let fullname = path.join(uib.rootFolder, params.url, params.folder) if (params.cmd === 'newfile' ) { fullname = path.join(fullname, params.fname) } // Does folder or file already exist? If so, return error if ( fs.pathExistsSync(fullname) ) { let statusMsg = `selected ${params.cmd === 'newfolder' ? 'folder':'file'} already exists. url=${params.url}, cmd=${params.cmd}, folder=${params.folder}` log.error(`[uibuilder:admin-router:POST] Admin API. ${statusMsg}`) res.statusMessage = statusMsg res.status(500).end() return } // try to create folder/file - if fail, return error try { if ( params.cmd === 'newfolder') { fs.ensureDirSync(fullname) } else { fs.ensureFileSync(fullname) } } catch (e) { let statusMsg = `could not create ${params.cmd === 'newfolder' ? 'folder':'file'}. url=${params.url}, cmd=${params.cmd}, folder=${params.folder}, error=${e.message}` log.error(`[uibuilder:admin-router:POST] Admin API. ${statusMsg}`) res.statusMessage = statusMsg res.status(500).end() return } log.trace(`[uibuilder:admin-router:POST] Admin API. Folder/File create SUCCESS. url=${params.url}, file=${params.folder}/${params.fname}`) res.statusMessage = 'Folder/File created successfully' res.status(200).json({ 'fullname': fullname, 'params': params, }) }) /** Delete a folder or a file */ .delete(function(/** @type {Express.Request} */ req, /** @type {Express.Response} */ res) { // @ts-ignore ts(2339) const params = res.allparams params.type = 'delete' // Several command options available: deletefolder, deletefile // deletefolder or deletefile: // Validate folder name - params.folder const chkFldr = chkParamFldr(params) if ( chkFldr.status !== 0 ) { log.error(`[uibuilder:admin-router:DELETE] Admin API. ${chkFldr.statusMessage}. url=${params.url}`) res.statusMessage = chkFldr.statusMessage res.status(chkFldr.status).end() return } // Validate command - must be present and either be 'deletefolder' or 'deletefile' if ( ! (params.cmd && (params.cmd === 'deletefolder' || params.cmd === 'deletefile')) ) { let statusMsg = `cmd parameter not present or wrong value (must be 'deletefolder' or 'deletefile'). url=${params.url}, cmd=${params.cmd}` log.error(`[uibuilder:admin-router:DELETE] Admin API. ${statusMsg}`) res.statusMessage = statusMsg res.status(500).end() return } // If newfile, validate file name - params.fname if (params.cmd === 'deletefile' ) { const chkFname = chkParamFname(params) if ( chkFname.status !== 0 ) { log.error(`[uibuilder:admin-router:DELETE] Admin API. ${chkFname.statusMessage}. url=${params.url}`) res.statusMessage = chkFname.statusMessage res.status(chkFname.status).end() return } } let fullname = path.join(uib.rootFolder, params.url, params.folder) if (params.cmd === 'deletefile' ) { fullname = path.join(fullname, params.fname) } // Does folder or file does not exist? Return error if ( ! fs.pathExistsSync(fullname) ) { let statusMsg = `selected ${params.cmd === 'deletefolder' ? 'folder':'file'} does not exist. url=${params.url}, cmd=${params.cmd}, folder=${params.folder}` log.error(`[uibuilder:admin-router:DELETE] Admin API. ${statusMsg}`) res.statusMessage = statusMsg res.status(500).end() return } // try to create folder/file - if fail, return error try { fs.removeSync(fullname) // deletes both files and folders } catch (e) { let statusMsg = `could not delete ${params.cmd === 'deletefolder' ? 'folder':'file'}. url=${params.url}, cmd=${params.cmd}, folder=${params.folder}, error=${e.message}` log.error(`[uibuilder:admin-router:DELETE] Admin API. ${statusMsg}`) res.statusMessage = statusMsg res.status(500).end() return } log.trace(`[uibuilder:admin-router:DELETE] Admin API. Folder/File delete SUCCESS. url=${params.url}, file=${params.folder}/${params.fname}`) res.statusMessage = 'Folder/File deleted successfully' res.status(200).json({ 'fullname': fullname, 'params': params, }) }) /** @see https://expressjs.com/en/4x/api.html#app.METHOD for other methods * patch, report, search ? */ //#endregion ====== Admin API v3 ====== // /** Create a simple NR admin API to return the content of a file in the `<userLib>/uibuilder/<url>/src` folder * @since 2019-01-27 - Adding the file edit admin ui * @param {string} url The admin api url to create * @param {Object} permissions The permissions required for access * @param {function} cb **/ RED.httpAdmin.get('/uibgetfile', function(/** @type {Express.Request} */ req, /** @type {Express.Response} */ res) { //#region --- Parameter validation --- /** req.query parameters * url * fname * folder */ const params = req.query // @ts-ignore const chkUrl = chkParamUrl(params) if ( chkUrl.status !== 0 ) { log.error(`[uibuilder:uibgetfile] Admin API. ${chkUrl.statusMessage}`) res.statusMessage = chkUrl.statusMessage res.status(chkUrl.status).end() return } // @ts-ignore const chkFname = chkParamFname(params) if ( chkFname.status !== 0 ) { log.error(`[uibuilder:uibgetfile] Admin API. ${chkFname.statusMessage}. url=${params.url}`) res.statusMessage = chkFname.statusMessage res.status(chkFname.status).end() return } // @ts-ignore const chkFldr = chkParamFldr(params) if ( chkFldr.status !== 0 ) { log.error(`[uibuilder:uibgetfile] Admin API. ${chkFldr.statusMessage}. url=${params.url}`) res.statusMessage = chkFldr.statusMessage res.status(chkFldr.status).end() return } //#endregion ---- ---- log.trace(`[uibuilder:uibgetfile] Admin API. File get requested. url=${params.url}, file=${params.folder}/${params.fname}`) if ( params.folder === 'root' ) params.folder = '' // @ts-ignore const filePathRoot = path.join(uib.rootFolder, req.query.url, params.folder) // @ts-ignore const filePath = path.join(filePathRoot, req.query.fname) // Does the file exist? if ( fs.existsSync(filePath) ) { // Send back a plain text response body containing content of the file res.type('text/plain').sendFile( // @ts-ignore req.query.fname, { // Prevent injected relative paths from escaping `src` folder 'root': filePathRoot, // Turn off caching 'lastModified': false, 'cacheControl': false, 'dotfiles': 'allow', } ) } else { log.error(`[uibuilder:uibgetfile] Admin API. File does not exist '${filePath}'. url=${params.url}`) res.statusMessage = 'File does not exist' res.status(500).end() } }) // ---- End of uibgetfile ---- // /** Create a simple NR admin API to UPDATE the content of a file in the `<userLib>/uibuilder/<url>/<folder>` folder * @since 2019-02-04 - Adding the file edit admin ui * @param {string} url The admin api url to create * @param {Object} permissions The permissions required for access (Express middleware) * @param {function} cb **/ RED.httpAdmin.post('/uibputfile', function(/** @type {Express.Request} */ req, /** @type {Express.Response} */ res) { //#region ====== Parameter validation ====== // const params = req.body const chkUrl = chkParamUrl(params) if ( chkUrl.status !== 0 ) { log.error(`[uibuilder:uibputfile] Admin API. ${chkUrl.statusMessage}`) res.statusMessage = chkUrl.statusMessage res.status(chkUrl.status).end() return } const chkFname = chkParamFname(params) if ( chkFname.status !== 0 ) { log.error(`[uibuilder:uibputfile] Admin API. ${chkFname.statusMessage}. url=${params.url}`) res.statusMessage = chkFname.statusMessage res.status(chkFname.status).end() return } const chkFldr = chkParamFldr(params) if ( chkFldr.status !== 0 ) { log.error(`[uibuilder:uibputfile] Admin API. ${chkFldr.statusMessage}. url=${params.url}`) res.statusMessage = chkFldr.statusMessage res.status(chkFldr.status).end() return } //#endregion ====== ====== // log.trace(`[uibuilder:uibputfile] Admin API. File put requested. url=${params.url}, file=${params.folder}/${params.fname}, reload? ${params.reload}`) const fullname = path.join(uib.rootFolder, params.url, params.folder, params.fname) // eslint-disable-next-line no-unused-vars fs.writeFile(fullname, req.body.data, function (err, data) { if (err) { // Send back a response message and code 200 = OK, 500 (Internal Server Error)=Update failed log.error(`[uibuilder:uibputfile] Admin API. File write FAIL. url=${params.url}, file=${params.folder}/${params.fname}`, err) res.statusMessage = err res.status(500).end() } else { // Send back a response message and code 200 = OK, 500 (Internal Server Error)=Update failed log.trace(`[uibuilder:uibputfile] Admin API. File write SUCCESS. url=${params.url}, file=${params.folder}/${params.fname}`) res.statusMessage = 'File written successfully' res.status(200).end() // Reload connected clients if required by sending them a reload msg if ( params.reload ) { sockets.send({ '_uib': { 'reload': true, } }, params.url) } } }) }) // ---- End of uibputfile ---- // /** Create an index web page or JSON return listing all uibuilder endpoints * Also allows confirmation of whether a url is in use ('check' parameter) or a simple list of urls in use. * @since 2019-02-04 v1.1.0-beta6 */ RED.httpAdmin.get('/uibindex', function(/** @type {Express.Request} */ req, /** @type {Express.Response} */ res) { log.trace('[uibindex] User Page/API. List all available uibuilder endpoints') // If using own Express server, correct the URL's const url = new URL(req.headers.referer) url.pathname = '' if (uib.customServer.port) { // @ts-expect-error ts(2322) url.port = uib.customServer.port } const urlPrefix = url.href /** Return full details based on type parameter */ switch (req.query.type) { case 'json': { res.json(uib.instances) break } case 'urls': { res.json(Object.values(uib.instances)) break } // default to 'html' output type default: { //console.log('Expresss 3.x - app.routes: ', app.routes) // Expresss 3.x //console.log('Expresss 3.x with express.router - app.router.stack: ', app.router.stack) // Expresss 3.x with express.router //console.log('Expresss 4.x - app._router.stack: ', app._router.stack) // Expresss 4.x //console.log('Restify - server.router.mounts: ', server.router.mounts) // Restify // Update the uib.vendorPaths master variable