@frangoteam/fuxa
Version:
Web-based Process Visualization (SCADA/HMI/Dashboard) software
567 lines (504 loc) • 19 kB
JavaScript
const fs = require('fs');
const path = require('path');
const morgan = require('morgan');
const http = require('http');
const https = require('https');
const socketIO = require('socket.io');
const nopt = require("nopt");
const schedule = require('node-schedule');
const paths = require('./paths');
const logger = require('./runtime/logger');
const utils = require('./runtime/utils');
var events = require("./runtime/events").create();
const FUXA = require('./fuxa.js');
const runtime = require('./runtime');
const authJwt = require('./api/jwt-helper');
const express = require('express');
const app = express();
var server;
var settingsFile;
var startTime = new Date();
var knownOpts = {
"help": Boolean,
"port": Number,
"userDir": [path]
};
var shortHands = {
"?": ["--help"],
"p": ["--port"],
"u": ["--userDir"]
};
nopt.invalidHandler = function (k, v, t) {
// TODO: console.log(k,v,t);
}
var parsedArgs = nopt(knownOpts, shortHands, process.argv, 2);
if (parsedArgs.help) {
console.log("FUXA v" + FUXA.version());
console.log("Usage: fuxa [-?] [--port PORT] [--userDir DIR]");
console.log("");
console.log("Options:");
console.log(" -p, --port PORT port to listen on");
console.log(" -u, --userDir DIR use specified user directory");
console.log(" -?, --help show this help");
process.exit();
}
// Define directory
var rootDir = __dirname;
var workDir = path.resolve(process.cwd(), '_appdata');
if (process.env.userDir) {
rootDir = process.env.userDir;
workDir = path.resolve(process.env.userDir, '_appdata');
}
if (parsedArgs.userDir) {
rootDir = parsedArgs.userDir;
workDir = path.resolve(parsedArgs.userDir, '_appdata');
}
if (parsedArgs.env) {
require('./envParams.js');
}
if (!fs.existsSync(workDir)) {
fs.mkdirSync(workDir);
}
// Read app settings
var appSettingsFile = path.join(workDir, 'settings.js');
if (fs.existsSync(appSettingsFile)) {
// _appdata/settings.js exists
settingsFile = appSettingsFile;
} else {
// Not exist, copy from code resource
var defaultSettings = path.join(__dirname, 'settings.default.js');
try {
fs.copyFileSync(defaultSettings, appSettingsFile, fs.constants.COPYFILE_EXCL);
logger.debug('settings.js default created successful!');
} catch (err) {
logger.error(err);
}
settingsFile = appSettingsFile;
}
try {
// load settings and set some app variable
var settings = require(settingsFile);
// check new settings from default and merge if not defined
var defSettings = require(path.join(__dirname, 'settings.default.js'));
if (defSettings.version !== settings.version) {
logger.warn("Settings are outdated. Missing fields have been merged from defaults. Consider reviewing 'settings.json'.");
settings = utils.deepMerge(defSettings, settings);
}
settings.workDir = workDir;
settings.appDir = __dirname;
settings.packageDir = path.resolve(rootDir, '_pkg');
settings.settingsFile = settingsFile;
settings.environment = process.env.NODE_ENV || 'prod';
settings.uploadFileDir = '_upload_files';
settings.imagesFileDir = path.resolve(rootDir, '_images');
settings.widgetsFileDir = path.resolve(rootDir, '_widgets');
settings.reportsDir = path.resolve(rootDir, '_reports');
settings.webcamSnapShotsDir = path.resolve(rootDir, settings.webcamSnapShotsDir);
settings.logDir = path.resolve(rootDir, settings.logDir);
settings.dbDir = path.resolve(rootDir, settings.dbDir || '_db');
} catch (err) {
logger.error('Error loading settings file: ' + settingsFile)
if (err.code == 'MODULE_NOT_FOUND') {
if (err.toString().indexOf(settingsFile) === -1) {
logger.error(err.toString());
}
} else {
logger.error(err);
}
process.exit();
}
// Read user settings
try {
var userSettingsFile = path.join(workDir, 'mysettings.json');
settings.userSettingsFile = userSettingsFile;
if (fs.existsSync(userSettingsFile)) {
var mysettings = JSON.parse(fs.readFileSync(userSettingsFile, 'utf8'));
if (mysettings.language) {
settings.language = mysettings.language;
}
if (mysettings.uiPort) {
settings.uiPort = mysettings.uiPort;
}
if (!utils.isNullOrUndefined(mysettings.secureEnabled)) {
settings.secureEnabled = mysettings.secureEnabled;
if (!settings.tokenExpiresIn) {
settings.tokenExpiresIn = '1h';
}
}
if (!utils.isNullOrUndefined(mysettings.secureOnlyEditor)) {
settings.secureOnlyEditor = mysettings.secureOnlyEditor;
}
if (mysettings.tokenExpiresIn) {
settings.tokenExpiresIn = mysettings.tokenExpiresIn;
}
if (!utils.isNullOrUndefined(mysettings.enableRefreshCookieAuth)) {
settings.enableRefreshCookieAuth = mysettings.enableRefreshCookieAuth;
}
if (mysettings.refreshTokenExpiresIn) {
settings.refreshTokenExpiresIn = mysettings.refreshTokenExpiresIn;
}
if (mysettings.secretCode) {
settings.secretCode = mysettings.secretCode;
}
if (mysettings.smtp) {
settings.smtp = mysettings.smtp;
}
if (mysettings.daqstore) {
settings.daqstore = mysettings.daqstore;
}
if (mysettings.alarms) {
settings.alarms = mysettings.alarms;
}
if (mysettings.logs) {
settings.logs = mysettings.logs;
}
if (!utils.isNullOrUndefined(mysettings.broadcastAll)) {
settings.broadcastAll = mysettings.broadcastAll;
}
if (!utils.isNullOrUndefined(mysettings.logFull)) {
settings.logFull = mysettings.logFull;
}
if (!utils.isNullOrUndefined(mysettings.userRole)) {
settings.userRole = mysettings.userRole;
}
if (!utils.isNullOrUndefined(mysettings.nodeRedEnabled)) {
settings.nodeRedEnabled = mysettings.nodeRedEnabled;
}
if (!utils.isNullOrUndefined(mysettings.nodeRedAuthMode)) {
settings.nodeRedAuthMode = mysettings.nodeRedAuthMode;
}
if (!utils.isNullOrUndefined(mysettings.swaggerEnabled)) {
settings.swaggerEnabled = mysettings.swaggerEnabled;
}
if (mysettings.nodeRedEnabled === true && utils.isNullOrUndefined(mysettings.nodeRedAuthMode)) {
settings.nodeRedAuthMode = 'legacy-open';
}
}
} catch (err) {
logger.error('Error loading user settings file: ' + userSettingsFile)
}
// Ensure secure mode never runs with an empty/static-known JWT secret.
if (settings.secureEnabled && !settings.secretCode) {
settings.secretCode = utils.generateSecretCode();
logger.warn('Generated a random JWT secret in memory because secureEnabled=true and secretCode was missing. Persist it in settings for stable sessions across restarts.');
}
// Check logger
if (!settings.logDir) {
settings.logDir = path.resolve(rootDir, '_logs');
}
if (!fs.existsSync(settings.logDir)) {
fs.mkdirSync(settings.logDir);
}
logger.init(settings);
const version = FUXA.version();
if (version.indexOf('beta') > 0) {
logger.warn('FUXA V.' + version);
} else {
logger.info('FUXA V.' + version);
}
// Check storage Database dir
if (!fs.existsSync(settings.dbDir)) {
fs.mkdirSync(settings.dbDir);
}
// Check package folder
if (!fs.existsSync(settings.packageDir)) {
fs.mkdirSync(settings.packageDir);
}
// Check reports folder
if (!fs.existsSync(settings.reportsDir)) {
fs.mkdirSync(settings.reportsDir);
}
// Check upload file folder
settings.httpUploadFileStatic = 'resources';
settings.uploadFileDir = path.resolve(workDir, settings.uploadFileDir);
if (!fs.existsSync(settings.uploadFileDir)) {
fs.mkdirSync(settings.uploadFileDir);
}
// Check images resources folder
if (!fs.existsSync(settings.imagesFileDir)) {
fs.mkdirSync(settings.imagesFileDir);
}
// Check widgets resources folder
if (!fs.existsSync(settings.widgetsFileDir)) {
fs.mkdirSync(settings.widgetsFileDir);
}
// Check webcam shots folder
if (!fs.existsSync(settings.webcamSnapShotsDir)) {
fs.mkdirSync(settings.webcamSnapShotsDir);
}
// Server settings
if (settings.https) {
server = https.createServer(settings.https, app);
} else {
server = http.createServer(app);
}
server.setMaxListeners(0);
const io = socketIO(server, {
pingInterval: 60000, // send ping interval
pingTimeout: 120000, // close connection if pong is not received
allowEIO3: true, //Whether to enable compatibility with Socket.IO v2 clients.
cors: {
origin: "*",
methods: ["GET", "POST"],
credentials: false
}
});
// Check settings value
var www = path.resolve(__dirname, '../client/dist');
if (!fs.existsSync(www)) { // compatibility with docker/npm/electron
www = path.resolve(__dirname, './dist');
}
settings.httpStatic = settings.httpStatic || www;
if (parsedArgs.port !== undefined) {
settings.uiPort = parsedArgs.port;
} else {
if (settings.uiPort === undefined) {
settings.uiPort = 1881;
}
}
settings.uiHost = settings.uiHost || "0.0.0.0";
// Wait ending initialization
events.once('init-runtime-ok', function () {
logger.info('FUXA init in ' + utils.endTime(startTime) + 'ms.');
startFuxa();
initWebcamSnapshotCleanup();
});
// Init FUXA
try {
FUXA.init(server, io, settings, logger, events);
} catch (err) {
if (err.code == 'unsupported_version') {
logger.error('Unsupported version of node.js:', process.version);
logger.error('FUXA requires node.js v6 or later');
} else if (err.code == 'not_built') {
logger.error('FUXA has not been built. See README.md for details');
} else {
logger.error('Failed to start server:');
if (err.stack) {
logger.error(err.stack);
} else {
logger.error(err);
}
}
process.exit(1);
}
// Http Server for client UI
const allowCrossDomain = function (req, res, next) {
const origin = req.headers.origin;
const allowedOrigins = settings.allowedOrigins || ["*"];
const isOriginAllowed = (origin) => {
if (!origin) return false;
if (allowedOrigins.includes("*")) return true;
// Convert wildcard-style strings to regex
return allowedOrigins.some(pattern => {
if (!pattern.includes("*")) return pattern === origin;
// Escape dots and replace * with regex
const regexPattern = new RegExp(
"^" + pattern.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$"
);
return regexPattern.test(origin);
});
};
if (isOriginAllowed(origin)) {
res.header('Access-Control-Allow-Origin', origin || '*');
if (settings.enableRefreshCookieAuth) {
res.header('Access-Control-Allow-Credentials', 'true');
}
}
res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS');
res.header('Access-Control-Allow-Headers', 'x-access-token, x-auth-user, Origin, Content-Type, Accept, Skip-Auth, Skip-Error');
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
};
app.use(allowCrossDomain);
app.use('/', express.static(settings.httpStatic));
app.use('/home', express.static(settings.httpStatic));
app.use('/home/:viewName', express.static(settings.httpStatic));
app.use('/lab', express.static(settings.httpStatic));
app.use('/editor', express.static(settings.httpStatic));
app.use('/device', express.static(settings.httpStatic));
app.use('/rodevice', express.static(settings.httpStatic));
app.use('/users', express.static(settings.httpStatic));
app.use('/view', express.static(settings.httpStatic));
app.use('/' + settings.httpUploadFileStatic, express.static(settings.uploadFileDir));
app.use('/_images', express.static(settings.imagesFileDir));
app.use('/_widgets', express.static(settings.widgetsFileDir));
app.use('/snapshots', express.static(settings.webcamSnapShotsDir))
var accessLogStream = fs.createWriteStream(settings.logDir + '/api.log', { flags: 'a' });
if (runtime.settings.logApiLevel !== 'none') {
app.use(morgan('combined', {
stream: accessLogStream,
skip: function (req, res) { return res.statusCode < 400 }
}));
app.use(morgan('dev', {
skip: function (req, res) {
return res.statusCode < 400
}, stream: process.stderr
}));
app.use(morgan('dev', {
skip: function (req, res) {
return res.statusCode >= 400
}, stream: process.stdout
}));
}
function mountSwaggerIfEnabled() {
const swaggerEnabled = settings.swagger || settings.swaggerEnabled;
if (!swaggerEnabled) return;
let swaggerUi;
let YAML;
try {
swaggerUi = require('swagger-ui-express');
YAML = require('yamljs');
} catch (err) {
if (err && err.code !== 'MODULE_NOT_FOUND') {
throw err;
}
logger.warn('[Swagger] Enabled but optional dependencies are missing; skipping /api-docs. Install swagger-ui-express and yamljs to enable it.');
return;
}
const swaggerDocument = YAML.load(path.join(__dirname, 'docs', 'openapi.yaml'));
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
}
// Swagger API Docs (mounted on main app so it isn't intercepted by optional integrations)
try {
mountSwaggerIfEnabled();
} catch (err) {
logger.warn('Swagger UI failed to initialize', err);
}
// app.get('/', function (req, res) {
// res.sendFile('/index.html');
// try {
// var ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
// logger.info("Client connected: " + ip);
// } catch (err) {
// }
// })
// set api to listen
// if (settings.disableServer !== false) {
// app.use('/', FUXA.httpApi);
// }
function getListenPath() {
var port = settings.serverPort;
if (port === undefined) {
port = settings.uiPort;
}
var listenPath = 'http' + (settings.https ? 's' : '') + '://' +
(settings.uiHost == '::' ? 'localhost' : (settings.uiHost == '0.0.0.0' ? '127.0.0.1' : settings.uiHost)) +
':' + port;
if (settings.httpStatic) {
listenPath += '/';
}
return listenPath;
}
let mountNodeRedIfInstalled;
if (settings.nodeRedEnabled) {
({ mountNodeRedIfInstalled } = require('./integrations/node-red'));
}
// Start FUXA
function startFuxa() {
FUXA.start().then(async () => {
if (settings.httpStatic) {
server.on('error', function (err) {
if (err.errno === 'EADDRINUSE') {
logger.error('server.port-in-use');
logger.error('server.unable-to-listen ', { listenpath: getListenPath() });
} else {
if (err.stack) {
logger.error(err.stack);
} else {
logger.error('server.error ' + err);
}
}
process.exit(1);
});
// Mount Node-RED if present; never block FUXA if it fails
if (settings.nodeRedEnabled && typeof mountNodeRedIfInstalled === 'function') {
try {
await mountNodeRedIfInstalled({ app, server, settings, runtime, logger, authJwt, events });
} catch (e) {
logger.warn('[Node-RED] Failed to initialize, continuing without it.', e);
}
} else if (settings.nodeRedEnabled) {
logger.warn('[Node-RED] Enabled but integration not available; continuing without it.');
}
if (settings.disableServer !== false) {
app.use('/', FUXA.httpApi);
}
server.listen(settings.uiPort, settings.uiHost, function () {
settings.serverPort = server.address().port;
process.title = 'FUXA';
logger.info('WebServer is running ' + getListenPath());
});
} else {
logger.info('server.headless-mode');
}
}).catch(function (err) {
logger.error('server.failed-to-start');
if (err) {
if (err.stack) {
logger.error(err.stack);
} else {
logger.error(err);
}
}
});
}
const initWebcamSnapshotCleanup = () => {
if (!settings.webcamSnapShotsCleanup) {
return;
}
schedule.scheduleJob('0 1 * * *', cleanupSnapShotsFiles);
logger.info('Scheduled webcam snapshot cleanup at 01:00 daily.');
};
/**
* Cleanup Snapshots Files
* @description start on '0 1 * * *'
*/
const cleanupSnapShotsFiles = async () => {
const { webcamSnapShotsCleanup, webcamSnapShotsDir, webcamSnapShotsRetain } = settings;
if (!webcamSnapShotsCleanup) {
return;
}
try {
const now = Date.now();
const retentionMillis = webcamSnapShotsRetain * 24 * 60 * 60 * 1000;
const files = await fs.promises.readdir(webcamSnapShotsDir);
let deletedCount = 0;
for (const file of files) {
const filePath = path.join(webcamSnapShotsDir, file);
try {
const stat = await fs.promises.stat(filePath);
if (stat.mtime && (now - stat.mtimeMs > retentionMillis)) {
await fs.promises.unlink(filePath);
deletedCount++;
}
} catch (fileErr) {
logger.error(`Failed to process snapshot file: ${filePath}`, fileErr);
}
}
logger.info(`Snapshot cleanup completed. ${deletedCount} old file(s) deleted.`);
} catch (err) {
logger.error('Error during webcam snapshot cleanup', err);
}
};
// Don't wait any more
setTimeout(() => {
events.emit('init-runtime-ok');
}, 60000);
process.on('uncaughtException', function (err) {
if (err.stack) {
logger.error(err.stack);
} else {
logger.error(err);
}
});
process.on('SIGINT', function () {
FUXA.stop().then(function () {
process.exit();
});
logger.info('FUXA end!');
process.exit();
});