Base de conocimiento para el personal del Instituto Santiago Tapia: oferta educativa (en tarjetas y en tabla), requisitos y titulacion, becas y fechas de inscripcion, links de clases de Google Meet, preguntas frecuentes, mensajes listos y datos del instituto. Acceso con Magic Login: enlace de un solo uso, 15 minutos de vigencia, y en la base solo se guarda el SHA-256 del token. El canje es atomico, asi que dos canjes simultaneos del mismo enlace no pueden crear dos sesiones. Tres roles con 19 permisos. El catalogo lo edita direccion; los links de clases, direccion y administracion; el resto del equipo consulta. El menu se pinta con lo que el servidor autoriza y cada endpoint exige la misma clave, asi que no hay pantallas alcanzables escribiendo la direccion a mano. Toda edicion pasa por una lista blanca de campos validados y queda en la bitacora. Un precio vacio no se guarda como cero, un link que no es una direccion se rechaza, y no se puede marcar una validez oficial como confirmada sin numero de acuerdo. Pensado para leerse comodo: texto de 17 px, contraste AA, areas de toque de 44 px, tema claro y bloques plegados en telefono. La oferta, los costos, las becas, los requisitos y el contacto son los reales del instituto, tomados del CSV de oferta educativa y de la base de conocimiento. Los usuarios de acceso y los links de Meet son de demostracion. Verificado: 103 comprobaciones de API y 51 pruebas en WebKit escritorio y iPhone 13. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
120 lines
5.0 KiB
JavaScript
120 lines
5.0 KiB
JavaScript
// =====================================================================
|
|
// 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: [email protected] · [email protected] · [email protected]\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);
|
|
}
|