You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
523 lines
15 KiB
JavaScript
523 lines
15 KiB
JavaScript
const express = require('express');
|
|
const cors = require('cors');
|
|
const crypto = require('node:crypto');
|
|
const fs = require('node:fs/promises');
|
|
const path = require('node:path');
|
|
const {
|
|
detectMode,
|
|
executeEzoCommand
|
|
} = require('./ezo-command-service');
|
|
const {
|
|
detectAcquisitionMode,
|
|
readRuntimeConfig,
|
|
writeRuntimeConfig
|
|
} = require('./acquisition-service');
|
|
const {
|
|
readNotificationConfig,
|
|
redactNotificationConfig,
|
|
writeNotificationConfig
|
|
} = require('./notification-service');
|
|
|
|
const app = express();
|
|
const PORT = Number(process.env.PORT || 3000);
|
|
const HOST = process.env.HOST || '127.0.0.1';
|
|
const API_AUTH_TOKEN = process.env.API_AUTH_TOKEN || '';
|
|
const API_RATE_LIMIT_WINDOW_MS = Number(process.env.API_RATE_LIMIT_WINDOW_MS || 60000);
|
|
const API_RATE_LIMIT_MAX = Number(process.env.API_RATE_LIMIT_MAX || 60);
|
|
const LOGS_DIRECTORY = process.env.LOGS_DIRECTORY
|
|
? path.resolve(process.env.LOGS_DIRECTORY)
|
|
: path.join(__dirname, '..', 'logs');
|
|
const ALARM_CONFIG_FILE = process.env.ALARM_CONFIG_FILE
|
|
? path.resolve(process.env.ALARM_CONFIG_FILE)
|
|
: path.join(__dirname, '..', 'config', 'alarms.json');
|
|
const HISTORY_FILES = {
|
|
temperature: 'timestamp,value\n',
|
|
ph: 'timestamp,value\n',
|
|
do: 'timestamp,value\n',
|
|
ec: 'timestamp,value\n',
|
|
alarms: 'timestamp,sensor,state,value,message\n'
|
|
};
|
|
const HISTORY_SENSOR_MAP = {
|
|
rtd: { file: 'temperature.csv', name: 'RTD' },
|
|
ph: { file: 'ph.csv', name: 'PH' },
|
|
do: { file: 'do.csv', name: 'DO' },
|
|
ec: { file: 'ec.csv', name: 'EC' }
|
|
};
|
|
const HISTORY_SENSOR_IDS = {
|
|
temperature: 'rtd',
|
|
ph: 'ph',
|
|
do: 'do',
|
|
ec: 'ec'
|
|
};
|
|
const ALARM_SENSOR_IDS = ['temperature', 'ph', 'do', 'ec'];
|
|
const SECURITY_HEADERS = {
|
|
'X-Content-Type-Options': 'nosniff',
|
|
'X-Frame-Options': 'DENY',
|
|
'Referrer-Policy': 'same-origin',
|
|
'Cache-Control': 'no-store'
|
|
};
|
|
const rateLimitBuckets = new Map();
|
|
|
|
function parseAlarmCsv(csvText) {
|
|
return csvText
|
|
.split(/\r?\n/)
|
|
.slice(1)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.map((line) => {
|
|
const columns = parseCsvLine(line);
|
|
|
|
if (columns.length < 5) return null;
|
|
return {
|
|
timestamp: columns[0],
|
|
sensor: columns[1],
|
|
state: columns[2],
|
|
value: columns[3] === '' ? null : Number(columns[3]),
|
|
message: columns[4]
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function parseCsvLine(line) {
|
|
const columns = [];
|
|
let current = '';
|
|
let quoted = false;
|
|
|
|
for (let index = 0; index < line.length; index++) {
|
|
const character = line[index];
|
|
const nextCharacter = line[index + 1];
|
|
|
|
if (character === '"' && quoted && nextCharacter === '"') {
|
|
current += '"';
|
|
index++;
|
|
} else if (character === '"') {
|
|
quoted = !quoted;
|
|
} else if (character === ',' && !quoted) {
|
|
columns.push(current);
|
|
current = '';
|
|
} else {
|
|
current += character;
|
|
}
|
|
}
|
|
|
|
columns.push(current);
|
|
return columns;
|
|
}
|
|
|
|
async function readAlarmConfigFile() {
|
|
const rawConfig = await fs.readFile(ALARM_CONFIG_FILE, 'utf8');
|
|
return JSON.parse(rawConfig);
|
|
}
|
|
|
|
function validateAlarmConfig(rawConfig) {
|
|
if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) {
|
|
throw Object.assign(new Error('Configuracion de alarmas invalida.'), { status: 400 });
|
|
}
|
|
|
|
const normalizedConfig = {};
|
|
|
|
for (const sensorId of ALARM_SENSOR_IDS) {
|
|
const limits = rawConfig[sensorId];
|
|
const min = Number(limits && limits.min);
|
|
const max = Number(limits && limits.max);
|
|
|
|
if (!Number.isFinite(min) || !Number.isFinite(max) || min >= max) {
|
|
throw Object.assign(
|
|
new Error(`Umbrales invalidos para ${sensorId}.`),
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
normalizedConfig[sensorId] = { min, max };
|
|
}
|
|
|
|
return normalizedConfig;
|
|
}
|
|
|
|
async function writeAlarmConfigFile(rawConfig) {
|
|
const config = validateAlarmConfig(rawConfig);
|
|
await fs.mkdir(path.dirname(ALARM_CONFIG_FILE), { recursive: true });
|
|
await fs.writeFile(
|
|
ALARM_CONFIG_FILE,
|
|
`${JSON.stringify(config, null, 4)}\n`,
|
|
'utf8'
|
|
);
|
|
return config;
|
|
}
|
|
|
|
app.use(cors());
|
|
app.use(express.json());
|
|
app.use((req, res, next) => {
|
|
Object.entries(SECURITY_HEADERS).forEach(([name, value]) => {
|
|
res.setHeader(name, value);
|
|
});
|
|
next();
|
|
});
|
|
app.use('/frontend', express.static(path.join(__dirname, '..', 'frontend')));
|
|
app.use('/data', express.static(path.join(__dirname, '..', 'data'), {
|
|
etag: false,
|
|
maxAge: 0
|
|
}));
|
|
app.use('/logs', express.static(LOGS_DIRECTORY, {
|
|
etag: false,
|
|
maxAge: 0
|
|
}));
|
|
app.use('/config', express.static(path.join(__dirname, '..', 'config'), {
|
|
etag: false,
|
|
maxAge: 0
|
|
}));
|
|
app.get('/vendor/chart.js', (req, res) => {
|
|
res.sendFile(path.join(
|
|
__dirname,
|
|
'..',
|
|
'node_modules',
|
|
'chart.js',
|
|
'dist',
|
|
'chart.umd.js'
|
|
));
|
|
});
|
|
app.get('/vendor/xlsx.js', (req, res) => {
|
|
res.sendFile(path.join(
|
|
__dirname,
|
|
'..',
|
|
'node_modules',
|
|
'xlsx',
|
|
'dist',
|
|
'xlsx.full.min.js'
|
|
));
|
|
});
|
|
|
|
app.get('/', (req, res) => {
|
|
res.redirect('/frontend/index.html');
|
|
});
|
|
|
|
function requireApiToken(req, res, next) {
|
|
if (!API_AUTH_TOKEN) {
|
|
return next();
|
|
}
|
|
|
|
const providedToken = req.get('X-API-Token') || '';
|
|
|
|
if (tokensMatch(providedToken, API_AUTH_TOKEN)) {
|
|
return next();
|
|
}
|
|
|
|
return res.status(401).json({
|
|
success: false,
|
|
error: 'Token de API requerido.'
|
|
});
|
|
}
|
|
|
|
function tokensMatch(providedToken, expectedToken) {
|
|
const provided = Buffer.from(String(providedToken));
|
|
const expected = Buffer.from(String(expectedToken));
|
|
|
|
if (provided.length !== expected.length) {
|
|
return false;
|
|
}
|
|
|
|
return crypto.timingSafeEqual(provided, expected);
|
|
}
|
|
|
|
function getRateLimitKey(req) {
|
|
const token = req.get('X-API-Token') || '';
|
|
const identity = token || req.ip || req.socket.remoteAddress || 'unknown';
|
|
|
|
return `${identity}:${req.method}:${req.path}`;
|
|
}
|
|
|
|
function rateLimitCriticalApi(req, res, next) {
|
|
if (API_RATE_LIMIT_MAX <= 0 || API_RATE_LIMIT_WINDOW_MS <= 0) {
|
|
return next();
|
|
}
|
|
|
|
const now = Date.now();
|
|
const key = getRateLimitKey(req);
|
|
const bucket = rateLimitBuckets.get(key) || [];
|
|
const recentRequests = bucket.filter((timestamp) =>
|
|
now - timestamp < API_RATE_LIMIT_WINDOW_MS
|
|
);
|
|
|
|
if (recentRequests.length >= API_RATE_LIMIT_MAX) {
|
|
const retryAfterMs = API_RATE_LIMIT_WINDOW_MS - (now - recentRequests[0]);
|
|
res.setHeader('Retry-After', String(Math.max(1, Math.ceil(retryAfterMs / 1000))));
|
|
rateLimitBuckets.set(key, recentRequests);
|
|
return res.status(429).json({
|
|
success: false,
|
|
error: 'Demasiadas solicitudes. Intente nuevamente en unos segundos.'
|
|
});
|
|
}
|
|
|
|
recentRequests.push(now);
|
|
rateLimitBuckets.set(key, recentRequests);
|
|
return next();
|
|
}
|
|
|
|
app.get('/api/system/ezo', (req, res) => {
|
|
const commandMode = detectMode();
|
|
const acquisitionMode = detectAcquisitionMode();
|
|
|
|
res.json({
|
|
mode: commandMode.mode,
|
|
message: commandMode.message,
|
|
acquisitionMode: acquisitionMode.mode,
|
|
acquisitionMessage: acquisitionMode.message
|
|
});
|
|
});
|
|
|
|
function parseHistoryCsv(csvText, sensorName) {
|
|
return csvText
|
|
.split(/\r?\n/)
|
|
.slice(1)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.map((line) => {
|
|
const separatorIndex = line.indexOf(',');
|
|
|
|
if (separatorIndex < 0) return null;
|
|
const timestamp = line.slice(0, separatorIndex).trim();
|
|
const value = Number(line.slice(separatorIndex + 1).trim());
|
|
|
|
if (!timestamp || !Number.isFinite(value)) return null;
|
|
return { Timestamp: timestamp, Sensor: sensorName, Valor: value };
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
async function readSensorHistory(sensorType) {
|
|
const sensor = HISTORY_SENSOR_MAP[sensorType];
|
|
const csvText = await fs.readFile(
|
|
path.join(LOGS_DIRECTORY, sensor.file),
|
|
'utf8'
|
|
);
|
|
return parseHistoryCsv(csvText, sensor.name);
|
|
}
|
|
|
|
app.get('/api/sensors/:type', async (req, res) => {
|
|
const sensorType = req.params.type.toLowerCase();
|
|
|
|
if (sensorType !== 'all' && !HISTORY_SENSOR_MAP[sensorType]) {
|
|
return res.status(400).json({ error: 'Sensor no válido.' });
|
|
}
|
|
|
|
try {
|
|
if (sensorType === 'all') {
|
|
const runtimeConfig = await readRuntimeConfig();
|
|
const enabledHistorySensors = runtimeConfig.enabledSensors
|
|
.map((sensorId) => HISTORY_SENSOR_IDS[sensorId])
|
|
.filter(Boolean);
|
|
const histories = await Promise.all(
|
|
enabledHistorySensors.map(readSensorHistory)
|
|
);
|
|
return res.json(
|
|
histories.flat().sort((left, right) =>
|
|
String(left.Timestamp).localeCompare(String(right.Timestamp))
|
|
)
|
|
);
|
|
}
|
|
|
|
return res.json(await readSensorHistory(sensorType));
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') return res.json([]);
|
|
console.error('[HISTORY] No se pudo leer el historial:', error);
|
|
return res.status(500).json({
|
|
error: 'No se pudo leer el historial.'
|
|
});
|
|
}
|
|
});
|
|
|
|
app.get('/api/alarms', async (req, res) => {
|
|
try {
|
|
const csvText = await fs.readFile(
|
|
path.join(LOGS_DIRECTORY, 'alarms.csv'),
|
|
'utf8'
|
|
);
|
|
res.json(parseAlarmCsv(csvText));
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') return res.json([]);
|
|
console.error('[ALARMS] No se pudo leer la bitacora:', error);
|
|
return res.status(500).json({
|
|
error: 'No se pudo leer la bitacora de alarmas.'
|
|
});
|
|
}
|
|
});
|
|
|
|
app.post('/api/sensors/:type/command', requireApiToken, rateLimitCriticalApi, async (req, res) => {
|
|
try {
|
|
const result = await executeEzoCommand(
|
|
req.params.type.toLowerCase(),
|
|
req.body.command,
|
|
{ dangerousConfirmed: req.body.dangerousConfirmed === true }
|
|
);
|
|
res.json(result);
|
|
} catch (error) {
|
|
res.status(error.status || 500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
app.get('/api/config/logging', async (req, res) => {
|
|
const config = await readRuntimeConfig();
|
|
res.json({
|
|
success: true,
|
|
rate: config.loggingRateSeconds,
|
|
historyRetentionDays: config.historyRetentionDays,
|
|
enabledSensors: config.enabledSensors
|
|
});
|
|
});
|
|
|
|
app.get('/api/config/alarms', async (req, res) => {
|
|
try {
|
|
res.json({
|
|
success: true,
|
|
thresholds: await readAlarmConfigFile()
|
|
});
|
|
} catch (error) {
|
|
console.error('[CONFIG] No se pudieron leer alarmas:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'No se pudieron leer los umbrales de alarma.'
|
|
});
|
|
}
|
|
});
|
|
|
|
app.post('/api/config/alarms', requireApiToken, rateLimitCriticalApi, async (req, res) => {
|
|
try {
|
|
const thresholds = await writeAlarmConfigFile(req.body.thresholds || req.body);
|
|
console.log('[CONFIG] Umbrales de alarma actualizados.');
|
|
res.json({
|
|
success: true,
|
|
thresholds,
|
|
message: 'Umbrales de alarma actualizados.'
|
|
});
|
|
} catch (error) {
|
|
res.status(error.status || 500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
app.get('/api/config/notifications', async (req, res) => {
|
|
const config = await readNotificationConfig();
|
|
res.json({
|
|
success: true,
|
|
config: redactNotificationConfig(config)
|
|
});
|
|
});
|
|
|
|
app.post('/api/config/notifications', requireApiToken, rateLimitCriticalApi, async (req, res) => {
|
|
try {
|
|
const config = await writeNotificationConfig(req.body.config || req.body);
|
|
console.log('[CONFIG] Notificaciones actualizadas.');
|
|
res.json({
|
|
success: true,
|
|
config: redactNotificationConfig(config),
|
|
message: 'Configuracion de notificaciones actualizada.'
|
|
});
|
|
} catch (error) {
|
|
res.status(error.status || 500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
app.post('/api/config/logging', requireApiToken, rateLimitCriticalApi, async (req, res) => {
|
|
try {
|
|
const config = await writeRuntimeConfig({ rate: req.body.rate });
|
|
console.log(
|
|
`[CONFIG] Frecuencia de adquisición: ${config.loggingRateSeconds}s`
|
|
);
|
|
res.json({
|
|
success: true,
|
|
rate: config.loggingRateSeconds,
|
|
message: 'La frecuencia será aplicada por el recolector en el siguiente ciclo.'
|
|
});
|
|
} catch (error) {
|
|
res.status(error.status || 500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
app.get('/api/config/runtime', async (req, res) => {
|
|
const config = await readRuntimeConfig();
|
|
res.json({
|
|
success: true,
|
|
rate: config.loggingRateSeconds,
|
|
historyRetentionDays: config.historyRetentionDays,
|
|
enabledSensors: config.enabledSensors,
|
|
availableSensors: Object.keys(HISTORY_SENSOR_IDS)
|
|
});
|
|
});
|
|
|
|
app.post('/api/config/runtime', requireApiToken, rateLimitCriticalApi, async (req, res) => {
|
|
try {
|
|
const config = await writeRuntimeConfig({
|
|
rate: req.body.rate,
|
|
historyRetentionDays: req.body.historyRetentionDays,
|
|
enabledSensors: req.body.enabledSensors
|
|
});
|
|
console.log(
|
|
`[CONFIG] Sensores activos: ${config.enabledSensors.join(', ')}`
|
|
);
|
|
res.json({
|
|
success: true,
|
|
rate: config.loggingRateSeconds,
|
|
historyRetentionDays: config.historyRetentionDays,
|
|
enabledSensors: config.enabledSensors,
|
|
message: 'La configuracion sera aplicada por el recolector en el siguiente ciclo.'
|
|
});
|
|
} catch (error) {
|
|
res.status(error.status || 500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
app.post('/api/history/clear', requireApiToken, rateLimitCriticalApi, async (req, res) => {
|
|
try {
|
|
await fs.mkdir(LOGS_DIRECTORY, { recursive: true });
|
|
await Promise.all(
|
|
Object.entries(HISTORY_FILES).map(([name, header]) =>
|
|
fs.writeFile(
|
|
path.join(LOGS_DIRECTORY, `${name}.csv`),
|
|
header,
|
|
'utf8'
|
|
)
|
|
)
|
|
);
|
|
|
|
console.log('[HISTORY] Archivos CSV truncados.');
|
|
res.json({
|
|
success: true,
|
|
message: 'Archivos de registro truncados correctamente.'
|
|
});
|
|
} catch (error) {
|
|
console.error('[HISTORY] No se pudieron truncar los historiales:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'No se pudieron borrar los archivos históricos.'
|
|
});
|
|
}
|
|
});
|
|
|
|
if (require.main === module) {
|
|
app.listen(PORT, HOST, () => {
|
|
console.log(`[API] Backend Node.js activo en http://${HOST}:${PORT}`);
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
app,
|
|
HISTORY_FILES,
|
|
parseHistoryCsv,
|
|
resetRateLimits: () => rateLimitBuckets.clear()
|
|
};
|