UNPKG

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.

711 lines (618 loc) 31 kB
/** v3 Admin API ExpressJS Router Handler * * See: https://expressjs.com/en/4x/api.html#router, https://expressjs.com/en/guide/routing.html * * Copyright (c) 2021-2024 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').uibConfig} uibConfig */ const express = require('express') const path = require('path') const fg = require('fast-glob') // https://github.com/mrmlnc/fast-glob const fslib = require('./fs') // Utility library for uibuilder const web = require('./web') const sockets = require('./socket') const packageMgt = require('./package-mgt') const templateConf = require('../../templates/template_dependencies') // Template configuration metadata const elements = require('../elements/elements.js') const v3AdminRouter = express.Router() // eslint-disable-line new-cap const errUibRootFldr = new Error('uib.rootFolder is null') //#region === REST API Validation functions === // /** 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 * @returns {{statusMessage: string, status: number}} Status message */ 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 } // Trim the url params.url = params.url.trim() // 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 } // Actually, since uib auto-creates folder if not exists, this just gets in the way - // Does this url have a matching instance root folder? // if ( ! fslib.existsSync(path.join(uib.rootFolder, params.url)) ) { // res.statusMessage = `url does not have a matching instance root folder. url='${params.url}', Master root folder='${uib.rootFolder}'` // res.status = 500 // return res // } 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 * @returns {{statusMessage: string, status: number}} Status message */ 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 * @returns {{statusMessage: string, status: number}} Status message */ function chkParamFldr(params) { const res = { 'statusMessage': '', 'status': 0 } const 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 ---- // //#endregion === End of API validation functions === // /** Get the description & options HTML for an element and return to caller * @param {object} params All parameters from the HTTP call * @param {string} rootFolder uibuilder's root folder * @param {express.Request} req ExpressJS request object * @param {express.Response} res ExpressJS response object */ function doGetOneElement(params, rootFolder, req, res) { const rootPath = [__dirname, '..', 'elements'] let optsHtml = '' let descHtml = '' const availableLangs = ['en-US'] let lang if (params.languages) { lang = params.languages.filter(value => availableLangs.includes(value)) } if (!lang) lang = ['en-US'] rootPath.push(lang[0]) let fname = path.join( ...rootPath, `${params.elType}-options.html`) // Get the content for the advanced settings tab try { optsHtml = fslib.readFileSync(fname, 'utf8') } catch (e) { fname = path.join( ...rootPath, 'default-options.html') optsHtml = fslib.readFileSync(fname, 'utf8') } // Get the description try { fname = path.join( ...rootPath, `${params.elType}-description.html`) descHtml = fslib.readFileSync(fname, 'utf8') } catch (e) { fname = path.join( ...rootPath, 'default-description.html') descHtml = fslib.readFileSync(fname, 'utf8') } res.statusMessage = `No-code element ${params.elType} options returned` res.status(200).json( { descHtml, optsHtml } ) } /** Return a router but allow parameters to be passed in * @param {uibConfig} uib Reference to uibuilder's master uib object * @param {*} log Reference to uibuilder's log functions * @returns {express.Router} The v3 admin API ExpressJS router */ function adminRouterV3(uib, log) { /** uibuilder v3 unified Admin API router - new API commands should be added here * Typical URL is: http://127.0.0.1:1880/red/uibuilder/admin/nodeurl?cmd=listfolders */ v3AdminRouter.route('/:url') // For all routes (this function is called before more specific ones) .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:adminRouterV3: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) { if (uib.rootFolder === null) throw errUibRootFldr // @ts-ignore const params = res.allparams params.type = 'get' // Commands ... switch (params.cmd) { // See if a node's custom folder exists. Return true if it does, else false case 'checkfolder': { log.trace(`🌐[uibuilder[:adminRouterV3:GET:checkfolder] See if a node's custom folder exists. URL: ${params.url}`) const folder = path.join( uib.rootFolder, params.url) fslib.access(folder, fslib.constants.F_OK) .then( () => { res.statusMessage = 'Folder checked' res.status(200).json( true ) return true }) .catch( () => { // err) => { res.statusMessage = 'Folder checked' res.status(200).json( false ) return false }) break } // See if a specific package has been installed into uibRoot (e.g. via library manager) case 'checkpackage': { // We must have a packageName if (!params.packageName) { log.error(`🌐🛑[uibuilder:adminRouterV3:GET] Admin API. cmd=${checkpackage}. 'packageName' parameter not provided. url=${params.url}`) res.statusMessage = 'packageName parameter not provided' res.status(500).end() return } const ans = packageMgt.isPackageInstalled(params.packageName) if (ans === false) { res.statusMessage = 'Package checked - not installed' res.status(200).json( false ) } res.statusMessage = 'Package checked - is installed' res.status(200).json( true ) break } // Check if URL is already in use case 'checkurls': { log.trace(`🌐[uibuilder[:adminRouterV3:GET:checkurls] Check if URL is already in use. URL: ${params.url}`) /** @returns {boolean} True if the given url exists, else false */ const chkInstances = Object.values(uib.instances).includes(params.url) const chkFolders = fslib.existsSync(uib.rootFolder, params.url) res.statusMessage = 'Instances and Folders checked' res.status(200).json( chkInstances || chkFolders ) break } // Get list of all available no-code elements case 'getElements': { res.statusMessage = 'No-code elements list returned' res.status(200).json( elements ) break } // Get details for one specific no-code element case 'getOneElement': { doGetOneElement(params, uib.rootFolder, req, res) break } // List all folders and files for this uibuilder instance case 'listall': { log.trace(`🌐[uibuilder[:adminRouterV3: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( [ // '**', // '!node_modules', // '!.git', // '!.vscode', // '!_*', // '!/**/_*/', `${root2}/${params.url}/**`, `!${root2}/${params.url}/node_modules`, `!${root2}/${params.url}/.git`, `!${root2}/${params.url}/.vscode`, `!${root2}/${params.url}/_*`, `!${root2}/${params.url}/**/[_]*`, ], { // cwd: `${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 { const splitEntry = entry.split('/') const last = splitEntry.pop() fldr = splitEntry.join('/') if ( fldr === '' ) fldr = 'root' // Wrap in a try because we can't exclude xxx/_yyyy/som.thing and that seems to crash the push. try { out[fldr].push(last) } catch (e) { /* Nothing needed here */ } } }) .on('end', () => { res.statusMessage = 'Folders and Files listed successfully' res.status(200).json(out) }) break } // List all folders for this uibuilder instance case 'listfolders': { log.trace(`🌐[uibuilder[:adminRouterV3:GET] Admin API. List all folders. url=${params.url}, root fldr=${uib.rootFolder}`) // get list of all (sub)folders (follow symlinks as well) // const out = { 'root': [] } const out = [] const root2 = uib.rootFolder.replace(/\\/g, '/') fg.stream( [ // '**', // '!node_modules', // '!.git', // '!.vscode', // '!_*', // '!/**/_*/', `${root2}/${params.url}/**`, `!${root2}/${params.url}/node_modules`, `!${root2}/${params.url}/.git`, `!${root2}/${params.url}/.vscode`, `!${root2}/${params.url}/_*`, `!${root2}/${params.url}/**/[_]*`, ], { // cwd: `${root2}/${params.url}/`, dot: true, onlyFiles: false, onlyDirectories: true, deep: 10, followSymbolicLinks: true, markDirectories: false, } ) .on('data', entry => { entry = entry.replace(`${root2}/${params.url}/`, '') out.push(entry) }) .on('end', () => { res.statusMessage = 'Folders listed successfully' res.status(200).json(out) }) break } // List all of the deployed instance urls case 'listinstances': { log.trace('🌐[uibuilder:adminRouterV3:GET:listinstances] Returning a list of deployed URLs (instances of uib).') /** @returns {boolean} True if the given url exists, else false */ // let chkInstances = Object.values(uib.instances).includes(params.url) // let chkFolders = fslib.existsSync(path.join(uib.rootFolder, params.url)) res.statusMessage = 'Instances listed' res.status(200).json( uib.instances ) break } // Return a list of all user urls in use by ExpressJS case 'listurls': { // TODO Not currently working let route const routes = [] web.app._router.stack.forEach( (middleware) => { if (middleware.route) { // routes registered directly on the app const path = middleware.route.path const 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) }) } }) log.trace('🌐[uibuilder:adminRouterV3:GET:listurls] Admin API. List of all user urls in use.') res.statusMessage = 'URLs listed successfully' res.status(200).json(web.app._router.stack) break } default: { res.statusMessage = 'cmd parameter missing or incorrect' res.status(500).json( { error: res.statusMessage } ) break } } }) // TODO Write file contents .put(function(/** @type {express.Request} */ req, /** @type {express.Response} */ res) { if (uib.rootFolder === null) throw errUibRootFldr // @ts-expect-error const params = res.allparams params.type = 'put' const fullname = path.join(uib.rootFolder, params.url) // Commands ... switch (params.cmd) { // Tell uibuilder to delete the instance local folder when this instance is deleted - see html file oneditdelete & uiblib.processClose case 'deleteondelete': { log.trace(`🌐[uibuilder[:adminRouterV3:PUT:deleteondelete] url=${params.url}`) uib.deleteOnDelete[params.url] = true res.statusMessage = 'PUT successful' res.status(200).json({}) return } case 'updatepackage': { log.trace(`🌐[uibuilder[:adminRouterV3:PUT:updatepackage] url=${params.url}`) res.statusMessage = 'PUT successful' res.status(200).json({ newVersion: '' }) return } } // If we get here, we've failed log.trace(`🌐[uibuilder:adminRouterV3:PUT] Unsuccessful. command=${params.cmd}, url=${params.url}`) res.statusMessage = 'PUT unsuccessful' res.status(500).json({ 'cmd': params.cmd, 'fullname': fullname, 'params': params, 'message': 'PUT unsuccessful', }) }) // Load new template or Create a new folder or file .post(function(/** @type {express.Request} */ req, /** @type {express.Response} */ res) { if (uib.rootFolder === null) throw errUibRootFldr // @ts-ignore const params = res.allparams params.type = 'post' if ( params.cmd === 'replaceTemplate' ) { fslib.replaceTemplate(params.url, params.template, params.extTemplate, params.cmd, templateConf, uib, log) .then( resp => { res.statusMessage = resp.statusMessage if ( resp.status === 200 ) res.status(200).json(resp.json) else res.status(resp.status).end() // Reload connected clients if required by sending them a reload msg if ( params.reload === 'true' ) { sockets.sendToFe2( { '_uib': { 'reload': true, } }, // @ts-ignore { url: params.url } ) } return true }) .catch( err => { let statusMsg, mystr if ( err.code === 'MISSING_REF' ) { statusMsg = `Degit clone error. CHECK External Template Name. Name='${params.extTemplate}', url=${params.url}, cmd=${params.cmd}. ${err.message}` } else { if ( params.template === 'external' ) mystr = `, ${params.extTemplate}` statusMsg = `Replace template error. ${err.message}. url=${params.url}. ${params.template}${mystr}` } log.error(`🌐🛑[uibuilder:adminapi:POST:replaceTemplate] ${statusMsg}`, err) res.statusMessage = statusMsg res.status(500).end() } ) } else { // Validate folder name - params.folder const chkFldr = chkParamFldr(params) if ( chkFldr.status !== 0 ) { log.error(`🌐🛑[uibuilder:adminRouterV3: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')) ) { const statusMsg = `cmd parameter not present or wrong value (must be 'newfolder' or 'newfile'). url=${params.url}, cmd=${params.cmd}` log.error(`🌐🛑[uibuilder:adminRouterV3: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:adminRouterV3:POST] Admin API. ${chkFname.statusMessage}. url=${params.url}`) res.statusMessage = chkFname.statusMessage res.status(chkFname.status).end() return } } // Fix for Issue #155 - if fldr = root, no folder if ( params.folder === 'root' ) params.folder = '' 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 ( fslib.existsSync(fullname) ) { const statusMsg = `selected ${params.cmd === 'newfolder' ? 'folder' : 'file'} already exists. url=${params.url}, cmd=${params.cmd}, folder=${params.folder}` log.error(`🌐🛑[uibuilder:adminRouterV3: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') { fslib.ensureDirSync(fullname) } else { fslib.ensureFileSync(fullname) } } catch (e) { const statusMsg = `could not create ${params.cmd === 'newfolder' ? 'folder' : 'file'}. url=${params.url}, cmd=${params.cmd}, folder=${params.folder}, error=${e.message}` log.error(`🌐🛑[uibuilder:adminRouterV3:POST] Admin API. ${statusMsg}`) res.statusMessage = statusMsg res.status(500).end() return } log.trace(`🌐[uibuilder:adminRouterV3: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, }) } // end of else }) // --- End of POST processing --- // // Delete a folder or a file .delete(function(/** @type {express.Request} */ req, /** @type {express.Response} */ res) { if (uib.rootFolder === null) throw errUibRootFldr // @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:adminRouterV3: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')) ) { const statusMsg = `cmd parameter not present or wrong value (must be 'deletefolder' or 'deletefile'). url=${params.url}, cmd=${params.cmd}` log.error(`🌐🛑[uibuilder:adminRouterV3: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:adminRouterV3:DELETE] Admin API. ${chkFname.statusMessage}. url=${params.url}`) res.statusMessage = chkFname.statusMessage res.status(chkFname.status).end() return } } // Fix for Issue #155 - if fldr = root, no folder if ( params.folder === 'root' ) params.folder = '' 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 ( !fslib.existsSync(fullname) ) { const statusMsg = `selected ${params.cmd === 'deletefolder' ? 'folder' : 'file'} does not exist. url=${params.url}, cmd=${params.cmd}, folder=${params.folder}` log.error(`🌐🛑[uibuilder:adminRouterV3:DELETE] Admin API. ${statusMsg}`) res.statusMessage = statusMsg res.status(500).end() return } // try to delete folder/file - if fail, return error try { fslib.removeSync(fullname) // deletes both files and folders } catch (e) { const statusMsg = `could not delete ${params.cmd === 'deletefolder' ? 'folder' : 'file'}. url=${params.url}, cmd=${params.cmd}, folder=${params.folder}, error=${e.message}` log.error(`🌐🛑[uibuilder:adminRouterV3:DELETE] Admin API. ${statusMsg}`) res.statusMessage = statusMsg res.status(500).end() return } log.trace(`🌐[uibuilder:adminRouterV3: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 ? */ return v3AdminRouter } module.exports = adminRouterV3