Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
Omar Sánchez Pizarro
2026-01-21 10:31:06 +01:00
parent c72ef29319
commit 7289ad6c26
10 changed files with 977 additions and 71 deletions

View File

@@ -30,6 +30,8 @@ router.get('/sessions', basicAuthMiddleware, adminAuthMiddleware, async (req, re
const sessionsByUser = {};
let activeSessions = 0;
let expiredSessions = 0;
let connectedSessions = 0; // Sesiones con actividad reciente
let inactiveSessions = 0; // Sesiones sin actividad pero no expiradas
sessions.forEach(session => {
if (!sessionsByUser[session.username]) {
@@ -41,6 +43,13 @@ router.get('/sessions', basicAuthMiddleware, adminAuthMiddleware, async (req, re
expiredSessions++;
} else {
activeSessions++;
// Contar sesiones conectadas (con actividad reciente)
if (session.isActive) {
connectedSessions++;
} else {
inactiveSessions++;
}
}
});
@@ -50,6 +59,8 @@ router.get('/sessions', basicAuthMiddleware, adminAuthMiddleware, async (req, re
total: sessions.length,
active: activeSessions,
expired: expiredSessions,
connected: connectedSessions,
inactive: inactiveSessions,
byUser: sessionsByUser,
},
});

View File

@@ -1,9 +1,10 @@
import express from 'express';
import bcrypt from 'bcrypt';
import { getDB, getUser, createUser, deleteUser as deleteUserFromDB, getAllUsers, updateUserPassword } from '../services/mongodb.js';
import { getDB, getUser, createUser, deleteUser as deleteUserFromDB, getAllUsers, updateUserPassword, getActiveSessions } from '../services/mongodb.js';
import { basicAuthMiddleware, createSession, invalidateSession, invalidateUserSessions } from '../middlewares/auth.js';
import { adminAuthMiddleware } from '../middlewares/adminAuth.js';
import { combineFingerprint } from '../utils/fingerprint.js';
import { getActiveUsers } from '../services/websocket.js';
const router = express.Router();
@@ -375,5 +376,71 @@ router.delete('/:username', basicAuthMiddleware, adminAuthMiddleware, async (req
}
});
// Obtener usuarios activos/conectados (requiere autenticación)
router.get('/active', basicAuthMiddleware, async (req, res) => {
try {
const db = getDB();
if (!db) {
return res.status(500).json({ error: 'MongoDB no está disponible' });
}
// Obtener usuarios activos del WebSocket
const activeUsersWS = getActiveUsers();
// Obtener sesiones activas de la base de datos
const activeSessions = await getActiveSessions();
// Combinar información y eliminar duplicados
const userMap = new Map();
// Añadir usuarios del WebSocket
for (const user of activeUsersWS) {
if (!userMap.has(user.username)) {
userMap.set(user.username, {
username: user.username,
role: user.role,
status: user.status,
lastActivity: user.lastActivity,
connectedViaWebSocket: true
});
}
}
// Añadir usuarios de sesiones activas (que podrían no estar en WebSocket)
for (const session of activeSessions) {
if (!userMap.has(session.username)) {
const user = await getUser(session.username);
userMap.set(session.username, {
username: session.username,
role: user?.role || 'user',
status: 'active',
lastActivity: session.lastActivity?.toISOString() || null,
connectedViaWebSocket: false,
deviceInfo: session.deviceInfo
});
}
}
// Convertir a array
const activeUsers = Array.from(userMap.values());
// Ordenar por última actividad (más recientes primero)
activeUsers.sort((a, b) => {
const dateA = a.lastActivity ? new Date(a.lastActivity).getTime() : 0;
const dateB = b.lastActivity ? new Date(b.lastActivity).getTime() : 0;
return dateB - dateA;
});
res.json({
activeUsers,
total: activeUsers.length,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Error obteniendo usuarios activos:', error);
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -1101,6 +1101,30 @@ export async function getSession(token) {
}
}
// Actualizar actividad de una sesión
export async function updateSessionActivity(token, isActive = true) {
if (!db) {
return false;
}
try {
const sessionsCollection = db.collection('sessions');
await sessionsCollection.updateOne(
{ token },
{
$set: {
lastActivity: new Date(),
isActive: isActive,
},
}
);
return true;
} catch (error) {
console.error('Error actualizando actividad de sesión:', error.message);
return false;
}
}
export async function deleteSession(token) {
if (!db) {
return false;
@@ -1139,21 +1163,63 @@ export async function getAllSessions() {
try {
const sessionsCollection = db.collection('sessions');
const sessions = await sessionsCollection.find({}).toArray();
return sessions.map(session => ({
token: session.token,
username: session.username,
fingerprint: session.fingerprint || null,
deviceInfo: session.deviceInfo || null,
createdAt: session.createdAt,
expiresAt: session.expiresAt,
isExpired: session.expiresAt ? new Date(session.expiresAt) < new Date() : false,
}));
const now = new Date();
const INACTIVE_TIMEOUT = 5 * 60 * 1000; // 5 minutos
return sessions.map(session => {
const isExpired = session.expiresAt ? new Date(session.expiresAt) < now : false;
const timeSinceActivity = session.lastActivity ? now - new Date(session.lastActivity) : null;
const isActive = session.isActive && timeSinceActivity && timeSinceActivity < INACTIVE_TIMEOUT;
return {
token: session.token,
username: session.username,
fingerprint: session.fingerprint || null,
deviceInfo: session.deviceInfo || null,
createdAt: session.createdAt,
expiresAt: session.expiresAt,
lastActivity: session.lastActivity || null,
isActive: isActive || false,
isExpired: isExpired,
};
});
} catch (error) {
console.error('Error obteniendo todas las sesiones:', error.message);
return [];
}
}
// Obtener sesiones activas (usuarios conectados)
export async function getActiveSessions() {
if (!db) {
return [];
}
try {
const sessionsCollection = db.collection('sessions');
const now = new Date();
const INACTIVE_TIMEOUT = 5 * 60 * 1000; // 5 minutos
const recentTime = new Date(now - INACTIVE_TIMEOUT);
// Buscar sesiones activas (con actividad reciente)
const sessions = await sessionsCollection.find({
isActive: true,
lastActivity: { $gte: recentTime },
expiresAt: { $gt: now }
}).toArray();
return sessions.map(session => ({
username: session.username,
lastActivity: session.lastActivity,
deviceInfo: session.deviceInfo || null,
fingerprint: session.fingerprint || null,
}));
} catch (error) {
console.error('Error obteniendo sesiones activas:', error.message);
return [];
}
}
// Funciones para artículos
export async function getArticle(platform, id, currentUsername = null) {
if (!db) {

View File

@@ -1,11 +1,17 @@
import { WebSocketServer } from 'ws';
import { getDB, getSession, getUser, deleteSession as deleteSessionFromDB } from './mongodb.js';
import { getDB, getSession, getUser, deleteSession as deleteSessionFromDB, updateSessionActivity } from './mongodb.js';
let wss = null;
// Duración de la sesión en milisegundos (24 horas)
const SESSION_DURATION = 24 * 60 * 60 * 1000;
// Tiempo de inactividad para marcar como inactivo (5 minutos)
const INACTIVE_TIMEOUT = 5 * 60 * 1000;
// Intervalo para limpiar conexiones inactivas (1 minuto)
const CLEANUP_INTERVAL = 60 * 1000;
// Inicializar WebSocket Server
export function initWebSocket(server) {
wss = new WebSocketServer({ server, path: '/ws' });
@@ -73,18 +79,90 @@ export function initWebSocket(server) {
role: user.role || 'user',
token: token
};
ws.isAlive = true;
ws.lastActivity = new Date();
// Actualizar estado de conexión en la base de datos
await updateSessionActivity(token, true);
console.log(`Cliente WebSocket conectado: ${session.username} (${user.role || 'user'})`);
// Enviar confirmación de conexión
ws.send(JSON.stringify({
type: 'connection',
status: 'connected',
timestamp: new Date().toISOString()
}));
// Broadcast a otros clientes que este usuario se conectó
broadcastUserStatus(session.username, 'online');
} catch (error) {
console.error('Error validando token WebSocket:', error);
ws.close(1011, 'Error de autenticación');
return;
}
ws.on('close', () => {
// Manejar mensajes del cliente
ws.on('message', async (data) => {
try {
const message = JSON.parse(data.toString());
// Manejar diferentes tipos de mensajes
switch (message.type) {
case 'ping':
case 'heartbeat':
// Actualizar actividad
ws.isAlive = true;
ws.lastActivity = new Date();
// Actualizar en base de datos
if (ws.user && ws.user.token) {
await updateSessionActivity(ws.user.token, true);
}
// Responder con pong
ws.send(JSON.stringify({
type: 'pong',
timestamp: new Date().toISOString()
}));
break;
case 'activity':
// El usuario realizó alguna actividad (click, scroll, etc.)
ws.isAlive = true;
ws.lastActivity = new Date();
if (ws.user && ws.user.token) {
await updateSessionActivity(ws.user.token, true);
}
break;
default:
console.log(`Mensaje WebSocket no manejado: ${message.type}`);
}
} catch (error) {
console.error('Error procesando mensaje WebSocket:', error);
}
});
// Manejar pong nativo de WebSocket
ws.on('pong', () => {
ws.isAlive = true;
ws.lastActivity = new Date();
});
ws.on('close', async () => {
if (ws.user) {
console.log(`Cliente WebSocket desconectado: ${ws.user.username}`);
// Actualizar estado en base de datos
if (ws.user.token) {
await updateSessionActivity(ws.user.token, false);
}
// Broadcast a otros clientes que este usuario se desconectó
broadcastUserStatus(ws.user.username, 'offline');
} else {
console.log('Cliente WebSocket desconectado');
}
@@ -95,9 +173,81 @@ export function initWebSocket(server) {
});
});
// Iniciar limpieza periódica de conexiones inactivas
startCleanupInterval();
return wss;
}
// Limpiar conexiones inactivas periódicamente
let cleanupIntervalId = null;
function startCleanupInterval() {
if (cleanupIntervalId) {
clearInterval(cleanupIntervalId);
}
cleanupIntervalId = setInterval(() => {
if (!wss) return;
const now = new Date();
wss.clients.forEach(async (ws) => {
// Si el cliente no ha respondido, marcarlo como inactivo
if (ws.isAlive === false) {
console.log(`Cerrando conexión inactiva: ${ws.user?.username || 'desconocido'}`);
// Actualizar estado en base de datos antes de cerrar
if (ws.user && ws.user.token) {
await updateSessionActivity(ws.user.token, false);
}
return ws.terminate();
}
// Verificar si ha pasado el timeout de inactividad
if (ws.lastActivity && (now - ws.lastActivity) > INACTIVE_TIMEOUT) {
console.log(`Usuario inactivo detectado: ${ws.user?.username || 'desconocido'}`);
// Actualizar estado en base de datos pero mantener conexión
if (ws.user && ws.user.token) {
await updateSessionActivity(ws.user.token, false);
}
}
// Marcar como no vivo y enviar ping
ws.isAlive = false;
ws.ping();
});
}, CLEANUP_INTERVAL);
}
// Detener limpieza
function stopCleanupInterval() {
if (cleanupIntervalId) {
clearInterval(cleanupIntervalId);
cleanupIntervalId = null;
}
}
// Broadcast del estado de un usuario específico
function broadcastUserStatus(username, status) {
if (!wss) return;
const message = JSON.stringify({
type: 'user_status',
username: username,
status: status, // 'online' | 'offline' | 'inactive'
timestamp: new Date().toISOString()
});
wss.clients.forEach((client) => {
if (client.readyState === 1) { // WebSocket.OPEN
client.send(message);
}
});
}
// Broadcast a todos los clientes WebSocket
export function broadcast(data) {
if (!wss) return;
@@ -114,3 +264,51 @@ export function getWebSocketServer() {
return wss;
}
// Obtener usuarios activos conectados
export function getActiveUsers() {
if (!wss) return [];
const activeUsers = [];
const now = new Date();
wss.clients.forEach((ws) => {
if (ws.user && ws.readyState === 1) { // WebSocket.OPEN
const isActive = ws.lastActivity && (now - ws.lastActivity) <= INACTIVE_TIMEOUT;
activeUsers.push({
username: ws.user.username,
role: ws.user.role,
status: isActive ? 'active' : 'inactive',
lastActivity: ws.lastActivity?.toISOString() || null,
connectedAt: ws.lastActivity?.toISOString() || null
});
}
});
// Eliminar duplicados (un usuario puede tener múltiples conexiones)
const uniqueUsers = [];
const seenUsernames = new Set();
for (const user of activeUsers) {
if (!seenUsernames.has(user.username)) {
seenUsernames.add(user.username);
uniqueUsers.push(user);
}
}
return uniqueUsers;
}
// Cerrar todas las conexiones y limpiar
export function closeWebSocket() {
stopCleanupInterval();
if (wss) {
wss.clients.forEach((ws) => {
ws.close(1001, 'Servidor cerrando');
});
wss.close();
wss = null;
}
}