require('dotenv').config(); const express = require('express'); const nunjucks = require('nunjucks'); process.on('uncaughtException', (err) => { console.error('Uncaught Exception:', err); }); process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled Rejection at:', promise, 'reason:', reason); }); try { require('nunjucks/src/jinja-compat').call(nunjucks); } catch (e) { console.warn('Jinja compat loader notice:', e.message); } const nunjucksRuntime = require('nunjucks/src/runtime'); const origMemberLookup = nunjucksRuntime.memberLookup; nunjucksRuntime.memberLookup = function(obj, val) { if (obj && val === 'items' && Array.isArray(obj.items)) { return obj.items; } if (obj && val === 'get' && typeof obj.get !== 'function') { return function(key, defaultVal) { return (obj[key] !== undefined && obj[key] !== null) ? obj[key] : defaultVal; }; } const res = origMemberLookup(obj, val); if (typeof res === 'function' && obj && typeof obj[val] === 'function') { Object.assign(res, obj[val]); } return res; }; const cookieParser = require('cookie-parser'); const path = require('path'); const axios = require('axios'); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const app = express(); const port = process.env.PORT || 3000; app.disable('x-powered-by'); app.set('trust proxy', 1); // Blindaje HTTP con Helmet (compatible con despliegues directos por IP y proxies HTTPS) app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false, crossOriginOpenerPolicy: false, originAgentCluster: false })); // Rate Limiting para protección contra ataques de fuerza bruta y abuso const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 60, message: 'Demasiados intentos de autenticación. Por favor intente nuevamente en 15 minutos.', standardHeaders: true, legacyHeaders: false }); const generalLimiter = rateLimit({ windowMs: 60 * 1000, max: 180, standardHeaders: true, legacyHeaders: false }); app.post('/auth/login', authLimiter); app.post('/auth/moodle', authLimiter); app.use('/api', generalLimiter); // Configuración de middlewares app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); // Configuración de archivos estáticos app.use(express.static(path.join(__dirname, '../public'))); app.use('/static', express.static(path.join(__dirname, '../public'))); // Configuración de Nunjucks const env = nunjucks.configure(path.join(__dirname, '../views'), { autoescape: true, express: app, watch: process.env.NODE_ENV !== 'production' }); // Nunjucks custom filters/helpers env.addFilter('get', function(obj, key, defaultVal) { if (!obj) return defaultVal; return obj[key] !== undefined ? obj[key] : defaultVal; }); env.addFilter('min', function(arr) { if (Array.isArray(arr)) return Math.min(...arr); return arr; }); env.addFilter('max', function(arr) { if (Array.isArray(arr)) return Math.max(...arr); return arr; }); env.addFilter('round', function(val, precision = 0) { if (val === undefined || val === null) return 0; const factor = Math.pow(10, precision); return Math.round(val * factor) / factor; }); env.addFilter('format', function(str, ...args) { if (!str) return ''; let i = 0; return String(str).replace(/%0?(\d+)?d|%s/g, (match, pad) => { let val = args[i++]; if (pad && typeof val === 'number') { return String(val).padStart(parseInt(pad, 10), '0'); } return val !== undefined ? String(val) : match; }); }); const formatDateHelper = function(d, fmt) { if (!d) return ''; if (typeof d === 'string') return d.split('T')[0]; return new Date(d).toISOString().split('T')[0]; }; const formatDateTimeHelper = function(d, fmt) { if (!d) return ''; if (typeof d === 'string') return d.replace('T', ' ').substring(0, 16); return new Date(d).toLocaleString(); }; env.addGlobal('format_date', formatDateHelper); env.addGlobal('format_datetime', formatDateTimeHelper); env.addFilter('format_date', formatDateHelper); env.addFilter('format_datetime', formatDateTimeHelper); env.addFilter('tojson', function(val) { return JSON.stringify(val !== undefined ? val : null); }); env.addFilter('sum', function(arr, attribute) { if (!Array.isArray(arr)) return 0; return arr.reduce((acc, item) => { let val = 0; if (attribute && typeof item === 'object' && item !== null) { val = item[attribute] || 0; } else { val = Number(item) || 0; } return acc + Number(val); }, 0); }); const translateHelper = function(s) { return s || ''; }; env.addGlobal('_', translateHelper); env.addFilter('_', translateHelper); app.set('view engine', 'html'); // Helper para extraer usuario de cookie si existe app.use((req, res, next) => { const token = req.cookies.auth_token; let user = { name: 'System', first_name: 'System', email: 'admin@edu-space.com', role: 'admin', is_authenticated: Boolean(token), is_admin: () => true, has_permission: () => true, has_role: () => true, can_edit_reservation: () => true, can_delete_reservation: () => true, get_institutional_role: () => 'admin', role_badge_display: { class: 'bg-primary text-white', icon: 'bi-shield-check', label: 'Administrador' } }; if (token) { try { const decoded = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString('utf8')); const roleStr = (decoded.role || 'Admin').toLowerCase(); user = { ...user, id: decoded.sub, name: decoded.name || 'System', first_name: (decoded.name || 'System').split(' ')[0], email: decoded.email || 'admin@edu-space.com', role: roleStr, is_admin: () => roleStr === 'admin' || decoded.is_admin === true }; } catch (e) {} } let isImpersonating = false; let impersonatedUser = null; if (user.is_admin() && req.cookies.impersonate_user_id) { isImpersonating = true; const targetId = req.cookies.impersonate_user_id; try { if (req.cookies.impersonate_user_data) { impersonatedUser = JSON.parse(req.cookies.impersonate_user_data); } } catch (e) {} if (!impersonatedUser) { impersonatedUser = { id: targetId, name: `Usuario #${targetId}`, role: 'usuario' }; } } req.user = user; res.locals.user = user; res.locals.current_user = user; res.locals.role = user.role; res.locals.is_impersonating = isImpersonating; res.locals.impersonated_user = impersonatedUser; res.locals.session = { get: (k, d) => d }; res.locals.occupancy_metrics = { get: (k, d) => d }; next(); }); // Rutas Principales const indexRoutes = require('./routes/index'); const authRoutes = require('./routes/auth'); const dashboardRoutes = require('./routes/dashboard'); const classroomsRoutes = require('./routes/classrooms'); const scheduleRoutes = require('./routes/schedule'); const buildingsRoutes = require('./routes/buildings'); const adminRoutes = require('./routes/admin'); const mySubjectsRoutes = require('./routes/my_subjects'); // Proxy transparente hacia la API REST de Flask para llamadas AJAX frontend app.use('/api/v1', async (req, res) => { try { const token = req.cookies.auth_token; const targetUrl = `${process.env.FLASK_API_URL || 'http://localhost:5000/api/v1'}${req.url}`; const headers = { ...req.headers }; delete headers.host; if (token && !headers.authorization) { headers.authorization = `Bearer ${token}`; } const resp = await axios({ method: req.method, url: targetUrl, data: req.body, params: req.query, headers, validateStatus: () => true }); return res.status(resp.status).json(resp.data); } catch (err) { console.error('API v1 proxy error:', err.message); return res.status(500).json({ error: 'ProxyError', message: 'Error de comunicación con el backend API: ' + err.message }); } }); app.use('/', indexRoutes); app.use('/auth', authRoutes); app.use('/dashboard', dashboardRoutes); app.use('/classrooms', classroomsRoutes); app.use('/schedule', scheduleRoutes); app.use('/buildings', buildingsRoutes); app.use('/admin', adminRoutes); app.use('/my_subjects', mySubjectsRoutes); app.use('/mis-materias', mySubjectsRoutes); // Redirección de enlaces heredados de Jinja /main/dashboard preservando query params app.get(['/main/dashboard', '/main'], (req, res) => { const query = req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : ''; res.redirect('/dashboard' + query); }); // Módulo Predictivo y Optimizador app.get('/predictive', (req, res) => { res.render('predictive/index', { title: 'Dashboard Predictivo - Edu-Space Admin' }); }); app.get(['/genetic_optimizer', '/genetic_algorithm_web/genetic_optimizer'], (req, res) => { res.render('genetic_optimizer', { title: 'Optimizador de Aulas (IA) - Edu-Space Admin' }); }); // Proxy transparente de peticiones /api a Flask app.use('/api', async (req, res) => { // Si es dashboard-stats mock directo if (req.path === '/dashboard-stats') { return res.json({ monthly_data: [ { month: 'Ene', reservations: 120 }, { month: 'Feb', reservations: 180 }, { month: 'Mar', reservations: 250 }, { month: 'Abr', reservations: 220 }, { month: 'May', reservations: 300 }, { month: 'Jun', reservations: 310 }, { month: 'Jul', reservations: 200 }, { month: 'Ago', reservations: 350 }, { month: 'Sep', reservations: 420 }, { month: 'Oct', reservations: 480 }, { month: 'Nov', reservations: 520 }, { month: 'Dic', reservations: 210 } ] }); } try { const token = req.cookies.auth_token; const headers = { ...req.headers }; delete headers.host; delete headers.connection; if (token) { headers['Authorization'] = `Bearer ${token}`; } if (req.cookies.impersonate_user_id) { headers['X-Impersonate-User'] = req.cookies.impersonate_user_id; } const flaskUrl = `http://127.0.0.1:5000/api${req.url}`; const response = await axios({ method: req.method, url: flaskUrl, data: req.body, headers, validateStatus: () => true }); res.status(response.status).json(response.data); } catch (e) { res.status(500).json({ error: e.message }); } }); // Wildcard route to catch unmigrated menu links and attempt to render the Nunjucks templates app.get('/:blueprint/:route', (req, res) => { const { blueprint, route } = req.params; // Redirects for specific known route names if (blueprint === 'main' && route === 'dashboard') { return res.redirect('/dashboard'); } if (blueprint === 'classrooms' && (route === 'list_classrooms' || route === 'list')) { return res.redirect('/classrooms/list_classrooms'); } if (blueprint === 'classrooms' && (route === 'add_classroom' || route === 'add')) { return res.redirect('/classrooms/add_classroom'); } if (blueprint === 'buildings' && (route === 'list_buildings' || route === 'list')) { return res.redirect('/buildings/list_buildings'); } if (blueprint === 'buildings' && (route === 'add_building' || route === 'add')) { return res.redirect('/buildings/add_building'); } if (blueprint === 'schedule' && (route === 'calendar_view' || route === 'calendar')) { return res.redirect('/schedule/calendar_view'); } if (blueprint === 'schedule' && (route === 'add_reservation' || route === 'add')) { return res.redirect('/schedule/add_reservation'); } if (blueprint === 'schedule' && (route === 'today_schedule' || route === 'today')) { const query = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : ''; return res.redirect('/schedule/today' + query); } if (blueprint === 'schedule' && (route === 'list_reservations' || route === 'list')) { return res.redirect('/schedule/list_reservations'); } if (blueprint === 'admin' && (route === 'google_sheets_import' || route === 'import')) { return res.redirect('/admin/google_sheets_import'); } if (blueprint === 'admin' && (route === 'occupancy_metrics' || route === 'occupancy-metrics')) { return res.redirect('/admin/occupancy_metrics'); } if (blueprint === 'admin' && (route === 'users_list' || route === 'user_list' || route === 'users')) { return res.redirect('/admin/users_list'); } if (blueprint === 'admin' && (route === 'user_add' || route === 'user_new')) { return res.redirect('/admin/user_add'); } if (blueprint === 'admin' && (route === 'user_edit' || route === 'user_edit/:id')) { const id = req.query.id || ''; return res.redirect(id ? `/admin/user_edit/${id}` : '/admin/user_edit'); } if (blueprint === 'admin' && (route === 'roles_list' || route === 'role_list' || route === 'roles')) { return res.redirect('/admin/roles_list'); } if (blueprint === 'admin' && (route === 'role_add' || route === 'role_new')) { return res.redirect('/admin/role_add'); } if (blueprint === 'admin' && (route === 'role_edit' || route === 'role_edit/:id')) { const id = req.query.id || ''; return res.redirect(id ? `/admin/role_edit/${id}` : '/admin/role_edit'); } if (blueprint === 'classrooms' && (route === 'view_classroom' || route === 'view')) { return res.redirect('/classrooms/view_classroom'); } if (blueprint === 'classrooms' && (route === 'edit_classroom' || route === 'edit')) { const id = req.query.id || req.query.classroom_id || ''; return res.redirect(`/classrooms/edit/${id}`); } if (blueprint === 'admin' && route.includes('careers')) { return res.redirect('/admin/careers_list'); } if (blueprint === 'admin' && route.includes('subjects')) { return res.redirect('/admin/subjects_list'); } if (blueprint === 'admin' && route.includes('commissions')) { return res.redirect('/admin/commissions_list'); } if (blueprint === 'admin' && (route.includes('academic_term') || route.includes('academic_terms'))) { return res.redirect('/admin/academic_terms_list'); } if (blueprint === 'admin' && (route.includes('milestone_type') || route.includes('milestone_types'))) { return res.redirect('/admin/milestone_types_list'); } if (blueprint === 'admin' && route.includes('audit')) { return res.redirect('/admin/audit_logs'); } if (blueprint === 'genetic_algorithm_web' && route === 'genetic_optimizer') { return res.redirect('/genetic_optimizer'); } let templateName = `${blueprint}/${route}`; if (route.endsWith('_list') || route.endsWith('_view')) { templateName = `${blueprint}/list`; if (blueprint === 'admin' && route.includes('users')) templateName = 'admin/users/list'; if (blueprint === 'admin' && route.includes('roles')) templateName = 'admin/roles/list'; if (blueprint === 'admin' && route.includes('academic_terms')) templateName = 'admin/academic_terms/list'; if (blueprint === 'admin' && route.includes('subjects')) templateName = 'admin/subjects/list'; if (blueprint === 'admin' && route.includes('careers')) templateName = 'admin/careers/list'; if (blueprint === 'admin' && route.includes('commissions')) templateName = 'admin/commissions/list'; if (blueprint === 'admin' && route.includes('milestone_types')) templateName = 'admin/milestone_types/list'; } const mockContext = { title: 'Vista de Administración', role: 'admin', session: { get: (k, d) => d }, occupancy_metrics: { get: (id, d) => d }, users: [], roles: [], careers: [], subjects: [], commissions: [], academic_terms: [], terms: [], items: [], audit_logs: [] }; res.render(templateName, mockContext, function(err, html) { if (err) { console.log(`Fallback for ${blueprint}/${route}, tried to render ${templateName}. Error:`, err.message); res.send(` Vista en Migración

En Construcción

La funcionalidad ${blueprint}.${route} está siendo acoplada al nuevo BFF Node.js.

La migración de ORM Python a JSON REST está en curso para este módulo.

Volver al Panel
`); } else { res.send(html); } }); }); // Middleware 404 para cualquier ruta no encontrada (evita pantallas en blanco y errores Cannot POST/GET) app.use((req, res) => { res.status(404).send(` Página No Encontrada — Edu-Space

Página o Acción No Encontrada

La ruta solicitada ${req.originalUrl} no existe o no admite el método HTTP ejecutado.

Volver al Panel
`); }); // Middleware Global de Error 500 app.use((err, req, res, next) => { console.error('Error no capturado en BFF:', err); res.status(500).send(` Error del Sistema — Edu-Space

Error en la Aplicación

Ocurrió una eventualidad procesando la solicitud. Nuestro equipo técnico ha sido notificado.

Volver al Panel
`); }); app.listen(port, () => { console.log(`BFF Frontend corriendo en http://localhost:${port}`); });