Files
p2p-media-next/server.js
2025-11-29 23:42:59 +01:00

529 líneas
16 KiB
JavaScript

const { createServer } = require('http');
const { parse } = require('url');
const next = require('next');
const { Server } = require('socket.io');
const https = require('https');
const dev = process.env.NODE_ENV !== 'production';
const hostname = 'localhost';
const port = parseInt(process.env.PORT || '3000', 10);
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
// Configuración de streams disponibles
const STREAM_SOURCES = {
'rtve-la1': 'https://ztnr.rtve.es/ztnr/1688877.m3u8',
'rtve-la2': 'https://ztnr.rtve.es/ztnr/3987218.m3u8',
'rtve-24h': 'https://rtvelivestream.rtve.es/rtvesec/24h/24h_main_dvr_720.m3u8'
};
// Rate limiting por IP
const rateLimits = new Map();
const MAX_MESSAGES_PER_MINUTE = 30;
const MAX_CONNECTIONS_PER_IP = 5;
function checkRateLimit(ip) {
const now = Date.now();
const userLimit = rateLimits.get(ip) || { count: 0, resetTime: now + 60000 };
if (now > userLimit.resetTime) {
userLimit.count = 0;
userLimit.resetTime = now + 60000;
}
userLimit.count++;
rateLimits.set(ip, userLimit);
return userLimit.count <= MAX_MESSAGES_PER_MINUTE;
}
// Validación de datos
function sanitizeMessage(msg) {
if (typeof msg !== 'string') return '';
// Limitar longitud del mensaje
msg = msg.substring(0, 500);
// Eliminar caracteres peligrosos
return msg.replace(/[<>]/g, '');
}
function sanitizeUsername(username) {
if (typeof username !== 'string') return '';
// Limitar longitud del nombre de usuario
username = username.substring(0, 30);
// Solo permitir caracteres alfanuméricos, espacios, guiones y guiones bajos
return username.replace(/[^a-zA-Z0-9 _-]/g, '');
}
// Función para hacer proxy de streams con soporte para redirecciones
function proxyStream(sourceUrl, req, res, redirectCount = 0) {
const MAX_REDIRECTS = 5;
if (redirectCount > MAX_REDIRECTS) {
console.error('Demasiadas redirecciones para:', sourceUrl);
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Error: Demasiadas redirecciones');
return;
}
const urlParts = new URL(sourceUrl);
const isHttps = urlParts.protocol === 'https:';
const httpModule = isHttps ? https : require('http');
// Construir headers que parezcan de un navegador real
const headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Accept': '*/*',
'Accept-Language': 'es-ES,es;q=0.9',
'Accept-Encoding': 'identity', // No solicitar compresión para evitar problemas
'Connection': 'keep-alive',
'Sec-Ch-Ua': '"Chromium";v="131", "Not_A Brand";v="24"',
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"Windows"',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'cross-site',
'DNT': '1'
};
// Para RTVE, agregar headers específicos y muy importante el Referer
if (urlParts.hostname.includes('rtve.es')) {
headers['Origin'] = 'https://www.rtve.es';
headers['Referer'] = 'https://www.rtve.es/play/videos/directo/la-1/';
}
const options = {
hostname: urlParts.hostname,
port: isHttps ? 443 : 80,
path: urlParts.pathname + urlParts.search,
method: 'GET',
headers: headers
};
const proxyReq = httpModule.request(options, (proxyRes) => {
// Manejar redirecciones (301, 302, 303, 307, 308)
if (proxyRes.statusCode >= 300 && proxyRes.statusCode < 400 && proxyRes.headers.location) {
const redirectUrl = new URL(proxyRes.headers.location, sourceUrl).href;
// Consumir completamente la respuesta antes de redirigir
proxyRes.resume();
// Llamar recursivamente para seguir la redirección
proxyStream(redirectUrl, req, res, redirectCount + 1);
return;
}
// Si no es un 200, devolver error
if (proxyRes.statusCode !== 200) {
console.error(`Error de stream: ${proxyRes.statusCode} para ${sourceUrl}`);
res.writeHead(proxyRes.statusCode, {
'Content-Type': 'text/plain',
'Access-Control-Allow-Origin': '*'
});
res.end(`Error: Stream devolvió código ${proxyRes.statusCode}`);
return;
}
// Copiar headers de la respuesta para código 200
const responseHeaders = {
'Content-Type': proxyRes.headers['content-type'] || 'application/vnd.apple.mpegurl',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Cache-Control': proxyRes.headers['cache-control'] || 'no-cache',
'Connection': 'keep-alive'
};
// Si es un archivo .m3u8, procesar el contenido para actualizar las URLs
if (sourceUrl.endsWith('.m3u8') || proxyRes.headers['content-type']?.includes('mpegurl')) {
let data = '';
proxyRes.setEncoding('utf8');
proxyRes.on('data', (chunk) => {
data += chunk;
});
proxyRes.on('end', () => {
try {
const baseUrl = sourceUrl.substring(0, sourceUrl.lastIndexOf('/') + 1);
const lines = data.split('\n').map(line => {
line = line.trim();
// Si la línea es una URL (no es comentario ni está vacía)
if (line && !line.startsWith('#')) {
let fullUrl;
// Si es una URL absoluta, usarla directamente
if (line.startsWith('http')) {
fullUrl = line;
} else {
// Si es relativa, construir la URL completa
fullUrl = baseUrl + line;
}
// Solo proxear otros .m3u8, los .ts van directo (sin proxy)
if (fullUrl.endsWith('.m3u8')) {
const protocol = req.headers['x-forwarded-proto'] || 'http';
const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost:3000';
const proxyBaseUrl = `${protocol}://${host}/api/proxy?url=`;
return proxyBaseUrl + encodeURIComponent(fullUrl);
} else {
// Los .ts van directo al origen, sin proxy
return fullUrl;
}
}
return line;
});
res.writeHead(200, responseHeaders);
res.end(lines.join('\n'));
} catch (err) {
console.error('Error procesando playlist:', err);
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Error al procesar el playlist');
}
});
} else {
// Para archivos .ts (nunca deberían llegar aquí si la config es correcta)
res.writeHead(200, responseHeaders);
proxyRes.pipe(res);
}
});
proxyReq.on('error', (err) => {
console.error('Error en proxy:', err);
if (!res.headersSent) {
res.writeHead(500, {
'Content-Type': 'text/plain',
'Access-Control-Allow-Origin': '*'
});
res.end('Error al obtener el stream');
}
});
proxyReq.setTimeout(30000, () => {
console.error('Timeout en proxy:', sourceUrl);
proxyReq.destroy();
if (!res.headersSent) {
res.writeHead(504, {
'Content-Type': 'text/plain',
'Access-Control-Allow-Origin': '*'
});
res.end('Timeout al obtener el stream');
}
});
proxyReq.end();
}
app.prepare().then(() => {
const server = createServer((req, res) => {
const parsedUrl = parse(req.url, true);
const { pathname, query } = parsedUrl;
// Endpoint para streams proxy
if (pathname.startsWith('/api/stream/')) {
const streamId = pathname.replace('/api/stream/', '');
if (STREAM_SOURCES[streamId]) {
proxyStream(STREAM_SOURCES[streamId], req, res);
return;
} else {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Stream no encontrado');
return;
}
}
// Endpoint para proxy de URL personalizada
if (pathname === '/api/proxy' && query.url) {
try {
const targetUrl = decodeURIComponent(query.url);
// Validar que sea una URL HTTPS válida
if (!targetUrl.startsWith('https://') && !targetUrl.startsWith('http://')) {
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('URL inválida');
return;
}
proxyStream(targetUrl, req, res);
return;
} catch (err) {
console.error('Error al procesar URL:', err);
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('URL inválida');
return;
}
}
// Manejar solicitudes normales de Next.js
handle(req, res, parsedUrl);
});
const io = new Server(server, {
// Limitar tamaño de mensajes
maxHttpBufferSize: 1e6, // 1MB
// Configurar timeouts
pingTimeout: 60000,
pingInterval: 25000
});
// Almacenar usuarios conectados
const connectedUsers = new Map();
const ipConnections = new Map();
const userThumbnails = new Map();
io.on('connection', (socket) => {
const clientIp = socket.handshake.address;
// Verificar límite de conexiones por IP
const currentConnections = ipConnections.get(clientIp) || 0;
if (currentConnections >= MAX_CONNECTIONS_PER_IP) {
socket.disconnect(true);
return;
}
ipConnections.set(clientIp, currentConnections + 1);
// Registro de usuario
socket.on('register', (data) => {
try {
if (!data || !data.user) {
socket.emit('error', 'Datos de registro inválidos');
return;
}
const username = sanitizeUsername(data.user);
if (!username || username.length < 2) {
socket.emit('error', 'Nombre de usuario inválido');
return;
}
// Validar que usuarios normales no puedan usar el prefijo "anon"
// Solo permitir si viene exactamente con el formato anon#### (4 dígitos)
const anonPattern = /^anon\d{4}$/;
if (username.toLowerCase().startsWith('anon') && !anonPattern.test(username)) {
socket.emit('error', 'El prefijo "anon" está reservado para usuarios anónimos del sistema');
return;
}
// Verificar si el usuario ya existe
const existingUser = Array.from(connectedUsers.values()).find(u => u.username === username);
if (existingUser) {
socket.emit('rejoin', { user: username });
return;
}
// Registrar usuario
connectedUsers.set(socket.id, { username, ip: clientIp });
socket.username = username;
// Enviar lista de usuarios al nuevo usuario
const usersList = Array.from(connectedUsers.values()).map(u => u.username);
socket.emit('users', { users: usersList });
// Notificar a otros usuarios
socket.broadcast.emit('adduser', { user: username });
io.emit('join', { user: username });
} catch (error) {
console.error('Error en registro:', error);
socket.emit('error', 'Error al registrar usuario');
}
});
// Test de conexión para debugging
socket.on('test-connection-request', (data) => {
socket.emit('test-connection');
});
// Mensajes de chat
socket.on('emit msg', (data) => {
try {
if (!socket.username) {
socket.emit('error', 'Usuario no registrado');
return;
}
// Rate limiting
if (!checkRateLimit(clientIp)) {
socket.emit('error', 'Demasiados mensajes, espera un momento');
return;
}
if (!data || !data.chat) {
return;
}
const message = sanitizeMessage(data.chat);
if (!message || message.trim().length === 0) {
return;
}
// Broadcast del mensaje
socket.broadcast.emit('msg', {
user: socket.username,
chat: message,
timestamp: Date.now()
});
} catch (error) {
console.error('Error al enviar mensaje:', error);
}
});
// Señalización WebRTC para P2P
socket.on('signal', (data) => {
try {
if (!socket.username) return;
if (!data || !data.to || !data.signal) {
return;
}
// Encontrar el socket del destinatario
const targetSocket = Array.from(io.sockets.sockets.values())
.find(s => s.username === data.to);
if (targetSocket) {
targetSocket.emit('signal', {
from: socket.username,
signal: data.signal
});
}
} catch (error) {
console.error('Error en señalización:', error);
}
});
// Solicitar peer
socket.on('request-peer', (data) => {
try {
if (!socket.username || !data || !data.to) {
return;
}
const targetSocket = Array.from(io.sockets.sockets.values())
.find(s => s.username === data.to);
if (targetSocket) {
targetSocket.emit('peer-requested', {
from: socket.username
});
} else {
const allUsers = Array.from(connectedUsers.values()).map(u => u.username);
socket.emit('peer-not-found', {
user: data.to,
message: `El usuario ${data.to} no está conectado`
});
}
} catch (error) {
console.error('Error al solicitar peer:', error);
}
});
// Notificar loop detectado
socket.on('peer-loop-detected', (data) => {
try {
if (!socket.username || !data || !data.to) {
return;
}
const targetSocket = Array.from(io.sockets.sockets.values())
.find(s => s.username === data.to);
if (targetSocket) {
targetSocket.emit('peer-loop-detected', {
from: socket.username,
message: data.message
});
console.log(`⚠️ Loop detectado entre ${socket.username} y ${data.to}`);
}
} catch (error) {
console.error('Error al notificar loop:', error);
}
});
// Solicitar ver stream de usuario
socket.on('request-watch', (data) => {
try {
if (!socket.username) return;
if (!data || !data.target) return;
const targetSocket = Array.from(io.sockets.sockets.values())
.find(s => s.username === data.target);
if (targetSocket) {
// Notificar al target que alguien quiere ver su stream
targetSocket.emit('request-watch', {
from: socket.username
});
}
} catch (error) {
console.error('Error al solicitar watch:', error);
}
});
// Recibir thumbnail de video del usuario
socket.on('video-thumbnail', (data) => {
try {
if (!socket.username) return;
// Broadcast del thumbnail a todos los demás usuarios
socket.broadcast.emit('user-thumbnail', {
user: socket.username,
thumbnail: data.thumbnail,
isPlaying: data.isPlaying,
currentTime: data.currentTime,
duration: data.duration
});
} catch (error) {
console.error('Error al procesar thumbnail:', error);
}
});
// Desconexión
socket.on('disconnect', () => {
try {
if (socket.username) {
connectedUsers.delete(socket.id);
userThumbnails.delete(socket.username);
const usersList = Array.from(connectedUsers.values()).map(u => u.username);
io.emit('users', { users: usersList });
io.emit('quit', { msg: `QUITS ${socket.username}` });
}
// Decrementar contador de conexiones por IP
const currentConnections = ipConnections.get(clientIp) || 1;
if (currentConnections <= 1) {
ipConnections.delete(clientIp);
} else {
ipConnections.set(clientIp, currentConnections - 1);
}
} catch (error) {
console.error('Error en desconexión:', error);
}
});
});
// Limpiar rate limits cada minuto
setInterval(() => {
const now = Date.now();
for (const [ip, limit] of rateLimits.entries()) {
if (now > limit.resetTime + 60000) {
rateLimits.delete(ip);
}
}
}, 60000);
server.listen(port, (err) => {
if (err) throw err;
console.log(`> Servidor listo en http://${hostname}:${port}`);
console.log(`> Socket.IO listo con protección contra ataques`);
});
});