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() };