// ===================================================================== // Portal Interno INSTA · servidor // ===================================================================== import express from 'express'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { config } from './config.mjs'; import { getPool, q1 } from './db.mjs'; import { cargarSesion, canjearEnlaceMagico, ponerCookieSesion } from './auth.mjs'; import { registrar } from './bitacora.mjs'; import { rutas } from './rutas.mjs'; const raiz = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const publico = path.join(raiz, 'public'); const app = express(); app.disable('x-powered-by'); app.use(express.json({ limit: '256kb' })); // El cuerpo mal formado o demasiado grande se contesta con su código y con un // mensaje que la persona pueda entender, no con un 500 genérico. app.use((err, _req, res, next) => { if (!err) return next(); if (err.type === 'entity.too.large') { return res.status(413).json({ error: 'texto-demasiado-largo', mensaje: 'El texto que pegaste es demasiado largo. Recórtalo e inténtalo de nuevo.', }); } if (err.type === 'entity.parse.failed') { return res.status(400).json({ error: 'peticion-invalida', mensaje: 'No se entendió la petición. Recarga la página e inténtalo otra vez.' }); } return next(err); }); app.use(cargarSesion); // Cabeceras defensivas. La CSP no permite ningún recurso externo: todo el // portal se sirve desde este origen. Los estilos en línea sí se permiten // porque las plantillas usan el atributo style para los colores del catálogo. app.use((_req, res, next) => { res.setHeader('Content-Security-Policy', [ "default-src 'self'", "script-src 'self'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data:", "font-src 'self'", "connect-src 'self'", "form-action 'self'", "base-uri 'self'", "frame-ancestors 'none'", "object-src 'none'", ].join('; ')); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Referrer-Policy', 'same-origin'); res.setHeader('X-Frame-Options', 'DENY'); next(); }); // ---- Magic Login por navegación directa ------------------------------ // Es el enlace que en producción llegaría por correo. app.get('/acceso/:token', async (req, res) => { const r = await canjearEnlaceMagico(req.params.token, req); if (!r.ok) { await registrar(req, { accion: 'auth.canje', resultado: 'denegado', detalle: r.motivo }); return res.redirect(`/?error=${encodeURIComponent(r.motivo)}`); } ponerCookieSesion(res, r.sesionId); const u = await q1('SELECT nombre FROM usuarios WHERE id = ?', [r.usuarioId]); req.usuario = { id: r.usuarioId, nombre: u?.nombre ?? '', rol_clave: '' }; await registrar(req, { accion: 'auth.entrada', entidad: 'usuario', entidad_id: String(r.usuarioId), detalle: 'sesión iniciada con enlace mágico' }); res.redirect('/portal'); }); // Salud del proceso. A propósito NO consulta la base: si MySQL tarda en // arrancar, el contenedor no debe reiniciarse en bucle por eso. app.get('/salud', (_req, res) => { res.json({ ok: true, servicio: 'portal-insta', tiempoActivo: Math.round(process.uptime()) }); }); app.use('/api', rutas); // ---- estáticos y shell ---------------------------------------------- app.use(express.static(publico, { extensions: ['html'], maxAge: '5m' })); // El portal es una sola página; si no hay sesión, el propio shell manda a la entrada. app.get('/portal', (_req, res) => res.sendFile(path.join(publico, 'portal.html'))); app.get('/', (_req, res) => res.sendFile(path.join(publico, 'index.html'))); app.use((req, res) => { if (req.path.startsWith('/api')) return res.status(404).json({ error: 'endpoint-no-encontrado' }); res.status(404).sendFile(path.join(publico, 'index.html')); }); app.use((err, _req, res, _next) => { console.error('[error]', err); if (res.headersSent) return; res.status(500).json({ error: 'error-interno', mensaje: 'Algo falló del lado del servidor. Vuelve a intentarlo.' }); }); // Red de seguridad: un fallo suelto se registra, no tumba el portal. process.on('unhandledRejection', (motivo) => console.error('[rechazo no atrapado]', motivo)); process.on('uncaughtException', (err) => console.error('[excepción no atrapada]', err)); // ---- arranque -------------------------------------------------------- try { const fila = await q1('SELECT COUNT(*) AS n FROM programas'); if (!fila || fila.n === 0) throw new Error('la base está vacía: corre `npm run db:reset`'); app.listen(config.port, () => { console.log(`\n ▸ Portal INSTA en http://localhost:${config.port}`); console.log(` base: ${config.db.database}@${config.db.host}:${config.db.port} · ${fila.n} programas cargados`); console.log(' accesos demo: director@insta.demo · administracion@insta.demo · asesor@insta.demo\n'); }); } catch (err) { console.error(`\n✖ No se pudo arrancar: ${err.message}\n`); console.error(' Revisa que el contenedor esté arriba: docker compose up -d'); await getPool().end().catch(() => {}); process.exit(1); }