UNPKG

zwave-js-ui

Version:

Z-Wave Control Panel and MQTT Gateway

1,296 lines (1,295 loc) 47 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.startServer = void 0; const express_1 = __importDefault(require("express")); const connect_history_api_fallback_1 = __importDefault(require("connect-history-api-fallback")); const cors_1 = __importDefault(require("cors")); const csurf_1 = __importDefault(require("csurf")); const morgan_1 = __importDefault(require("morgan")); const store_1 = __importDefault(require("./config/store")); const Gateway_1 = __importStar(require("./lib/Gateway")); const jsonStore_1 = __importDefault(require("./lib/jsonStore")); const loggers = __importStar(require("./lib/logger")); const MqttClient_1 = __importDefault(require("./lib/MqttClient")); const SocketManager_1 = __importDefault(require("./lib/SocketManager")); const ZwaveClient_1 = __importDefault(require("./lib/ZwaveClient")); const multer_1 = __importStar(require("multer")); const extract_zip_1 = __importDefault(require("extract-zip")); const server_1 = require("@zwave-js/server"); const archiver_1 = __importDefault(require("archiver")); const express_rate_limit_1 = __importDefault(require("express-rate-limit")); const express_session_1 = __importDefault(require("express-session")); const fs_extra_1 = __importStar(require("fs-extra")); const http_1 = require("http"); const https_1 = require("https"); const jsonwebtoken_1 = __importDefault(require("jsonwebtoken")); const path_1 = __importDefault(require("path")); const session_file_store_1 = __importDefault(require("session-file-store")); const util_1 = require("util"); const zwave_js_1 = require("zwave-js"); const app_1 = require("./config/app"); const CustomPlugin_1 = require("./lib/CustomPlugin"); const SocketEvents_1 = require("./lib/SocketEvents"); const utils = __importStar(require("./lib/utils")); const BackupManager_1 = __importDefault(require("./lib/BackupManager")); const promises_1 = require("fs/promises"); const selfsigned_1 = require("selfsigned"); const ZnifferManager_1 = __importDefault(require("./lib/ZnifferManager")); const core_1 = require("@zwave-js/core"); const createCertificate = (0, util_1.promisify)(selfsigned_1.generate); function multerPromise(m, req, res) { return new Promise((resolve, reject) => { m(req, res, (err) => { if (err) { reject(err); } else { resolve(); } }); }); } const Storage = (0, multer_1.diskStorage)({ async destination(reqD, file, callback) { await (0, fs_extra_1.mkdirp)(app_1.tmpDir); callback(null, app_1.tmpDir); }, filename(reqF, file, callback) { callback(null, file.originalname); }, }); const multerUpload = (0, multer_1.default)({ storage: Storage, }).array('upload', 1); // Field name and max count const FileStore = (0, session_file_store_1.default)(express_session_1.default); const app = (0, express_1.default)(); const logger = loggers.module('App'); const verifyJWT = (0, util_1.promisify)(jsonwebtoken_1.default.verify.bind(jsonwebtoken_1.default)); const storeLimiter = (0, express_rate_limit_1.default)({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, handler: function (req, res) { res.json({ success: false, message: 'Request limit reached. You can make only 100 requests every 15 minutes', }); }, }); const loginLimiter = (0, express_rate_limit_1.default)({ windowMs: 60 * 60 * 1000, // keep in memory for 1 hour max: 5, // start blocking after 5 requests handler: function (req, res) { res.json({ success: false, message: 'Max requests limit reached' }); }, }); const apisLimiter = (0, express_rate_limit_1.default)({ windowMs: 60 * 60 * 1000, // keep in memory for 1 hour max: 500, // start blocking after 500 requests handler: function (req, res) { res.json({ success: false, message: 'Max requests limit reached' }); }, }); function sslDisabled() { return process.env.FORCE_DISABLE_SSL === 'true'; } // apis response codes var RESPONSE_CODES; (function (RESPONSE_CODES) { RESPONSE_CODES["OK"] = "OK"; RESPONSE_CODES["GENERAL_ERROR"] = "General Error"; RESPONSE_CODES["INVALID"] = "Invalid data"; RESPONSE_CODES["AUTH_FAILED"] = "Authentication failed"; RESPONSE_CODES["PERMISSION_ERROR"] = "Insufficient permissions"; })(RESPONSE_CODES || (RESPONSE_CODES = {})); const socketManager = new SocketManager_1.default(); socketManager.authMiddleware = function (socket, next) { if (!isAuthEnabled()) { next(); } else if (socket.handshake.query && socket.handshake.query.token) { jsonwebtoken_1.default.verify(socket.handshake.query.token, app_1.sessionSecret, function (err, decoded) { if (err) return next(new Error('Authentication error')); socket.user = decoded; next(); }); } else { next(new Error('Authentication error')); } }; let gw; // the gateway instance let zniffer; // the zniffer instance const plugins = []; let pluginsRouter; // flag used to prevent multiple restarts while one is already in progress let restarting = false; // ### UTILS /** * Start http/https server and all the manager */ async function startServer(port, host) { let server; const settings = jsonStore_1.default.get(store_1.default.settings); // as the really first thing setup loggers so all logs will go to file if specified in settings setupLogging(settings); const httpsEnabled = process.env.HTTPS || settings?.gateway?.https; if (httpsEnabled) { if (!sslDisabled()) { logger.info('HTTPS is enabled. Loading cert and keys'); const { cert, key } = await loadCertKey(); if (cert && key) { server = (0, https_1.createServer)({ key, cert, rejectUnauthorized: false, }, app); } else { logger.warn('HTTPS is enabled but cert or key cannot be generated. Falling back to HTTP'); } } else { logger.warn('HTTPS enabled but FORCE_DISABLE_SSL env var is set. Falling back to HTTP'); } } if (!server) { server = (0, http_1.createServer)(app); } server.listen(port, host, function () { const addr = server.address(); const bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr?.port; logger.info(`Listening on ${bind}${host ? 'host ' + host : ''} protocol ${httpsEnabled ? 'HTTPS' : 'HTTP'}`); }); server.on('error', function (error) { if (error.syscall !== 'listen') { throw error; } const bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port; // handle specific listen errors with friendly messages switch (error.code) { case 'EACCES': logger.error(bind + ' requires elevated privileges'); process.exit(1); break; case 'EADDRINUSE': logger.error(bind + ' is already in use'); process.exit(1); break; default: throw error; } }); const users = jsonStore_1.default.get(store_1.default.users); if (users.length === 0) { users.push({ username: app_1.defaultUser, passwordHash: await utils.hashPsw(app_1.defaultPsw), }); await jsonStore_1.default.put(store_1.default.users, users); } setupSocket(server); setupInterceptor(); await loadSnippets(); startZniffer(settings.zniffer); await startGateway(settings); } exports.startServer = startServer; const defaultSnippets = []; async function loadSnippets() { const localSnippetsDir = utils.joinPath(false, 'snippets'); await (0, fs_extra_1.mkdirp)(app_1.snippetsDir); const files = await (0, fs_extra_1.readdir)(localSnippetsDir); for (const file of files) { const filePath = path_1.default.join(localSnippetsDir, file); if (await isSnippet(filePath)) { const content = await (0, promises_1.readFile)(filePath, 'utf8'); const name = path_1.default.basename(filePath, '.js'); defaultSnippets.push({ name, content }); } } } async function isSnippet(file) { return (await (0, fs_extra_1.stat)(file)).isFile() && file.endsWith('.js'); } async function getSnippets() { const files = await (0, fs_extra_1.readdir)(app_1.snippetsDir); const snippets = []; for (const file of files) { const filePath = path_1.default.join(app_1.snippetsDir, file); if (await isSnippet(filePath)) { snippets.push({ name: file.replace('.js', ''), content: await (0, promises_1.readFile)(filePath, 'utf8'), }); } } const snippetsCache = gw.zwave?.cacheSnippets ?? []; return [...snippetsCache, ...defaultSnippets, ...snippets]; } /** * Get the `path` param from a request. Throws if the path is not safe */ function getSafePath(req) { let reqPath = typeof req === 'string' ? req : req.query.path; if (typeof reqPath !== 'string') { throw Error('Invalid path'); } reqPath = path_1.default.normalize(reqPath); if (!reqPath.startsWith(app_1.storeDir) || reqPath === app_1.storeDir) { throw Error('Path not allowed'); } return reqPath; } async function loadCertKey() { const certFile = process.env.SSL_CERTIFICATE || utils.joinPath(app_1.storeDir, 'cert.pem'); const keyFile = process.env.SSL_KEY || utils.joinPath(app_1.storeDir, 'key.pem'); let key; let cert; try { cert = await fs_extra_1.default.readFile(certFile, 'utf8'); key = await fs_extra_1.default.readFile(keyFile, 'utf8'); } catch (error) { // noop } if (!cert || !key) { logger.info('Cert and key not found in store, generating fresh new ones...'); try { const result = await createCertificate([], { days: 99999, keySize: 2048, }); key = result.private; cert = result.cert; await fs_extra_1.default.writeFile(utils.joinPath(app_1.storeDir, 'key.pem'), key); await fs_extra_1.default.writeFile(utils.joinPath(app_1.storeDir, 'cert.pem'), cert); logger.info('New cert and key created'); } catch (error) { logger.error('Error creating cert and key for HTTPS', error); } } return { cert, key }; } function setupLogging(settings) { loggers.setupAll(settings ? settings.gateway : null); } async function startGateway(settings) { let mqtt; let zwave; if (isAuthEnabled() && app_1.sessionSecret === 'DEFAULT_SESSION_SECRET_CHANGE_ME') { logger.error('Session secret is the default one. For security reasons you should change it by using SESSION_SECRET env var'); } if (settings.mqtt) { mqtt = new MqttClient_1.default(settings.mqtt); } if (settings.zwave) { zwave = new ZwaveClient_1.default(settings.zwave, socketManager.io); } BackupManager_1.default.init(zwave); gw = new Gateway_1.default(settings.gateway, zwave, mqtt); await gw.start(); const pluginsConfig = settings.gateway?.plugins ?? null; pluginsRouter = express_1.default.Router(); // load custom plugins if (pluginsConfig && Array.isArray(pluginsConfig)) { for (const plugin of pluginsConfig) { try { const pluginName = path_1.default.basename(plugin); const pluginsContext = { zwave, mqtt, app: pluginsRouter, logger: loggers.module(pluginName), }; const instance = (0, CustomPlugin_1.createPlugin)( // eslint-disable-next-line @typescript-eslint/no-var-requires require(plugin), pluginsContext, pluginName); plugins.push(instance); logger.info(`Successfully loaded plugin ${instance.name}`); } catch (error) { logger.error(`Error while loading ${plugin} plugin`, error); } } } restarting = false; } function startZniffer(settings) { if (settings) { zniffer = new ZnifferManager_1.default(settings, socketManager.io); } } async function destroyPlugins() { while (plugins.length > 0) { const instance = plugins.pop(); if (instance && typeof instance.destroy === 'function') { logger.info('Closing plugin ' + instance.name); await instance.destroy(); } } } function setupInterceptor() { // intercept logs and redirect them to socket loggers.logStream.on('data', (chunk) => { socketManager.io.emit(SocketEvents_1.socketEvents.debug, chunk.toString()); }); } async function parseDir(dir) { const toReturn = []; const files = await fs_extra_1.default.readdir(dir); for (const file of files) { try { const entry = { name: path_1.default.basename(file), path: utils.joinPath(dir, file), }; const stats = await fs_extra_1.default.lstat(entry.path); if (stats.isDirectory()) { if (entry.path === process.env.ZWAVEJS_EXTERNAL_CONFIG) { // hide config-db continue; } entry.children = []; sortStore(entry.children); } else { entry.ext = file.split('.').pop(); } entry.size = utils.humanSize(stats.size); toReturn.push(entry); } catch (error) { logger.error(`Error while parsing ${file} in ${dir}`, error); } } sortStore(toReturn); return toReturn; } /** * * Sort children folders first and files after */ function sortStore(store) { return store.sort((a, b) => { if (a.children && !b.children) { return -1; } if (!a.children && b.children) { return 1; } return 0; }); } // ### EXPRESS SETUP logger.info(`Version: ${utils.getVersion()}`); logger.info('Application path:' + utils.getPath(true)); if (process.env.TRUST_PROXY) { app.set('trust proxy', process.env.TRUST_PROXY === 'true' ? true : process.env.TRUST_PROXY); } app.use((0, morgan_1.default)(loggers.disableColors ? 'tiny' : 'dev', { stream: { write: (msg) => logger.info(msg.trimEnd()) }, })); app.use(express_1.default.json({ limit: '50mb' })); app.use(express_1.default.urlencoded({ limit: '50mb', extended: true, parameterLimit: 50000, })); // must be placed before history middleware app.use(function (req, res, next) { if (pluginsRouter !== undefined) { pluginsRouter(req, res, next); } else { next(); } }); app.use((0, connect_history_api_fallback_1.default)({ index: '/', })); // fix back compatibility with old history mode after switching to hash mode const redirectPaths = [ '/control-panel', '/smart-start', '/settings', '/scenes', '/debug', '/store', '/mesh', ]; app.use('/', (req, res, next) => { if (redirectPaths.includes(req.originalUrl)) { // get path when running behind a proxy const path = req.header('X-External-Path')?.replace(/\/$/, '') ?? ''; res.redirect(`${path}/#${req.originalUrl}`); } else { next(); } }); app.use('/', express_1.default.static(utils.joinPath(false, 'dist'))); app.use((0, cors_1.default)({ credentials: true, origin: true })); // enable sessions management app.use((0, express_session_1.default)({ name: 'zwave-js-ui-session', secret: app_1.sessionSecret, resave: false, saveUninitialized: false, store: new FileStore({ path: path_1.default.join(app_1.storeDir, 'sessions'), logFn: (...args) => { // skip ENOENT errors if (args && args.filter((a) => a.indexOf('ENOENT') >= 0).length === 0) { logger.debug(args[0]); } }, }), cookie: { secure: !!process.env.HTTPS || !!process.env.USE_SECURE_COOKIE, httpOnly: true, // prevents cookie to be sent by client javascript maxAge: 24 * 60 * 60 * 1000, // one day }, })); // Node.js CSRF protection middleware. // Requires either a session middleware or cookie-parser to be initialized first. const csrfProtection = (0, csurf_1.default)({ value: (req) => req.csrfToken(), }); // ### SOCKET SETUP // eslint-disable-next-line @typescript-eslint/no-empty-function const noop = () => { }; /** * Binds socketManager to `server` */ function setupSocket(server) { socketManager.bindServer(server); socketManager.io.on('connection', (socket) => { // Server: https://socket.io/docs/v4/server-application-structure/#all-event-handlers-are-registered-in-the-indexjs-file // Client: https://socket.io/docs/v4/client-api/#socketemiteventname-args socket.on(SocketEvents_1.inboundEvents.init, (data, cb = noop) => { let state = {}; if (gw.zwave) { state = gw.zwave.getState(); } if (zniffer) { state.zniffer = zniffer.status(); } cb(state); }); socket.on(SocketEvents_1.inboundEvents.zwave, // eslint-disable-next-line @typescript-eslint/no-misused-promises async (data, cb = noop) => { if (gw.zwave) { if (!data.args) data.args = []; const result = await gw.zwave.callApi(data.api, ...data.args); result.api = data.api; cb(result); } else { cb({ success: false, message: 'Zwave client not connected', }); } }); // eslint-disable-next-line @typescript-eslint/no-misused-promises socket.on(SocketEvents_1.inboundEvents.mqtt, (data, cb = noop) => { logger.info(`Mqtt api call: ${data.api}`); let res, err; try { switch (data.api) { case 'updateNodeTopics': res = gw.updateNodeTopics(data.args[0]); break; case 'removeNodeRetained': res = gw.removeNodeRetained(data.args[0]); break; default: err = `Unknown MQTT api ${data.apiName}`; } } catch (error) { logger.error('Error while calling MQTT api', error); err = error.message; } const result = { success: !err, message: err || 'Success MQTT api call', result: res, api: data.api, }; cb(result); }); // eslint-disable-next-line @typescript-eslint/no-misused-promises socket.on(SocketEvents_1.inboundEvents.hass, async (data, cb = noop) => { logger.info(`Hass api call: ${data.apiName}`); let res, err; try { switch (data.apiName) { case 'delete': res = gw.publishDiscovery(data.device, data.nodeId, { deleteDevice: true, forceUpdate: true, }); break; case 'discover': res = gw.publishDiscovery(data.device, data.nodeId, { deleteDevice: false, forceUpdate: true, }); break; case 'rediscoverNode': res = gw.rediscoverNode(data.nodeId); break; case 'disableDiscovery': res = gw.disableDiscovery(data.nodeId); break; case 'update': res = gw.zwave.updateDevice(data.device, data.nodeId); break; case 'add': res = gw.zwave.addDevice(data.device, data.nodeId); break; case 'store': res = await gw.zwave.storeDevices(data.devices, data.nodeId, data.remove); break; } } catch (error) { logger.error('Error while calling HASS api', error); err = error.message; } const result = { success: !err, message: err || 'Success HASS api call', result: res, api: data.apiName, }; cb(result); }); // eslint-disable-next-line @typescript-eslint/no-misused-promises socket.on(SocketEvents_1.inboundEvents.zniffer, async (data, cb = noop) => { logger.info(`Zniffer api call: ${data.api}`); let res, err; try { switch (data.apiName) { case 'start': res = await zniffer.start(); break; case 'stop': res = await zniffer.stop(); break; case 'clear': res = zniffer.clear(); break; case 'getFrames': res = zniffer.getFrames(); break; case 'setFrequency': res = await zniffer.setFrequency(data.frequency); break; case 'setLRChannelConfig': res = await zniffer.setLRChannelConfig(data.channelConfig); break; case 'saveCaptureToFile': res = await zniffer.saveCaptureToFile(); break; case 'loadCaptureFromBuffer': { const buffer = Buffer.from(data.buffer); res = zniffer.loadCaptureFromBuffer(buffer); break; } default: throw new Error(`Unknown ZNIFFER api ${data.apiName}`); } } catch (error) { logger.error('Error while calling ZNIFFER api', error); err = error.message; } const result = { success: !err, message: err || 'Success ZNIFFER api call', result: res, api: data.apiName, }; cb(result); }); }); // emitted every time a new client connects/disconnects socketManager.on('clients', (event, activeSockets) => { if (event === 'connection' && activeSockets.size === 1) { gw.zwave?.setUserCallbacks(); } else if (event === 'disconnect' && activeSockets.size === 0) { gw.zwave?.removeUserCallbacks(); } }); } // ### APIs function isAuthEnabled() { const settings = jsonStore_1.default.get(store_1.default.settings); return settings.gateway?.authEnabled === true; } async function parseJWT(req) { // if not authenticated check if he has a valid token let token = req.headers['x-access-token'] || req.headers.authorization; // Express headers are auto converted to lowercase token = Array.isArray(token) ? token[0] : token; if (token && token.startsWith('Bearer ')) { // Remove Bearer from string token = token.slice(7, token.length); } // third-party cookies must be allowed in order to work if (!token) { throw Error('Invalid token header'); } const decoded = await verifyJWT(token, app_1.sessionSecret); // Successfully authenticated, token is valid and the user _id of its content // is the same of the current session const users = jsonStore_1.default.get(store_1.default.users); const user = users.find((u) => u.username === decoded.username); if (user) { return user; } else { throw Error('User not found'); } } // middleware to check if user is authenticated async function isAuthenticated(req, res, next) { // if user is authenticated in the session, carry on if (req?.session?.user || !isAuthEnabled()) { return next(); } // third-party cookies must be allowed in order to work try { const user = await parseJWT(req); req.session.user = user; next(); } catch (error) { logger.debug('Authentication failed', error); res.json({ success: false, message: RESPONSE_CODES.GENERAL_ERROR, code: 3, }); } } // logout the user app.get('/api/auth-enabled', apisLimiter, function (req, res) { res.json({ success: true, data: isAuthEnabled() }); }); // api to authenticate user app.post('/api/authenticate', loginLimiter, csrfProtection, async function (req, res) { const token = req.body.token; let user; try { // token auth, mostly used to restore sessions when user refresh the page if (token) { const decoded = await verifyJWT(token, app_1.sessionSecret); // Successfully authenticated, token is valid and the user _id of its content // is the same of the current session const users = jsonStore_1.default.get(store_1.default.users); user = users.find((u) => u.username === decoded.username); } else { // credentials auth const users = jsonStore_1.default.get(store_1.default.users); const username = req.body.username; const password = req.body.password; user = users.find((u) => u.username === username); if (user && !(await utils.verifyPsw(password, user.passwordHash))) { user = null; } } const result = { success: !!user, code: undefined, message: '', user: undefined, }; if (result.success) { // don't edit the original user object, remove the password from jwt payload const userData = Object.assign({}, user); delete userData.passwordHash; const token = jsonwebtoken_1.default.sign(userData, app_1.sessionSecret, { expiresIn: '1d', }); userData.token = token; req.session.user = userData; result.user = userData; loginLimiter.resetKey(req.ip); logger.info(`User ${user.username} logged in successfully from ${req.ip}`); } else { result.code = 3; result.message = RESPONSE_CODES.GENERAL_ERROR; logger.error(`User ${user?.username || req.body.username} failed to login from ${req.ip}: wrong credentials`); } res.json(result); } catch (error) { res.json({ success: false, message: 'Authentication failed', code: 3, }); logger.error(`User ${user?.username || req.body.username} failed to login from ${req.ip}: ${error.message}`); } }); // logout the user app.get('/api/logout', apisLimiter, isAuthenticated, function (req, res) { req.session.destroy((err) => { if (err) { res.json({ success: false, message: err.message }); } else { res.json({ success: true, message: 'User logged out' }); } }); }); // update user password app.put('/api/password', apisLimiter, csrfProtection, isAuthenticated, async function (req, res) { try { const users = jsonStore_1.default.get(store_1.default.users); const user = req.session.user; const oldUser = users.find((u) => u.username === user.username); if (!oldUser) { return res.json({ success: false, message: 'User not found' }); } if (!(await utils.verifyPsw(req.body.current, oldUser.passwordHash))) { return res.json({ success: false, message: 'Current password is wrong', }); } if (req.body.new !== req.body.confirmNew) { return res.json({ success: false, message: "Passwords doesn't match", }); } oldUser.passwordHash = await utils.hashPsw(req.body.new); req.session.user = oldUser; await jsonStore_1.default.put(store_1.default.users, users); res.json({ success: true, message: 'Password updated', user: oldUser, }); } catch (error) { res.json({ success: false, message: 'Error while updating passwords', error: error.message, }); logger.error('Error while updating password', error); } }); app.get('/health', apisLimiter, function (req, res) { let mqtt; let zwave; if (gw) { mqtt = gw.mqtt?.getStatus() ?? false; zwave = gw.zwave?.getStatus().status ?? false; } // if mqtt is disabled, return true. Fixes #469 if (mqtt && typeof mqtt !== 'boolean') { mqtt = mqtt.status || mqtt.config.disabled; } const status = mqtt && zwave; res.status(status ? 200 : 500).send(status ? 'Ok' : 'Error'); }); app.get('/health/:client', apisLimiter, function (req, res) { const client = req.params.client; let status; if (client !== 'zwave' && client !== 'mqtt') { res.status(500).send("Requested client doesn 't exist"); } else { status = gw?.[client]?.getStatus().status ?? false; } res.status(status ? 200 : 500).send(status ? 'Ok' : 'Error'); }); app.get('/version', apisLimiter, function (req, res) { res.json({ appVersion: utils.getVersion(), zwavejs: zwave_js_1.libVersion, zwavejsServer: server_1.serverVersion, }); }); // get settings app.get('/api/settings', apisLimiter, isAuthenticated, async function (req, res) { const allSensors = (0, core_1.getAllSensors)(); const namedScaleGroups = (0, core_1.getAllNamedScaleGroups)(); const scales = []; for (const group of namedScaleGroups) { for (const scale of Object.values(group.scales)) { scales.push({ key: group.name, sensor: group.name, unit: scale.unit, label: scale.label, description: scale.description, }); } } for (const sensor of allSensors) { for (const scale of Object.values(sensor.scales)) { scales.push({ key: sensor.key, sensor: sensor.label, label: scale.label, unit: scale.unit, description: scale.description, }); } } const settings = jsonStore_1.default.get(store_1.default.settings); const data = { success: true, settings, devices: gw?.zwave?.devices ?? {}, serial_ports: [], scales: scales, sslDisabled: sslDisabled(), tz: process.env.TZ, locale: process.env.LOCALE, deprecationWarning: process.env.TAG_NAME === 'zwavejs2mqtt', }; if (process.platform !== 'sunos') { try { data.serial_ports = await zwave_js_1.Driver.enumerateSerialPorts({ local: true, remote: true, }); } catch (error) { logger.error(error); data.serial_ports = []; } res.json(data); } else res.json(data); }); // update settings app.post('/api/settings', apisLimiter, isAuthenticated, async function (req, res) { try { if (restarting) { throw Error('Gateway is restarting, wait a moment before doing another request'); } let settings = req.body; let restartAll = false; let shouldRestartGw = false; let shouldRestartZniffer = false; const actualSettings = jsonStore_1.default.get(store_1.default.settings); // TODO: validate settings using calss-validator // when settings is null consider a force restart if (settings && Object.keys(settings).length > 0) { shouldRestartGw = !utils.deepEqual({ zwave: actualSettings.zwave, gateway: actualSettings.gateway, mqtt: actualSettings.mqtt, }, { zwave: settings.zwave, gateway: settings.gateway, mqtt: settings.mqtt, }); shouldRestartZniffer = !utils.deepEqual(actualSettings.zniffer, settings.zniffer); // nothing changed, consider it a forced restart restartAll = !shouldRestartGw && !shouldRestartZniffer; await jsonStore_1.default.put(store_1.default.settings, settings); } else { restartAll = true; settings = actualSettings; } if (restartAll || shouldRestartGw) { restarting = true; await gw.close(); await destroyPlugins(); // reload loggers settings setupLogging(settings); // restart clients and gateway await startGateway(settings); BackupManager_1.default.init(gw.zwave); } if (restartAll || shouldRestartZniffer) { if (zniffer) { await zniffer.close(); } startZniffer(settings.zniffer); } res.json({ success: true, message: 'Configuration updated successfully', data: settings, }); } catch (error) { restarting = false; logger.error(error); res.json({ success: false, message: error.message }); } }); // update settings app.post('/api/statistics', apisLimiter, isAuthenticated, async function (req, res) { try { if (restarting) { throw Error('Gateway is restarting, wait a moment before doing another request'); } const { enableStatistics } = req.body; const settings = jsonStore_1.default.get(store_1.default.settings) || {}; if (!settings.zwave) { settings.zwave = {}; } settings.zwave.enableStatistics = enableStatistics; settings.zwave.disclaimerVersion = 1; await jsonStore_1.default.put(store_1.default.settings, settings); if (gw && gw.zwave) { if (enableStatistics) { gw.zwave.enableStatistics(); } else { gw.zwave.disableStatistics(); } } res.json({ success: true, enabled: enableStatistics, message: 'Statistics configuration updated successfully', }); } catch (error) { logger.error(error); res.json({ success: false, message: error.message }); } }); // update versions app.post('/api/versions', apisLimiter, isAuthenticated, async function (req, res) { try { const { disableChangelog } = req.body; const settings = jsonStore_1.default.get(store_1.default.settings) || {}; if (!settings.gateway) { settings.gateway = { type: Gateway_1.GatewayType.NAMED, }; settings.gateway.versions = {}; } // update versions to actual ones settings.gateway.versions = { app: utils.pkgJson.version, // don't use getVersion here as it may include commit sha driver: zwave_js_1.libVersion, server: server_1.serverVersion, }; settings.gateway.disableChangelog = disableChangelog; await jsonStore_1.default.put(store_1.default.settings, settings); res.json({ success: true, message: 'Versions updated successfully', }); } catch (error) { logger.error(error); res.json({ success: false, message: error.message }); } }); // get config app.get('/api/exportConfig', apisLimiter, isAuthenticated, function (req, res) { return res.json({ success: true, data: jsonStore_1.default.get(store_1.default.nodes), message: 'Successfully exported nodes JSON configuration', }); }); // import config app.post('/api/importConfig', apisLimiter, isAuthenticated, async function (req, res) { let config = req.body.data; try { if (!gw.zwave) throw Error('Z-Wave client not inited'); // try convert to node object if (Array.isArray(config)) { const parsed = {}; for (let i = 0; i < config.length; i++) { if (config[i]) { parsed[i] = config[i]; } } config = parsed; } for (const nodeId in config) { const node = config[nodeId]; if (!node || typeof node !== 'object') continue; // All API calls expect nodeId to be a number, so convert it here. const nodeIdNumber = Number(nodeId); if (utils.hasProperty(node, 'name')) { await gw.zwave.callApi('setNodeName', nodeIdNumber, node.name || ''); } if (utils.hasProperty(node, 'loc')) { await gw.zwave.callApi('setNodeLocation', nodeIdNumber, node.loc || ''); } if (node.hassDevices) { await gw.zwave.storeDevices(node.hassDevices, nodeIdNumber, false); } } res.json({ success: true, message: 'Configuration imported successfully', }); } catch (error) { logger.error(error.message); return res.json({ success: false, message: error.message }); } }); // if no path provided return all store dir files/folders, otherwise return the file content app.get('/api/store', storeLimiter, isAuthenticated, async function (req, res) { try { let data; if (req.query.path) { const reqPath = getSafePath(req); // lgtm [js/path-injection] let stat = await fs_extra_1.default.lstat(reqPath); // check symlink is secure if (stat.isSymbolicLink()) { const realPath = await (0, promises_1.realpath)(reqPath); getSafePath(realPath); stat = await fs_extra_1.default.lstat(realPath); } if (stat.isFile()) { // lgtm [js/path-injection] data = await fs_extra_1.default.readFile(reqPath, 'utf8'); } else { // read directory // lgtm [js/path-injection] data = await parseDir(reqPath); } } else { data = [ { name: 'store', path: app_1.storeDir, isRoot: true, children: await parseDir(app_1.storeDir), }, ]; } res.json({ success: true, data: data }); } catch (error) { logger.error(error.message); return res.json({ success: false, message: error.message }); } }); app.put('/api/store', storeLimiter, isAuthenticated, async function (req, res) { try { const reqPath = getSafePath(req); const isNew = req.query.isNew === 'true'; const isDirectory = req.query.isDirectory === 'true'; if (!isNew) { // lgtm [js/path-injection] const stat = await fs_extra_1.default.lstat(reqPath); if (!stat.isFile()) { throw Error('Path is not a file'); } } if (!isDirectory) { // lgtm [js/path-injection] await fs_extra_1.default.writeFile(reqPath, req.body.content, 'utf8'); } else { // lgtm [js/path-injection] await fs_extra_1.default.mkdir(reqPath); } res.json({ success: true }); } catch (error) { logger.error(error.message); return res.json({ success: false, message: error.message }); } }); app.delete('/api/store', storeLimiter, isAuthenticated, async function (req, res) { try { const reqPath = getSafePath(req); // lgtm [js/path-injection] await fs_extra_1.default.remove(reqPath); res.json({ success: true }); } catch (error) { logger.error(error.message); return res.json({ success: false, message: error.message }); } }); app.put('/api/store-multi', storeLimiter, isAuthenticated, async function (req, res) { try { const files = req.body.files || []; for (const f of files) { await fs_extra_1.default.remove(f); } res.json({ success: true }); } catch (error) { logger.error(error.message); return res.json({ success: false, message: error.message }); } }); app.post('/api/store-multi', storeLimiter, isAuthenticated, async function (req, res) { const files = req.body.files || []; const archive = (0, archiver_1.default)('zip'); archive.on('error', function (err) { res.status(500).send({ error: err.message, }); }); // on stream closed we can end the request archive.on('end', function () { logger.debug('zip archive ready'); }); // set the archive name res.attachment('zwave-js-ui-store.zip'); res.setHeader('Content-Type', 'application/zip'); // use res as stream so I don't need to create a temp file archive.pipe(res); for (const f of files) { const s = await fs_extra_1.default.lstat(f); const name = f.replace(app_1.storeDir, ''); if (s.isFile()) { archive.file(f, { name }); } else if (s.isSymbolicLink()) { const targetPath = await (0, promises_1.realpath)(f); try { // check path is secure, if so add it as file getSafePath(targetPath); archive.file(targetPath, { name }); } catch (e) { // ignore } } } await archive.finalize(); }); app.get('/api/store/backup', storeLimiter, isAuthenticated, async function (req, res) { try { await jsonStore_1.default.backup(res); } catch (error) { res.status(500).send({ error: error.message, }); } }); app.post('/api/store/upload', storeLimiter, isAuthenticated, async function (req, res) { let file; let isRestore = false; try { // read files from request await multerPromise(multerUpload, req, res); isRestore = req.body.restore === 'true'; const folder = req.body.folder; file = req.files[0]; if (!file || !file.path) { throw Error('No file uploaded'); } if (isRestore) { await (0, extract_zip_1.default)(file.path, { dir: app_1.storeDir }); } else { const destinationPath = getSafePath(path_1.default.join(app_1.storeDir, folder, file.originalname)); await (0, fs_extra_1.move)(file.path, destinationPath); } res.json({ success: true }); } catch (err) { res.json({ success: false, message: err.message }); } if (file && isRestore) { await (0, fs_extra_1.rm)(file.path); } }); app.get('/api/snippet', apisLimiter, async function (req, res) { try { const snippets = await getSnippets(); res.json({ success: true, data: snippets }); } catch (err) { res.json({ success: false, message: err.message }); } }); // catch 404 and forward to error handler app.use(function (req, res, next) { const err = new Error('Not Found'); err.status = 404; next(err); }); // error handler app.use(function (err, req, res) { logger.error(`${req.method} ${req.url} ${err.status} - Error: ${err.message}`); // render the error page res.status(err.status || 500); res.redirect('/'); }); process.removeAllListeners('SIGINT'); async function gracefuShutdown() { logger.warn('Shutdown detected: closing clients...'); try { if (gw) await gw.close(); await destroyPlugins(); } catch (error) { logger.error('Error while closing clients', error); } return process.exit(); } process.on('uncaughtException', (reason) => { const stack = reason.stack || ''; logger.error( // eslint-disable-next-line @typescript-eslint/no-base-to-string `Unhandled Rejection, reason: ${reason}${stack ? `\n${stack}` : ''}`); }); for (const signal of ['SIGINT', 'SIGTERM']) { // eslint-disable-next-line @typescript-eslint/no-misused-promises process.once(signal, gracefuShutdown); } exports.default = app;