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
JavaScript
/* 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