435 lines
16 KiB
JavaScript
435 lines
16 KiB
JavaScript
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');
|
|
|
|
// Blindaje HTTP con Helmet
|
|
app.use(helmet({
|
|
contentSecurityPolicy: false,
|
|
crossOriginEmbedderPolicy: false
|
|
}));
|
|
|
|
// Rate Limiting para protección contra ataques de fuerza bruta y abuso
|
|
const authLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000,
|
|
max: 30,
|
|
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.use('/auth/login', 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');
|
|
|
|
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(`
|
|
<!DOCTYPE html><html><head><title>Vista en Migración</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<style>body { background: #121212; color: #fff; font-family: sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }</style>
|
|
</head><body><div class="text-center">
|
|
<h1 class="display-4 text-primary mb-3"><i class="bi bi-tools"></i> En Construcción</h1>
|
|
<p class="lead">La funcionalidad <strong>${blueprint}.${route}</strong> está siendo acoplada al nuevo BFF Node.js.</p>
|
|
<p class="text-muted">La migración de ORM Python a JSON REST está en curso para este módulo.</p>
|
|
<a href="/dashboard" class="btn btn-outline-light mt-4">Volver al Panel</a>
|
|
</div></body></html>
|
|
`);
|
|
} else {
|
|
res.send(html);
|
|
}
|
|
});
|
|
});
|
|
|
|
app.listen(port, () => {
|
|
console.log(`BFF Frontend corriendo en http://localhost:${port}`);
|
|
});
|