zwave-js-ui
Version:
Z-Wave Control Panel and MQTT Gateway
1,296 lines (1,295 loc) • 47 kB
JavaScript
"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;