node-red-contrib-uibuilder
Version:
Easily create data-driven web UI's for Node-RED. Single- & Multi-page. Multiple UI's. Work with existing web development workflows or mix and match with no-code/low-code features.
1,005 lines (855 loc) • 67.1 kB
JavaScript
/** Manage ExpressJS on behalf of uibuilder
* Singleton. only 1 instance of this class will ever exist. So it can be used in other modules within Node-RED.
*
* Copyright (c) 2017-2023 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'
/** --- Type Defs ---
* @typedef {import('../../typedefs.js').uibNode} uibNode
* @typedef {import('../../typedefs.js').uibConfig} uibConfig
* @typedef {import('../../typedefs.js').runtimeRED} runtimeRED
* @typedef {import('../../typedefs.js').uibPackageJsonPackage} uibPackageJsonPackage
* @typedef {import('express')} Express
* @typedef {import('express').Request} ExpressRequest
* @typedef {import('express').NextFunction} ExpressNextFunction
* @typedef {import('express').Router} ExpressRouter
*/
const { join, parse } = require('path')
const express = require('express')
const socketjs = require('./socket.js')
// const { getNs } = require('./socket.js') // NO! This gives an error because of incorrect `this` binding
const { getClientId, sortApps } = require('./uiblib')
const { accessSync, existsSync, mkdirSync, fgSync } = require('./fs.js')
const { mylog, urlJoin } = require('./tilib') // dumpReq, mylog
// WARNING: Don't try to deconstruct this, if you do the initial uibPackageJson access fails for some reason
const packageMgt = require('./package-mgt.js')
// Filename for default web page
const defaultPageName = 'index.html'
class UibWeb {
/** PRIVATE Flag to indicate whether setup() has been run (ignore the false eslint error)
* @type {boolean}
*/
#isConfigured = false
/** PRIVATE ExpressJS Router Options */
#routerOptions = { mergeParams: true, caseSensitive: true, }
//#region ---- References to core Node-RED & uibuilder objects ---- //
/** @type {runtimeRED} */
RED
/** @type {uibConfig} Reference link to uibuilder.js global configuration object */
uib
/** Reference to uibuilder's global log functions */
log
//#endregion ---- References to core Node-RED & uibuilder objects ---- //
/** Reference to ExpressJS app instance being used by uibuilder
* Used for all other interactions with Express
* @type {express.Application}
*/
app
/** Reference to ExpressJS server instance being used by uibuilder
* Used to enable the Socket.IO client code to be served to the front-end
*/
server
/** Set which folder to use for the central, static, front-end resources
* in the uibuilder module folders. Services standard images, ico file and fall-back index pages
* @type {string}
*/
masterStatic
/** Holder for node instance routers
* @type {Object<string, express.Router>}
*/
instanceRouters = {}
/** ExpressJS Route Metadata */
routers = { admin: [], user: [], instances: {}, config: {} }
/** Called when class is instantiated */
constructor() {
/** Set up a dummy ExpressJS Middleware Function
* @param {Express.Request} req x
* @param {Express.Response} res x
* @param {ExpressNextFunction} next x
*/
this.dummyMiddleware = function(req, res, next) { next() }
}
/** Assign uibuilder and Node-RED core vars to Class static vars.
* This makes them available wherever this MODULE is require'd.
* Because JS passess objects by REFERENCE, updates to the original
* variables means that these are updated as well.
* @param {uibConfig} uib reference to uibuilder 'global' configuration object
* param {Object} server reference to ExpressJS server being used by uibuilder
*/
setup( uib ) {
if ( !uib ) throw new Error('[uibuilder:web.js:setup] Called without required uib parameter or uib is undefined.')
if ( uib.RED === null ) throw new Error('[uibuilder:web.js:setup] uib.RED is null')
// Prevent setup from being called more than once
if ( this.#isConfigured === true ) {
uib.RED.log.warn('🌐⚠️[uibuilder:web:setup] Setup has already been called, it cannot be called again.')
return
}
const RED = this.RED = uib.RED
this.uib = uib
const log = this.log = uib.RED.log
log.trace('🌐[uibuilder:web:setup] Web setup start')
// Get the actual httpRoot
if ( RED.settings.httpRoot === undefined ) this.uib.httpRoot = ''
else this.uib.httpRoot = RED.settings.httpRoot
// Configure whether custom server will use case sensitive routing - allows override from settings.js
this.#routerOptions.caseSensitive = this.uib.customServer.serverOptions['case sensitive routing']
this.routers.config = { httpRoot: this.uib.httpRoot, httpAdminRoot: this.RED.settings.httpAdminRoot }
// At this point we have the refs to uib and RED
this.#isConfigured = true
// TODO: Replace _XXX with #XXX once node.js v14 is the minimum supported version
this._adminApiSetup()
this._setMasterStaticFolder()
this._webSetup()
log.trace('🌐[uibuilder:web:setup] Web setup end')
} // --- End of setup() --- //
//#region ==== Setup - these are called AFTER #isConfigured=true ==== //
/** Add routes for uibuilder's admin REST API's */
_adminApiSetup() {
if ( this.#isConfigured !== true ) {
this.log.warn('🌐⚠️[uibuilder:web.js:_adminApiSetup] Cannot run. Setup has not been called.')
return
}
this.adminRouter = express.Router(this.#routerOptions)
/** Serve up the v3 admin apis on /<httpAdminRoot>/uibuilder/admin/ */
this.adminRouterV3 = require('./admin-api-v3')(this.uib, this.log)
this.adminRouter.use('/admin', this.adminRouterV3)
this.routers.admin.push( { name: 'Admin API v3', path: `${this.RED.settings.httpAdminRoot}uibuilder/admin`, desc: 'Consolidated admin APIs used by the uibuilder Editor panel', type: 'Router' } )
/** Serve up the package docs folder on /<httpAdminRoot>/uibuilder/techdocs (uses docsify) - also make available on /uibuilder/docs
* @see https://github.com/TotallyInformation/node-red-contrib-uibuilder/issues/108
*/
const techDocsPath = join(__dirname, '..', '..', 'docs')
this.adminRouter.use('/docs', express.static( techDocsPath, this.uib.staticOpts ) )
this.routers.admin.push( {
name: 'Documentation',
path: `${this.RED.settings.httpAdminRoot}uibuilder/docs`,
desc: 'Documentation website powered by Docsify',
type: 'Static',
folder: techDocsPath
} )
this.adminRouter.use('/techdocs', express.static( techDocsPath, this.uib.staticOpts ) )
this.routers.admin.push( { name: 'Tech Docs', path: `${this.RED.settings.httpAdminRoot}uibuilder/techdocs`, desc: 'Documentation website powered by Docsify', type: 'Static', folder: techDocsPath } )
const docResources = join(techDocsPath, '..', 'front-end')
this.adminRouter.use('/docs/resources', express.static( docResources, this.uib.staticOpts ) )
this.routers.admin.push( {
name: 'Documentation Resources',
path: `${this.RED.settings.httpAdminRoot}uibuilder/front-end`,
desc: 'UIBUILDER front-end resources to support documentation display',
type: 'Static',
folder: docResources
} )
// TODO: Move v2 API's to V3
this.adminRouterV2 = require('./admin-api-v2')(this.uib, this.log)
this.routers.admin.push( { name: 'Admin API v2', path: `${this.RED.settings.httpAdminRoot}uibuilder/*`, desc: 'Various older admin APIs used by the uibuilder Editor panel', type: 'Router' } )
/** Serve up the admin root for uibuilder on /<httpAdminRoot>/uibuilder/ */
this.RED.httpAdmin.use('/uibuilder', this.adminRouter, this.adminRouterV2)
}
/** Add routes for master user-facing APIs */
_userApiSetup() {
if ( this.#isConfigured !== true ) {
this.log.warn('🌐⚠️[uibuilder:web.js:_userApiSetup] Cannot run. Setup has not been called.')
return
}
if (!this.uibRouter) throw new Error('this.uibRouter is undefined')
/** Serve up the v3 admin apis on /<httpAdminRoot>/uibuilder/admin/ */
this.userApiRouter = require('./user-apis')(this.uib, this.log)
// @ts-ignore
this.userApiRouter.myname = 'uibUserApiRouter'
this.uibRouter.use('/api', this.userApiRouter)
this.routers.admin.push( { name: 'User-facing APIs', path: `${this.uib.httpRoot}/uibuilder/api/*`, desc: 'User-facing APIs accessible to any valid user', type: 'Router' } )
this.log.trace(`🌐[uibuilder[:web:serveVendorPackages] Vendor Router created at '${this.uib.httpRoot}/uibuilder/vendor/*.`)
}
/** Set up the appropriate ExpressJS web server references
* @protected
*/
_webSetup() {
if ( this.#isConfigured !== true ) {
this.log.warn('🌐⚠️[uibuilder:web.js:_webSetup] Cannot run. Setup has not been called.')
return
}
// Reference static vars
const uib = this.uib
const RED = this.RED
const log = this.log
log.trace('🌐[uibuilder:web:_webSetup] Configuring ExpressJS')
/** We need an http server to serve the page and vendor packages. The app is used to serve up the Socket.IO client.
* NB: uib.nodeRoot is the root URL path for http-in/out and uibuilder nodes
* Always set to empty string if a dedicated ExpressJS app is required
* Otherwise it is set to RED.settings.httpNodeRoot
*/
if ( uib.customServer.isCustom === true ) {
// For custom server only, Try to find the external LAN IP address of the server
require('dns').lookup(/** @type {string} */ (uib.customServer.hostName), 4, function (err, add) {
if ( err ) {
log.error('🌐🛑[uibuilder:web.js:_websetup] DNS lookup failed.', err)
}
uib.customServer.host = add
log.trace(`🌐[uibuilder[:web:_webSetup] Using custom ExpressJS server at ${uib.customServer.type}://${add}:${uib.customServer.port}`)
})
// Port has been specified & is different to NR's port so create a new instance of express & app
const express = require('express')
this.app = express()
// Ensure sensible processing of JSON and URL encoded data
this.app.use(express.json())
this.app.use(express.urlencoded({ extended: true }))
// Use the Express server options from settings.js uibuilder.serverOptions (if any)
Object.keys(uib.customServer.serverOptions).forEach( key => {
this.app.set(key, uib.customServer.serverOptions[key] )
})
/** Socket.io needs an http(s) server rather than an ExpressJS app
* As we want Socket.io on the same port, we have to create our own server
* Use https if NR itself is doing so, use same certs as NR
* TODO: Switch from https to http/2?
*/
if ( uib.customServer.type === 'https' ) {
// Allow https settings separate from RED.settings.https
if ( RED.settings.uibuilder && RED.settings.uibuilder.https ) {
try {
this.server = require('https').createServer(RED.settings.uibuilder.https, this.app)
} catch (e) {
// Throw error - we don't want to continue if https is needed but we can't create the server
throw new Error(`🌐🛑[uibuilder:web:webSetup:CreateServer]\n\t Cannot create uibuilder custom ExpressJS server.\n\t Check uibuilder.https in settings.js,\n\t make sure the key and cert files exist and are accessible.\n\t ${e.message}\n \n `)
}
} else {
if ( RED.settings.https !== undefined ) { // eslint-disable-line no-lonely-if
this.server = require('https').createServer(RED.settings.https, this.app)
} else {
// Throw error - we don't want to continue if https is needed but we can't create the server
throw new Error('🌐🛑[uibuilder:web:webSetup:CreateServer]\n\t Cannot create uibuilder custom ExpressJS server using NR https settings.\n\t Check https property in settings.js,\n\t make sure the key and cert files exist and are accessible.\n \n ')
}
}
} else {
this.server = require('http').createServer(this.app)
}
// Connect the server to the requested port, domain is the same as Node-RED
this.server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
this.server.close()
log.error(
`🌐🛑[uibuilder:web:webSetup:CreateServer] ERROR: Port ${uib.customServer.port} is already in use. Cannot create uibuilder server, use a different port number and restart Node-RED`
)
} else {
log.error(
`🌐🛑[uibuilder:web:webSetup:CreateServer] ERROR: ExpressJS error. Cannot create uibuilder server. ${err.message}`,
err
)
}
})
this.server.listen(uib.customServer.port, () => {
// uib.customServer.host = this.server.address().address // not very useful. Typically returns `::`
})
} else {
log.trace(`🌐[uibuilder[:web:_webSetup] Using Node-RED ExpressJS server at ${RED.settings.https ? 'https' : 'http'}://${RED.settings.uiHost}:${RED.settings.uiPort}${uib.nodeRoot === '' ? '/' : uib.nodeRoot}`)
// Port not specified (default) so reuse Node-RED's ExpressJS server and app
// @ts-expect-error
this.app = /** @type {express.Application} */ (RED.httpNode) // || RED.httpAdmin
this.server = RED.server
}
if (uib.rootFolder === null) throw new Error('uib.rootFolder is null')
// Set views folder to uibRoot (but only if not overridden in settings)
if ( !uib.customServer.serverOptions.views ) {
this.app.set('views', join(uib.rootFolder, 'views') )
log.trace(`🌐[uibuilder[:web:_webSetup] ExpressJS Views folder set to '${join(uib.rootFolder, 'views')}'`)
} else {
log.trace(`🌐[uibuilder[:web:_webSetup] ExpressJS Views folder is '${uib.customServer.serverOptions.views}'`)
}
// Note: Keep the router vars separate so that they can be used for reporting
// Create Express Router to handle routes on `<httpNodeRoot>/uibuilder/`
this.uibRouter = express.Router(this.#routerOptions)
// Add auto-generated index page to uibRouter showing all uibuilder user app endpoints at `../uibuilder/apps`
this._serveUserUibIndex()
// Add masterStatic to ../uibuilder - serves up front-end/... uib-styles.css, clients, etc...
if ( this.masterStatic !== undefined ) {
this.uibRouter.use( express.static( this.masterStatic, uib.staticOpts ) )
log.trace(`🌐[uibuilder[:web:_webSetup] Master Static Folder '${this.masterStatic}' added to uib router ('_httpNodeRoot_/uibuilder/')`)
}
// Add vendor paths for installed front-end libraries - from `<uibRoot>/package.json`
this.serveVendorPackages()
// Add socket.io client (../uibuilder/vendor/socket.io/socket.io.js)
// this.serveVendorSocketIo()
// Serve the ping endpoint (../uibuilder/ping)
this.servePing()
// TODO: This needs some tweaking to allow the cache settings to change - currently you'd have to restart node-red.
if (uib.commonFolder === null) throw new Error('uib.commonFolder is null')
// Serve up the master common folder (e.g. <httpNodeRoute>/uibuilder/common/)
this.uibRouter.use( urlJoin(uib.commonFolderName), express.static( uib.commonFolder, uib.staticOpts ) )
this.routers.user.push( { name: 'Central Common Resources', path: `${this.uib.httpRoot}/uibuilder/${uib.commonFolderName}/*`, desc: 'Common resource library', type: 'Static', folder: uib.commonFolder } )
// Assign the uibRouter to the ../uibuilder url path
this.app.use( urlJoin(uib.moduleName), this.uibRouter )
} // --- End of webSetup() --- //
/** Return a dynamic index page of all uibuilder user-accessible endpoints on /uibuilder/apps
* param {express.Request} req Express request object
* param {express.Response} res Express response object
* param {Function} next Function to pass to next handler
*/
_serveUserUibIndex() {
this.uibRouter.get('/apps', (req, res, next) => {
// Build the web page
let page = `
<!doctype html><html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>App Index</title>
<link rel="icon" href="../uibuilder/images/node-blue.ico">
<link type="text/css" rel="stylesheet" href="../uibuilder/uib-brand.min.css">
<style>
li > div {
border-left:5px solid var(--surface5);
padding-left: 5px;
}
</style>
</head><body class="uib"><div class="container">
<h1>List of available apps</h1>
<div><ul>
`
if ( Object.keys(this.uib.instances).length === 0 ) {
page += '<p>Instance list not yet ready, please try again</p>'
} else {
for ( let [url, data] of sortApps(Object.entries(this.uib.apps)) ) { // eslint-disable-line prefer-const
const title = data.title.length === 0 ? '' : `: ${data.title}`
const descr = data.descr.length === 0 ? '' : `<div>${data.descr}</div>`
page += `
<li>
<a href="../${url}">${url}${title}</a>${descr}
</li>
`
}
}
page += `
</ul></div>
</div></body></html>
`
res.statusMessage = 'Apps listed'
res.status(200).send( page )
})
// Record this endpoint for use on details page
this.routers.user.push( {
name: 'Apps (Instances)',
path: `${this.uib.httpRoot}/uibuilder/apps`,
desc: 'List of all uibuilder apps (instances)',
type: 'Get',
// folder: uib.commonFolder
} )
} // --- End of serveUserUibIndex --- //
/** Set folder to use for the central, static, front-end resources
* in the uibuilder module folders. Services standard images, ico file and fall-back index pages
* @protected */
_setMasterStaticFolder() {
if ( this.#isConfigured !== true ) {
this.log.warn('🌐⚠️[uibuilder:web.js:_setMasterStaticFolder] Cannot run. Setup has not been called.')
return
}
// Reference static vars
const uib = this.uib
// const RED = this.RED
const log = this.log
try {
accessSync( join(uib.masterStaticFeFolder, defaultPageName), 'r' )
log.trace(`🌐[uibuilder[:web:setMasterStaticFolder] Using master production build folder. '${uib.masterStaticFeFolder}'`)
this.masterStatic = uib.masterStaticFeFolder
} catch (e) {
throw new Error(`setMasterStaticFolder: Cannot serve master production build folder, cannot access to read: '${uib.masterStaticFeFolder}'`)
}
} // --- End of setMasterStaticFolder() --- //
/** Add ExpressJS Routes for all installed packages & ensure <uibRoot>/package.json is up-to-date. */
serveVendorPackages() {
if ( this.#isConfigured !== true ) {
this.log.warn('🌐⚠️[uibuilder:web.js:serveVendorPackages] Cannot run. Setup has not been called.')
return
}
const log = this.log
if (this.uibRouter === undefined) throw new Error('this.uibRouter is undefined')
log.trace('🌐[uibuilder:web:serveVendorPackages] Serve Vendor Packages started')
// Update the <uibRoot>/package.json file & uibPackageJson in case a package was manually installed then node-red restarted
// Get the installed packages from the `<uibRoot>/package.json` file. If it doesn't exist, this will create it.
const pj = packageMgt.uibPackageJson
if (!pj) throw new Error('web.js:serveVendorPackages: pj is undefined or null')
if ( pj.dependencies === undefined ) throw new Error('web.js:serveVendorPackages: pj.dependencies is undefined')
/** Create Express Router to handle routes on `<httpNodeRoot>/uibuilder/vendor/`
* @type {ExpressRouter & {myname?: string}}
*/
this.vendorRouter = express.Router(this.#routerOptions)
this.vendorRouter.myname = 'uibVendorRouter'
// Remove the vendor router if it already exists - we will recreate it. `some` stops once it has found a result
this.uibRouter.stack.some((layer, i, aStack) => {
if ( layer.regexp.toString() === '/^\\/vendor\\/?(?=\\/|$)/i' ) {
aStack.splice(i, 1)
return true
}
return false
} )
this.routers.user.some((entry, i, uRoutes) => {
if ( entry.name === 'Vendor Routes' ) {
uRoutes.splice(i, 1)
return true
}
return false
} )
// Assign the vendorRouter to the ../uibuilder/vendor url path (via uibRouter)
this.uibRouter.use( '/vendor', this.vendorRouter )
this.routers.user.push( { name: 'Vendor Routes', path: `${this.uib.httpRoot}/uibuilder/vendor/*`, desc: 'Front-end libraries are mounted under here', type: 'Router' } )
log.trace(`🌐[uibuilder[:web:serveVendorPackages] Vendor Router created at '${this.uib.httpRoot}/uibuilder/vendor/*.`)
Object.keys(pj.dependencies).forEach( packageName => {
if ( pj.uibuilder === undefined || pj.uibuilder.packages === undefined ) throw new Error('web.js:serveVendorPackages: pj.uibuilder or pj.uibuilder.packages is undefined')
/** @type {uibPackageJsonPackage} */
const pkgDetails = pj.uibuilder.packages[packageName]
if ( this.vendorRouter === undefined ) throw new Error('web.js:serveVendorPackages: this.vendorRouter is undefined')
// We already know something is wrong, report and skip this package
if ( pkgDetails.missing ) {
let probs = ''
if (pkgDetails.problems) probs = pkgDetails.problems.join('.')
log.error(`🌐🛑[uibuilder:web.js:serveVendorPackages] Package "${packageName}" is not actually installed. Remove this package or fix the installation. ${probs}`)
return
}
// Double-check if details missing. We can't mount the folder so skip this one.
if ( pkgDetails.installFolder === undefined || pkgDetails.packageUrl === undefined ) {
log.error(`🌐🛑[uibuilder:web.js:serveVendorPackages] Either installFolder or packageUrl is undefined for package "${packageName}". Remove this package. installFolder="${pkgDetails.installFolder}", packageUrl="${pkgDetails.packageUrl}"`)
return
}
// Add a route for each package to this.vendorRouter
this.vendorRouter.use(
pkgDetails.packageUrl,
express.static(
pkgDetails.installFolder,
this.uib.staticOpts
)
)
log.trace(`🌐[uibuilder[:web:serveVendorPackages] Vendor Route added for '${packageName}'. Fldr: '${pkgDetails.installFolder}', URL: '${this.uib.httpRoot}/uibuilder/vendor/${pkgDetails.packageUrl}/'. `)
})
log.trace('🌐[uibuilder:web:serveVendorPackages] Serve Vendor Packages end')
} // ---- End of serveVendorPackages ---- //
/** Add the ping endpoint to /uibuilder/ping
* This just returns a 201 (No Content) response and can be used for a keepalive process from the client.
*/
servePing() {
if (this.uibRouter === undefined) throw new Error('this.uibRouter is undefined')
this.uibRouter.get('/ping', (req, res) => {
res.status(204).end()
})
this.routers.user.push( { name: 'Ping', path: `${this.uib.httpRoot}/uibuilder/ping`, desc: 'Ping/keep-alive endpoint, returns 201', type: 'Endpoint' } )
}
//#endregion ==== End of Setup ==== //
/** Allow the isConfigured flag to be read (not written) externally
* @returns {boolean} True if this class as been configured
*/
get isConfigured() {
return this.#isConfigured
}
//#region ====== Per-node instance processing ====== //
/** Remove an ExpressJS router from the stack
* @param {string} url The url of the router to remove
*/
removeRouter(url) {
this.app._router.stack.forEach( (route, i, routes) => {
if (route.regexp.toString() === `/^\\/${url}\\/?(?=\\/|$)/i` ) {
routes.splice(i, 1)
}
})
} // ---- End of removeRouter ---- //
/** *️⃣ Setup the web resources for a specific uibuilder instance
* @param {uibNode} node Reference to the uibuilder node instance
*/
instanceSetup(node) {
if (this.uib.RED === null) throw new Error('this.uib.RED is null')
this.uib.RED.log.trace(`🌐[uibuilder[:web.js:instanceSetup] Setup for URL: ${node.url}`)
// Reference static vars
const uib = this.uib
// const RED = this.RED
const log = this.log
// NOTE: When an instance is renamed or deleted, the routes are removed
// See the relevant parts of uibuilder.js for details.
// Reset the routes for this instance
this.routers.instances[node.url] = []
this.removeRouter(node.url)
/** Make sure that the common static folder is only loaded once */
node.commonStaticLoaded = false
// Create router for this node instance
this.instanceRouters[node.url] = express.Router(this.#routerOptions)
this.routers.instances[node.url].push( { name: 'Instance Rooter', path: `${this.uib.httpRoot}/${node.url}/`, desc: 'Other routes hang off this', type: 'Router', folder: '--' } )
/** We want to add services in the right order - first load takes preference:
* (1) Middleware: (a) common (for all instances), (b) internal (all instances), (c) (if allowed in settings) instance API middleware
* (2) Front-end user code: (a) dynamic templated (*.ejs) & explicit (*.html) from views folder, (b) src or dist static
* (3) Master static folders - for the built-in front-end resources (css, default html, client libraries, etc)
* (4) [Optionally] The folder lister
* (5) Common static folder is last
* TODO Make sure the above is documented in Docs
*/
// (1.) Instance log route (./_clientLog)
this.addBeaconRoute(node)
// (1a) httpMiddleware - Optional common middleware from a custom file (same for all instances)
this.addMiddlewareFile(node)
// (1b) masterMiddleware - uib's internal dynamic middleware to add uibuilder specific headers & cookie
this.addMasterMiddleware(node)
// (1c) Add user-provided API middleware
if (uib.instanceApiAllowed === true ) this.addInstanceApiRouter(node)
else log.trace(`🌐[uibuilder[:webjs:instanceSetup] Instance API's not permitted. '${node.url}'`)
// ! IN-PROGRESS (1d) Add user-provided router middleware
this.addInstanceCustomRoutes(node)
if (uib.rootFolder === null) throw new Error('uib.rootFolder has no value')
const rootFolder = uib.rootFolder
// ! IN PROCRESS - Render views
/** (2a) Render dynamic and explicit template files from views folder
* ! NOTE: If you create a `views/index.html`, you will never reach your actual `src/index.html` (or dist)
* TODO For render - prevent base outside instanceRoot/views/ - https://security.stackexchange.com/a/123723/20102
* TODO Allow views dir to be set in editor
* TODO Allow custom data to be added via Editor and/or msg
* ? TODO give access to global/flow/node vars ? DANGEROUS - needs a list for specific entries instead.
* ? TODO change instance static to optional render
*/
this.instanceRouters[node.url].use( (req, res, next) => {
const pathRoot = join(rootFolder, node.url, 'views')
const requestedView = parse(req.path)
let filePath = join(pathRoot, requestedView.base)
if (this.app.get('view engine')) {
filePath = join(pathRoot, `${requestedView.name}.ejs`)
if (existsSync(filePath)) {
try {
// res.render( join(uib.rootFolder, node.url, 'views', requestedView.name), {foo:'Crunchy', footon: 'bar stool', _env: node.context().global.get('_env')} )
res.render( join(rootFolder, node.url, 'views', requestedView.name), { _env: node.context().global.get('_env') } )
} catch (e) {
res.sendFile( requestedView.base, { root: pathRoot } )
}
return
}
}
return next()
}) // --- End of render views --- //
// (2b) THIS IS THE IMPORTANT ONE - customStatic - Add static route for instance local custom files (src or dist)
this.instanceRouters[node.url].use( this.setupInstanceStatic(node) )
// (3) Master Static - Add static route for uibuilder's built-in front-end code
if ( this.masterStatic !== undefined ) {
this.instanceRouters[node.url].use( express.static( this.masterStatic, uib.staticOpts ) )
this.routers.instances[node.url].push( { name: 'Master Code', path: `${this.uib.httpRoot}/${node.url}/`, desc: 'Built-in FE code, same for all instances', type: 'Static', folder: this.masterStatic } )
}
if (uib.commonFolder === null) throw new Error('uib.commonFolder is null')
// (5) Serve up the uibuilder static common folder on `<httpNodeRoot>/<url>/<commonFolderName>` (it is already available on `<httpNodeRoot>/uibuilder/<commonFolderName>/`, see _webSetup()
this.instanceRouters[node.url].use( urlJoin(uib.commonFolderName), express.static( uib.commonFolder, uib.staticOpts ) )
// Track routes
this.routers.instances[node.url].push( { name: 'Common Code', path: `${this.uib.httpRoot}/${node.url}/common/`, desc: 'Shared FE code, same for all instances', type: 'Static', folder: uib.commonFolder } )
// Apply this instances router to the url path on `<httpNodeRoot>/<url>/`
this.app.use( urlJoin(node.url), this.instanceRouters[node.url])
// this.dumpUserRoutes(true)
// this.dumpInstanceRoutes(true, node.url)
} // --- End of instanceSetup --- //
/** (1a) Optional common middleware from a file (same for all instances)
* @param {uibNode} node Reference to the uibuilder node instance
*/
addMiddlewareFile(node) {
// Reference static vars
const uib = this.uib
// const RED = this.RED
const log = this.log
/** Provide the ability to have a ExpressJS middleware hook.
* This can be used for custom authentication/authorisation or anything else.
*/
if (uib.configFolder === null) throw new Error('uib.configFolder is null')
/** Check for <uibRoot>/.config/uibMiddleware.js, use it if present. Copy template if not exists @since v2.0.0-dev4 */
const uibMwPath = join(uib.configFolder, 'uibMiddleware.js')
try {
const uibMiddleware = require(uibMwPath)
if ( typeof uibMiddleware === 'function' ) {
// ! TODO: Add some more checks in here (e.g. does the function have a next()?)
this.instanceRouters[node.url].use( uibMiddleware )
log.trace(`🌐[uibuilder[:web:addMiddlewareFile:${node.url}] uibuilder common Middleware file loaded. Path: ${uibMwPath}`)
this.routers.instances[node.url].push( { name: 'Common Middleware', path: `${this.uib.httpRoot}/${node.url}/`, desc: 'Optional middleware, same for all instances', type: 'Handler', folder: uibMwPath } )
} else {
log.trace(`🌐[uibuilder[:web:addMiddlewareFile:${node.url}] uibuilder common Middleware file not loaded, not a function. Type: ${typeof uibMiddleware}, Path: ${uibMwPath}`)
}
} catch (e) {
log.trace(`🌐[uibuilder[:web:addMiddlewareFile:${node.url}] uibuilder common Middleware file failed to load. Path: ${uibMwPath}, Reason: ${e.message}`)
}
} // --- End of addMiddlewareFile --- //
/** (1b) Add uib's internal dynamic middleware - adds uibuilder specific headers & cookies
* @param {uibNode} node Reference to the uibuilder node instance
*/
addMasterMiddleware(node) { // eslint-disable-line class-methods-use-this
const uib = this.uib
let mypath
if ( uib.nodeRoot === '' || uib.nodeRoot === '/' ) mypath = `/${node.url}/`
else mypath = `${uib.nodeRoot}${node.url}/`
const qSec = uib.customServer.type === 'https' // true if using https else false
const that = this
/**
* Return a middleware handler
* @param {express.Request} req Express request object
* @param {express.Response} res Express response object
* @param {express.NextFunction} next Express next() function
*/
function masterMiddleware (req, res, next) {
// Check for client id from client - if it exists, reuse it otherwise create one
const clientId = getClientId(req)
// TODO: X-XSS-Protection only needed for html (and js?), not for css, etc
res
// Headers only accessible in the browser via web workers
.header({
// Help reduce risk of XSS and other attacks
'X-XSS-Protection': '1;mode=block',
'X-Content-Type-Options': 'nosniff',
// 'X-Frame-Options': 'SAMEORIGIN',
// Content-Security-Policy': "script-src 'self'",
// Tell the client that uibuilder is being used (overides the default "ExpressJS" entry)
'x-powered-by': 'uibuilder',
// Tell the client what Socket.IO namespace to use,
'uibuilder-namespace': node.url, // only client accessible from xhr or web worker
'uibuilder-node': node.id,
// 'uibuilder-path': mypath,
})
// .links({
// help: '',
// })
.cookie('uibuilder-namespace', node.url, {
path: mypath,
sameSite: true,
// @ts-expect-error
expires: 0, // session cookie only - expires/maxAge
secure: qSec,
})
// Give the client a fixed session id
.cookie('uibuilder-client-id', clientId, {
path: mypath,
sameSite: true,
// @ts-expect-error
expires: 0, // session cookie only - expires/maxAge
secure: qSec,
})
// Tell clients what httpNodeRoot to use (affects Socket.io path)
.cookie('uibuilder-webRoot', uib.nodeRoot.replace(/\//g, ''), {
path: mypath,
sameSite: true,
// @ts-expect-error
expires: 0, // session cookie only - expires/maxAge
secure: qSec,
})
// that.dumpExpressReqAppRes(req, res)
next()
}
this.instanceRouters[node.url].use(masterMiddleware )
// Track routes
that.routers.instances[node.url].push( { name: 'uib Internal Middleware', path: `${that.uib.httpRoot}/${node.url}/`, desc: 'Master middleware, same for all instances', type: 'Handler', folder: '(internal)' } )
} // --- End of addMasterMiddleware --- //
/** (2) Front-end code is mounted here - Add static ExpressJS route for an instance local resource files
* Called on startup but may also be called if user changes setting Advanced/Serve
* @param {uibNode} node Reference to the uibuilder node instance
* @returns {express.RequestHandler} serveStatic for the folder containing the front-end code
*/
setupInstanceStatic(node) {
// Reference static vars
const uib = this.uib
// const RED = this.RED
const log = this.log
let customStatic = node.sourceFolder
// Cope with pre v4.1 node configs (sourceFolder not defined)
if ( node.sourceFolder === undefined ) {
try {
// Check if local dist folder contains an index.html & if NR can read it - fall through to catch if not
accessSync( join(node.customFolder, 'dist', defaultPageName), 'r' )
// If the ./dist/index.html exists use the dist folder...
customStatic = 'dist'
log.trace(`🌐[uibuilder[:web:setupInstanceStatic:${node.url}] Using local dist folder`)
// NOTE: You are expected to have included vendor packages in
// a build process so we are not loading them here
} catch (e) {
// dist not being used or not accessible, use src
log.trace(`🌐[uibuilder[:web:setupInstanceStatic:${node.url}] Dist folder not in use or not accessible. Using local src folder. ${e.message}` )
customStatic = 'src'
}
}
const customFull = join(node.customFolder, customStatic)
// Does the customStatic folder exist? If not, then create it
try {
// With recursive, will create missing parents and does not error if parents already exist
mkdirSync( customFull, { recursive: true } )
// fslib.ensureDirSync( customFull )
log.trace(`🌐[uibuilder[:web:setupInstanceStatic:${node.url}] Using local ${customStatic} folder`)
} catch (e) {
node.warn(`[uibuilder:web:setupInstanceStatic:${node.url}] Cannot create or access ${customFull} folder, no pages can be shown. Error: ${e.message}`)
}
// Does it contain an index.html file? If not, then issue a warn
if ( !existsSync( join(customFull, defaultPageName) ) ) {
node.warn(`[uibuilder:web:setupInstanceStatic:${node.url}] Cannot show default page, index.html does not exist in ${customFull}.`)
}
// Track the route
this.routers.instances[node.url].push( { name: 'Front-end user code', path: `${uib.httpRoot}/${node.url}/`, desc: 'Your own FE Code', type: 'Static', folder: customFull } )
// Return the serveStatic
return express.static( customFull, uib.staticOpts )
} // --- End of setupInstanceStatic --- //
/** Load & return an ExpressJS Router from file(s) in <uibRoot>/<node.url>/api/*.js
* @param {uibNode} node Reference to the uibuilder node instance
* @returns {object|undefined} Valid instance router or undefined
*/
addInstanceApiRouter(node) {
// Reference static vars
const uib = this.uib
// const RED = this.RED
const log = this.log
// Allow all .js files in api folder to be loaded, always returns an array - NB: Fast Glob requires fwd slashes even on Windows
const apiFiles = fgSync(`${uib.rootFolder}/${node.url}/api/*.js`)
apiFiles.forEach( instanceApiPath => {
// Try to require the api module file
let instanceApi
try {
instanceApi = require(instanceApiPath)
} catch (e) {
log.error(`🌐🛑[uibuilder:webjs:addInstanceApiRouter] Could not require instance API file. API not added. '${node.url}', '${instanceApiPath}'. ${e.message}`)
return false
}
// TODO Add to this.routers.instances[node.url]
// if instanceApi is a function, simply .use it on /api
if ( instanceApi && typeof instanceApi === 'function' ) {
log.trace(`🌐[uibuilder[:webjs:addInstanceApiRouter] ${node.url} function api added`)
this.instanceRouters[node.url].use( '/api', instanceApi )
return
}
// Make sure we can understand the contents
let keys
try {
keys = Object.keys(instanceApi)
} catch (e) {
log.error(`🌐🛑[uibuilder:webjs:addInstanceApiRouter] Could not understand API file properties - is it an object? It must be an object or a function, see the docs for details. '${node.url}', '${instanceApiPath}'. ${e.message}`)
return false
}
// allow `path` property - if present, use as api path
let apipath
if ( instanceApi.path ) apipath = instanceApi.path
else apipath = '/api/*'
// allow apiSetup function
if ( instanceApi.apiSetup && typeof instanceApi.apiSetup === 'function' ) {
instanceApi.apiSetup(node, uib)
}
// ! TODO: FIX THIS, IT DOES NOT WORK!
// Each property in the imported object MUST match an ExpressJS method or `use` & must be a function
keys.forEach( fnName => {
if ( fnName === 'path' || fnName === 'apiSetup' ) return // ignore this
// TODO validate verb
if ( typeof instanceApi[fnName] === 'function' ) {
log.trace(`🌐[uibuilder[:webjs:addInstanceApiRouter] ${node.url} api added. ${fnName}, ${apipath}`)
this.instanceRouters[node.url][fnName]( apipath, instanceApi[fnName] )
}
})
})
return undefined
} // ---- End of getInstanceApiRouter ---- //
/** Create instance details web page
* @param {ExpressRequest} req ExpressJS Request object
* @param {uibNode} node configuration data for this instance
* @returns {string} page html
*/
showInstanceDetails(req, node) {
// Reference static vars
const uib = this.uib
const RED = this.RED
const userDir = RED.settings.userDir
// const log = this.log
let page = ''
// If using own Express server, correct the URL's
// if (!req.headers.referer) throw new Error('req.headers.referer does not exist')
let url = {}
try {
url = new URL(req.headers.referer)
} catch (error) { }
url.pathname = ''
// @ts-expect-error
if (uib.customServer && uib.customServer.port && uib.customServer.port != RED.settings.uiPort ) { // eslint-disable-line eqeqeq
// http://127.0.0.1:3001/uibuilder/vendor/bootstrap/dist/css/bootstrap.min.css
// customServer: { port: 3001, type: 'http', host: '::' }
url.port = uib.customServer.port.toString()
}
const urlPrefix = url.href
// let urlRoot = `${urlPrefix}${uib.nodeRoot.replace('/','')}${uib.moduleName}`
const urlRoot = `${urlPrefix}${uib.nodeRoot.replace('/', '')}${node.url}`
const nodeKeys = [
'id', 'type',
'name', 'wires', '_wireCount', 'credentials', 'topic', 'url',
'fwdInMessages', 'allowScripts', 'allowStyles', 'copyIndex', 'showfolder',
// 'useSecurity',
'sessionLength', 'tokenAutoExtend', 'customFolder',
'ioClientsCount', 'rcvMsgCount', 'ioNamespace'
]
const ns = socketjs.getNs(node.url)
page += `
<!doctype html><html lang="en"><head>
<title>uibuilder Instance Debug Page</title>
<link rel="icon" href="${urlRoot}/common/images/node-blue.ico">
<link type="text/css" rel="stylesheet" href="${urlRoot}/uib-brand.min.css" media="screen">
<style type="text/css" rel="stylesheet" media="all">
h2 { border-top:1px solid silver;margin-top:1em;padding-top:0.5em; }
.col3i tbody>tr>:nth-child(3){ font-style:italic; }
</style>
</head><body class="uib"><div class="container">
<h1>uibuilder Instance Debug Page</h1>
<p>
Note that this page is only accessible to users with Node-RED admin authority.
</p>
<h2>Instance Information for '${node.url}'</h2>
<table class="uib-info-tb">
<tbody>
<tr>
<th>The node id for this instance</th>
<td>${node.id}<br>
This can be used to search for the node in the Editor.
</td>
</tr>
<tr>
<th>Filing system path to front-end resources</th>
<td>${node.customFolder}<br>
Contains all of your UI code and other resources.
Folders and files can be viewed, edited, created and deleted using the "Edit Files" button.
You <b>MUST</b> keep at least the <code>src</code> and <code>dist</code> folders otherwise things may not work.
</td>
</tr>
<tr>
<th>URL for the front-end resources</th>
<td><a href="${urlPrefix}${urlJoin(uib.nodeRoot, node.url).replace('/', '')}" target="_blank">.${urlJoin(uib.nodeRoot, node.url)}/</a><br>Index.html page will be shown if you click.</td>
</tr>
<tr>
<th>Node-RED userDir folder</th>
<td>${userDir}<br>
Also the location for any installed vendor resources (installed library packages)
and your other nodes.
</td>
</tr>
<tr>
<th>URL for vendor resources</th>
<td>../uibuilder/vendor/<br>
See the <a href="../../uibindex" target="_blank">Detailed Information Page</a> for more details.
</td>
</tr>
<tr>
<th>Filing system path to common (shared) front-end resources</th>
<td>${uib.commonFolder}<br>
Resource files in this folder are accessible from the main URL.
</td>
</tr>
<tr>
<th>Filing system path to common uibuilder configuration resource files</th>
<td>${uib.configFolder}<br>
Contains the package list, master package list, authentication and authorisation middleware.
</td>
</tr>
<tr>
<th>Filing system path to uibuilder master template files</th>
<td>${uib.masterTemplateFolder}<br>
These are copied to any new instance of the uibuilder node.
If you keep the copy flag turned on they are re-copied if deleted.
</td>
</tr>
<tr>
<th>uibuilder version</th>
<td>${uib.version}</td>
</tr>
<tr>
<th>Node-RED version</th>
<td>${RED.settings.version}<br>
Minimum version required by uibuilder is ${uib.me['node-red'].version}