Backend (FLUJOS_APP.js): - Lista blanca de temas válidos (bloquea escaneo libre de la BD) - Validación estricta de subtematica (regex alfanumérico, 80 chars) - Validación de fechas: formato ISO YYYY-MM-DD + rango lógico - Validación de nodos y complejidad con rangos numéricos explícitos - CSP sin unsafe-inline en scriptSrc (eliminado) - Helmet completo activado (X-Frame-Options, X-Content-Type-Options, HSTS...) - Filtro anti-CSRF por cabecera Origin en /api/ - bodyParser con límite 10kb - Cabeceras: Referrer-Policy, X-Content-Type-Options explícitas Frontend (3dscript_eco-corp.html, script_eco-corp.html): - innerHTML eliminado, sustituido por DOM API + textContent Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
311 lines
12 KiB
JavaScript
Executable file
311 lines
12 KiB
JavaScript
Executable file
// FLUJOS_APP.js ubicado en /var/www/flujos/FLUJOS/BACK_BACK/
|
|
|
|
require('dotenv').config(); // Cargar variables de entorno desde .env
|
|
|
|
const express = require('express');
|
|
const path = require('path');
|
|
const bodyParser = require('body-parser');
|
|
const helmet = require('helmet');
|
|
const { MongoClient } = require('mongodb');
|
|
|
|
const app = express();
|
|
const port = process.env.PORT || 3000;
|
|
|
|
// Helmet completo: X-Powered-By off, XSS protection, nosniff, frameguard, HSTS, etc.
|
|
app.use(helmet());
|
|
|
|
// CSP personalizado encima del helmet por defecto
|
|
app.use(
|
|
helmet.contentSecurityPolicy({
|
|
directives: {
|
|
defaultSrc: ["'self'"],
|
|
scriptSrc: ["'self'", 'https://unpkg.com', 'https://cdnjs.cloudflare.com'],
|
|
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
|
|
imgSrc: ["'self'", 'data:', 'blob:'],
|
|
connectSrc: ["'self'"],
|
|
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
|
|
frameAncestors: ["'none'"],
|
|
objectSrc: ["'none'"],
|
|
baseUri: ["'self'"],
|
|
},
|
|
})
|
|
);
|
|
|
|
// Prevenir CSRF: solo aceptar peticiones GET a /api desde el mismo origen
|
|
app.use('/api/', (req, res, next) => {
|
|
const origin = req.headers.origin;
|
|
const referer = req.headers.referer;
|
|
// En producción detrás de nginx no hay Origin en peticiones same-site normales,
|
|
// pero sí lo habría en peticiones cross-origin maliciosas
|
|
if (origin && origin !== `https://theflows.net` && origin !== `http://localhost:${port}`) {
|
|
return res.status(403).json({ error: 'Origen no permitido' });
|
|
}
|
|
next();
|
|
});
|
|
|
|
// Añadir cabecera anti-clickjacking y anti-MIME-sniffing explícitas
|
|
app.use((req, res, next) => {
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
next();
|
|
});
|
|
|
|
app.use(bodyParser.json({ limit: '10kb' }));
|
|
|
|
// **Definir primero las rutas de la API**
|
|
|
|
app.get('/api/data', async (req, res) => {
|
|
console.log('Solicitud recibida en /api/data');
|
|
try {
|
|
// Asegurarse de que la conexión a la base de datos está establecida
|
|
if (!db) {
|
|
res.status(500).json({ error: 'No hay conexión a la base de datos' });
|
|
return;
|
|
}
|
|
|
|
// Acceder a las colecciones necesarias
|
|
const wikipediaCollection = db.collection('wikipedia');
|
|
const noticiasCollection = db.collection('noticias');
|
|
const torrentsCollection = db.collection('torrents');
|
|
const imagenesCollection = db.collection('imagenes'); // analizadas por Qwen
|
|
const imagenesWikiCollection = db.collection('imagenes_wiki'); // scrapeadas (fallback)
|
|
const comparacionesCollection = db.collection('comparaciones');
|
|
|
|
// ── Sanitización y validación de parámetros ──────────────────────────────
|
|
// Rechaza cualquier valor que no sea string (bloquea operator injection)
|
|
function sanitizeParam(val) {
|
|
if (typeof val !== 'string') return undefined;
|
|
return val.trim();
|
|
}
|
|
|
|
// Lista blanca de temas válidos (evita escaneo libre de la BD)
|
|
const TEMAS_VALIDOS = new Set([
|
|
'guerra global', 'inteligencia y seguridad', 'cambio climático',
|
|
'demografía y sociedad', 'economía y corporaciones', 'otros',
|
|
'geopolítica conflictos', 'seguridad internacional espionaje',
|
|
'libertad de prensa periodismo', 'corporaciones poder económico',
|
|
'populismo extremismo', 'desinformación redes sociales',
|
|
'privacidad vigilancia masiva', 'biodiversidad medioambiente',
|
|
'inteligencia artificial tecnología',
|
|
]);
|
|
|
|
const temaRaw = sanitizeParam(req.query.tema);
|
|
const subtematica = sanitizeParam(req.query.subtematica);
|
|
const palabraClave = sanitizeParam(req.query.palabraClave);
|
|
const fechaInicio = sanitizeParam(req.query.fechaInicio);
|
|
const fechaFin = sanitizeParam(req.query.fechaFin);
|
|
const nodos = sanitizeParam(req.query.nodos);
|
|
const complejidad = sanitizeParam(req.query.complejidad);
|
|
|
|
// Validar tema contra lista blanca
|
|
const tema = temaRaw && TEMAS_VALIDOS.has(temaRaw) ? temaRaw : null;
|
|
if (!tema) {
|
|
res.status(400).json({ error: 'El parámetro "tema" es obligatorio o no válido' });
|
|
return;
|
|
}
|
|
|
|
// Validar subtematica: solo letras, números, espacios y guiones (máx 80 chars)
|
|
if (subtematica && !/^[\w\s\-áéíóúñüÁÉÍÓÚÑÜ]{1,80}$/.test(subtematica)) {
|
|
res.status(400).json({ error: 'subtematica no válida' });
|
|
return;
|
|
}
|
|
|
|
// Validar palabraClave: máx 100 chars, escapar metacaracteres regex
|
|
let regexKw = null;
|
|
if (palabraClave) {
|
|
if (palabraClave.length > 100) {
|
|
res.status(400).json({ error: 'palabraClave demasiado larga' });
|
|
return;
|
|
}
|
|
regexKw = palabraClave.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
// Validar fechas: formato YYYY-MM-DD estricto
|
|
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
let fechaGte = null, fechaLte = null;
|
|
if (fechaInicio && fechaFin) {
|
|
if (!ISO_DATE.test(fechaInicio) || !ISO_DATE.test(fechaFin)) {
|
|
res.status(400).json({ error: 'Formato de fecha inválido (YYYY-MM-DD)' });
|
|
return;
|
|
}
|
|
fechaGte = new Date(fechaInicio);
|
|
fechaLte = new Date(fechaFin);
|
|
if (isNaN(fechaGte) || isNaN(fechaLte) || fechaGte > fechaLte) {
|
|
res.status(400).json({ error: 'Rango de fechas inválido' });
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Validar nodos: entero positivo entre 1 y 500
|
|
const nodosRaw = parseInt(nodos, 10);
|
|
const nodosLimit = (!isNaN(nodosRaw) && nodosRaw > 0) ? Math.min(nodosRaw, 500) : 100;
|
|
|
|
// Validar complejidad: float entre 0 y 100
|
|
const complejidadRaw = parseFloat(complejidad);
|
|
const porcentajeSimilitudMin = (!isNaN(complejidadRaw) && complejidadRaw >= 0 && complejidadRaw <= 100)
|
|
? complejidadRaw : 0;
|
|
|
|
// Construir query de MongoDB solo con valores validados
|
|
const nodesQuery = { tema };
|
|
if (subtematica) nodesQuery.subtema = subtematica;
|
|
if (regexKw) nodesQuery.texto = { $regex: regexKw, $options: 'i' };
|
|
if (fechaGte) nodesQuery.fecha = { $gte: fechaGte, $lte: fechaLte };
|
|
// Ejecutar la consulta y obtener los resultados de las colecciones
|
|
console.log('Ejecutando consulta para nodos en colecciones...');
|
|
// Límite de imágenes: un tercio del total para no saturar el grafo
|
|
const imagenesLimit = Math.floor(nodosLimit / 3);
|
|
|
|
const [wikipediaNodes, noticiasNodes, torrentsNodes, imagenesNodes, imagenesWikiNodes] = await Promise.all([
|
|
wikipediaCollection.find(nodesQuery).limit(nodosLimit).toArray(),
|
|
noticiasCollection.find(nodesQuery).limit(nodosLimit).toArray(),
|
|
torrentsCollection.find(nodesQuery).limit(nodosLimit).toArray(),
|
|
imagenesCollection.find(nodesQuery).limit(imagenesLimit).toArray(),
|
|
imagenesWikiCollection.find(nodesQuery).limit(imagenesLimit).toArray(),
|
|
]);
|
|
console.log('Nodos Wikipedia:', wikipediaNodes.length, '| Noticias:', noticiasNodes.length,
|
|
'| Torrents:', torrentsNodes.length, '| Imágenes:', imagenesNodes.length,
|
|
'| Imágenes Wiki:', imagenesWikiNodes.length);
|
|
|
|
// Preferir imagenes analizadas (con keywords); si no hay, usar imagenes_wiki
|
|
const imgNodes = imagenesNodes.length > 0 ? imagenesNodes : imagenesWikiNodes;
|
|
|
|
const nodes = [...wikipediaNodes, ...noticiasNodes, ...torrentsNodes];
|
|
console.log('Total nodos texto:', nodes.length, '| Nodos imagen:', imgNodes.length);
|
|
|
|
// Nodos de texto
|
|
const formattedNodes = nodes.map((result) => ({
|
|
id: result.archivo.trim(),
|
|
group: result.subtema || 'sin subtema',
|
|
tema: result.tema || 'sin tema',
|
|
content: result.texto || '',
|
|
fecha: result.fecha || '',
|
|
type: 'texto',
|
|
}));
|
|
|
|
// Nodos de imagen — image_url construida desde image_path absoluto
|
|
const WIKI_IMAGES_BASE = '/var/www/theflows.net/flujos/FLUJOS_DATOS/IMAGENES/output/wiki_images/';
|
|
const imgFormattedNodes = imgNodes.map((result) => {
|
|
const relativePath = result.image_path
|
|
? result.image_path.replace(WIKI_IMAGES_BASE, '')
|
|
: null;
|
|
return {
|
|
id: result.archivo.trim(),
|
|
group: result.subtema || result.tema || 'imagen',
|
|
tema: result.tema || 'sin tema',
|
|
content: result.texto || result.descripcion_wiki || '',
|
|
fecha: result.fecha || '',
|
|
type: 'imagen',
|
|
image_url: relativePath ? `/wiki-images/${relativePath}` : null,
|
|
label: result.subtema || result.tema || result.archivo,
|
|
};
|
|
}).filter(n => n.image_url);
|
|
|
|
const allNodes = [...formattedNodes, ...imgFormattedNodes];
|
|
const formattedNodes_final = allNodes; // alias para claridad
|
|
|
|
// Obtener los IDs de todos los nodos (texto + imagen)
|
|
const nodeIds = formattedNodes_final.map(node => node.id);
|
|
console.log('IDs de nodos:', nodeIds);
|
|
|
|
// Construir la consulta para obtener los enlaces relacionados con los nodos obtenidos
|
|
const linksQuery = {
|
|
porcentaje_similitud: { $gte: porcentajeSimilitudMin },
|
|
noticia1: { $in: nodeIds },
|
|
noticia2: { $in: nodeIds },
|
|
};
|
|
console.log('Consulta para enlaces:', linksQuery);
|
|
|
|
// Ejecutar la consulta y obtener los enlaces
|
|
console.log('Ejecutando consulta para enlaces...');
|
|
const links = await comparacionesCollection.find(linksQuery).toArray();
|
|
console.log('Enlaces obtenidos:', links.length);
|
|
|
|
// Formatear los enlaces sin normalizar los IDs
|
|
const formattedLinks = links.map((result) => ({
|
|
source: result.noticia1.trim(),
|
|
target: result.noticia2.trim(),
|
|
value: result.porcentaje_similitud,
|
|
}));
|
|
|
|
// Enviar los datos al cliente en formato JSON
|
|
res.json({ nodes: formattedNodes_final, links: formattedLinks });
|
|
console.log('Datos enviados al cliente');
|
|
} catch (error) {
|
|
console.error('Error al obtener datos:', error);
|
|
res.status(500).json({ error: 'Error al obtener datos' });
|
|
}
|
|
});
|
|
|
|
// **Luego, definir las rutas de las páginas principales**
|
|
// Rutas para las páginas principales
|
|
app.get('/', (req, res) => {
|
|
res.sendFile(path.join(__dirname, '../VISUALIZACION/public/index.html'));
|
|
});
|
|
|
|
app.get('/climate.html', (req, res) => {
|
|
res.sendFile(path.join(__dirname, '../VISUALIZACION/public/climate.html'));
|
|
});
|
|
|
|
app.get('/glob-war.html', (req, res) => {
|
|
res.sendFile(path.join(__dirname, '../VISUALIZACION/public/glob-war.html'));
|
|
});
|
|
|
|
app.get('/popl-up.html', (req, res) => {
|
|
res.sendFile(path.join(__dirname, '../VISUALIZACION/public/popl-up.html'));
|
|
});
|
|
|
|
app.get('/int-sec.html', (req, res) => {
|
|
res.sendFile(path.join(__dirname, '../VISUALIZACION/public/int-sec.html'));
|
|
});
|
|
|
|
app.get('/eco-corp.html', (req, res) => {
|
|
res.sendFile(path.join(__dirname, '../VISUALIZACION/public/eco-corp.html'));
|
|
});
|
|
|
|
// **Después, configurar el middleware de archivos estáticos**
|
|
|
|
// Imágenes scrapeadas de Wikipedia
|
|
app.use('/wiki-images', express.static(
|
|
'/var/www/theflows.net/flujos/FLUJOS_DATOS/IMAGENES/output/wiki_images'
|
|
));
|
|
|
|
app.use(express.static(path.join(__dirname, '../VISUALIZACION/public')));
|
|
|
|
// Conexión a MongoDB usando variables de entorno
|
|
const mongoUrl = process.env.MONGO_URL || 'mongodb://localhost:27017';
|
|
const dbName = process.env.DB_NAME || 'FLUJOS_DATOS';
|
|
|
|
let db; // Variable para almacenar la conexión a la base de datos
|
|
|
|
// Función para conectar a MongoDB
|
|
async function connectToMongoDB() {
|
|
try {
|
|
const mongoClient = new MongoClient(mongoUrl, {
|
|
useNewUrlParser: true,
|
|
useUnifiedTopology: true,
|
|
});
|
|
await mongoClient.connect();
|
|
db = mongoClient.db(dbName);
|
|
console.log('Conectado a MongoDB');
|
|
} catch (error) {
|
|
console.error('Error al conectar a MongoDB:', error);
|
|
process.exit(1); // Salir de la aplicación si no se puede conectar
|
|
}
|
|
}
|
|
|
|
// Llamar a la función para conectar a MongoDB
|
|
connectToMongoDB();
|
|
|
|
// Iniciar el servidor escuchando en todas las interfaces
|
|
app.listen(port, '127.0.0.1', () => {
|
|
console.log(`La aplicación está escuchando en http://localhost:${port}`);
|
|
});
|
|
|
|
// Manejar el cierre de la aplicación
|
|
process.on('SIGINT', async () => {
|
|
if (db) {
|
|
await db.close();
|
|
console.log('Conexión a MongoDB cerrada');
|
|
}
|
|
process.exit(0);
|
|
});
|