fixes: favoritos y mas cosas

Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
Omar Sánchez Pizarro
2026-01-20 20:06:28 +01:00
parent d5f0ba4e03
commit 05f0455744
20 changed files with 1691 additions and 350 deletions

View File

@@ -77,9 +77,9 @@ export async function authMiddleware(req, res, next) {
export const basicAuthMiddleware = authMiddleware;
// Función para crear sesión
export async function createSession(username) {
export async function createSession(username, fingerprint = null, deviceInfo = null) {
const { createSession: createSessionInDB } = await import('../services/mongodb.js');
return await createSessionInDB(username);
return await createSessionInDB(username, fingerprint, deviceInfo);
}
// Función para invalidar sesión

View File

@@ -0,0 +1,81 @@
import express from 'express';
import { basicAuthMiddleware } from '../middlewares/auth.js';
import { adminAuthMiddleware } from '../middlewares/adminAuth.js';
import { getDB, getAllSessions, deleteSession, getRateLimiterInfo } from '../services/mongodb.js';
const router = express.Router();
// Obtener información del rate limiter y bloqueos (requiere admin)
router.get('/rate-limiter', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => {
try {
const rateLimiterInfo = await getRateLimiterInfo();
res.json(rateLimiterInfo);
} catch (error) {
console.error('Error obteniendo información del rate limiter:', error);
res.status(500).json({ error: error.message });
}
});
// Obtener todas las sesiones (requiere admin)
router.get('/sessions', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => {
try {
const db = getDB();
if (!db) {
return res.status(500).json({ error: 'MongoDB no está disponible' });
}
const sessions = await getAllSessions();
// Agrupar por usuario para estadísticas
const sessionsByUser = {};
let activeSessions = 0;
let expiredSessions = 0;
sessions.forEach(session => {
if (!sessionsByUser[session.username]) {
sessionsByUser[session.username] = 0;
}
sessionsByUser[session.username]++;
if (session.isExpired) {
expiredSessions++;
} else {
activeSessions++;
}
});
res.json({
sessions,
stats: {
total: sessions.length,
active: activeSessions,
expired: expiredSessions,
byUser: sessionsByUser,
},
});
} catch (error) {
console.error('Error obteniendo sesiones:', error);
res.status(500).json({ error: error.message });
}
});
// Eliminar una sesión específica (requiere admin)
router.delete('/sessions/:token', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => {
try {
const { token } = req.params;
const deleted = await deleteSession(token);
if (deleted) {
res.json({ success: true, message: 'Sesión eliminada correctamente' });
} else {
res.status(404).json({ error: 'Sesión no encontrada' });
}
} catch (error) {
console.error('Error eliminando sesión:', error);
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -1,5 +1,5 @@
import express from 'express';
import { getNotifiedArticles, getArticleFacets, searchArticles } from '../services/mongodb.js';
import { getNotifiedArticles, getArticleFacets, searchArticles, getArticle } from '../services/mongodb.js';
import { basicAuthMiddleware } from '../middlewares/auth.js';
const router = express.Router();
@@ -25,6 +25,9 @@ router.get('/', basicAuthMiddleware, async (req, res) => {
if (req.query.worker_name) filter.worker_name = req.query.worker_name;
if (req.query.platform) filter.platform = req.query.platform;
// Siempre incluir el username del usuario autenticado para incluir su is_favorite
filter.currentUsername = user.username;
const articles = await getNotifiedArticles(filter);
const limit = parseInt(req.query.limit) || 100;
@@ -87,6 +90,9 @@ router.get('/search', basicAuthMiddleware, async (req, res) => {
if (req.query.worker_name) filter.worker_name = req.query.worker_name;
if (req.query.platform) filter.platform = req.query.platform;
// Siempre incluir el username del usuario autenticado para incluir su is_favorite
filter.currentUsername = user.username;
// Obtener modo de búsqueda (AND u OR), por defecto AND
const searchMode = (req.query.mode || 'AND').toUpperCase();
@@ -104,5 +110,39 @@ router.get('/search', basicAuthMiddleware, async (req, res) => {
}
});
// Obtener un artículo específico por plataforma e ID (requiere autenticación obligatoria)
router.get('/:platform/:id', basicAuthMiddleware, async (req, res) => {
try {
const { platform, id } = req.params;
// Obtener usuario autenticado (requerido)
const user = req.user;
const isAdmin = user.role === 'admin';
// Obtener el artículo con is_favorite del usuario autenticado
const article = await getArticle(platform, id, user.username);
if (!article) {
return res.status(404).json({ error: 'Artículo no encontrado' });
}
// Si no es admin, verificar que el artículo pertenezca al usuario
// Verificar en user_info si el artículo tiene alguna relación con el usuario
if (!isAdmin) {
const userInfoList = article.user_info || [];
const userHasAccess = userInfoList.some(ui => ui.username === user.username);
// También verificar compatibilidad con estructura antigua
if (!userHasAccess && article.username !== user.username) {
return res.status(403).json({ error: 'No tienes permiso para ver este artículo' });
}
}
res.json(article);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -3,6 +3,7 @@ import bcrypt from 'bcrypt';
import { getDB, getUser, createUser, deleteUser as deleteUserFromDB, getAllUsers, updateUserPassword } 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';
const router = express.Router();
@@ -14,7 +15,7 @@ router.post('/login', async (req, res) => {
return res.status(500).json({ error: 'MongoDB no está disponible' });
}
const { username, password } = req.body;
const { username, password, fingerprint: clientFingerprint, deviceInfo: clientDeviceInfo } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'username y password son requeridos' });
@@ -41,8 +42,11 @@ router.post('/login', async (req, res) => {
return res.status(401).json({ error: 'Invalid credentials', message: 'Usuario o contraseña incorrectos' });
}
// Crear sesión/token
const token = await createSession(username);
// Generar fingerprint del dispositivo
const { fingerprint, deviceInfo } = combineFingerprint(clientFingerprint, clientDeviceInfo, req);
// Crear sesión/token con fingerprint
const token = await createSession(username, fingerprint, deviceInfo);
// Obtener rol del usuario
const userRole = user.role || 'user';

View File

@@ -17,10 +17,14 @@ import configRouter from './routes/config.js';
import telegramRouter from './routes/telegram.js';
import pushRouter from './routes/push.js';
import usersRouter from './routes/users.js';
import adminRouter from './routes/admin.js';
const app = express();
const server = createServer(app);
// Configurar Express para confiar en proxies (necesario para obtener IP real)
app.set('trust proxy', true);
// Middlewares globales
app.use(cors());
app.use(express.json());
@@ -44,6 +48,7 @@ app.use('/api/config', configRouter);
app.use('/api/telegram', telegramRouter);
app.use('/api/push', pushRouter);
app.use('/api/users', usersRouter);
app.use('/api/admin', adminRouter);
// Inicializar servidor
async function startServer() {

View File

@@ -88,6 +88,8 @@ async function createIndexes() {
// Índices para sesiones (con TTL)
await db.collection('sessions').createIndex({ token: 1 }, { unique: true });
await db.collection('sessions').createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
await db.collection('sessions').createIndex({ username: 1, fingerprint: 1 });
await db.collection('sessions').createIndex({ fingerprint: 1 });
// Índices para workers
await db.collection('workers').createIndex({ username: 1 });
@@ -259,6 +261,54 @@ export function getRateLimiter() {
return rateLimiter;
}
// Obtener información del rate limiter y bloqueos (para memoria)
export async function getRateLimiterInfo() {
if (!rateLimiter) {
return {
enabled: false,
type: 'none',
blocks: [],
stats: {
totalBlocks: 0,
activeBlocks: 0,
},
};
}
try {
// RateLimiterMemory no expone directamente las claves bloqueadas
// Necesitamos usar una aproximación diferente
// Por ahora, retornamos información básica
return {
enabled: true,
type: 'memory',
blocks: [],
stats: {
totalBlocks: 0,
activeBlocks: 0,
},
config: {
points: RATE_LIMIT.POINTS,
duration: RATE_LIMIT.DURATION,
blockDuration: RATE_LIMIT.BLOCK_DURATION,
},
note: 'Rate limiter en memoria no expone información detallada de bloqueos',
};
} catch (error) {
console.error('Error obteniendo información del rate limiter:', error.message);
return {
enabled: true,
type: 'memory',
blocks: [],
stats: {
totalBlocks: 0,
activeBlocks: 0,
},
error: error.message,
};
}
}
export function getConfig() {
return config;
}
@@ -303,6 +353,9 @@ export async function getNotifiedArticles(filter = {}) {
.sort({ createdAt: -1, modified_at: -1 })
.toArray();
// Obtener el username del usuario actual para incluir su is_favorite
const currentUsername = filter.currentUsername || filter.username;
// Filtrar y transformar artículos según el usuario solicitado
return articles.map(article => {
// Si hay filtro de username, solo devolver el user_info correspondiente
@@ -350,7 +403,15 @@ export async function getNotifiedArticles(filter = {}) {
if (firstUserInfo) {
result.username = firstUserInfo.username;
result.worker_name = firstUserInfo.worker_name;
result.is_favorite = firstUserInfo.is_favorite || false;
// Si hay un usuario actual, buscar su is_favorite específico, sino usar el del primer user_info
if (currentUsername) {
const currentUserInfo = (article.user_info || []).find(
ui => ui.username === currentUsername
);
result.is_favorite = currentUserInfo ? (currentUserInfo.is_favorite || false) : false;
} else {
result.is_favorite = firstUserInfo.is_favorite || false;
}
result.notifiedAt =
firstUserInfo.notified_at?.getTime?.() ||
(typeof firstUserInfo.notified_at === 'number' ? firstUserInfo.notified_at : null) ||
@@ -579,6 +640,9 @@ export async function searchArticles(searchQuery, filter = {}, searchMode = 'AND
.sort({ createdAt: -1, modified_at: -1 })
.toArray();
// Obtener el username del usuario actual para incluir su is_favorite
const currentUsername = filter.currentUsername || filter.username;
// Transformar artículos según el usuario solicitado (similar a getNotifiedArticles)
return articles.map(article => {
let relevantUserInfo = null;
@@ -619,7 +683,15 @@ export async function searchArticles(searchQuery, filter = {}, searchMode = 'AND
if (firstUserInfo) {
result.username = firstUserInfo.username;
result.worker_name = firstUserInfo.worker_name;
result.is_favorite = firstUserInfo.is_favorite || false;
// Si hay un usuario actual, buscar su is_favorite específico, sino usar el del primer user_info
if (currentUsername) {
const currentUserInfo = (article.user_info || []).find(
ui => ui.username === currentUsername
);
result.is_favorite = currentUserInfo ? (currentUserInfo.is_favorite || false) : false;
} else {
result.is_favorite = firstUserInfo.is_favorite || false;
}
result.notifiedAt =
firstUserInfo.notified_at?.getTime?.() ||
(typeof firstUserInfo.notified_at === 'number' ? firstUserInfo.notified_at : null) ||
@@ -978,7 +1050,7 @@ export async function setTelegramConfig(username, telegramConfig) {
}
// Funciones para sesiones
export async function createSession(username) {
export async function createSession(username, fingerprint = null, deviceInfo = null) {
if (!db) {
throw new Error('MongoDB no está disponible');
}
@@ -989,9 +1061,22 @@ export async function createSession(username) {
try {
const sessionsCollection = db.collection('sessions');
// Si hay fingerprint, eliminar sesiones anteriores del mismo dispositivo/usuario
if (fingerprint) {
await sessionsCollection.deleteMany({
username,
fingerprint,
expiresAt: { $gt: new Date() } // Solo eliminar sesiones activas
});
}
// Crear nueva sesión con fingerprint
await sessionsCollection.insertOne({
token,
username,
fingerprint: fingerprint || null,
deviceInfo: deviceInfo || null,
createdAt: new Date(),
expiresAt,
});
@@ -1046,15 +1131,95 @@ export async function deleteUserSessions(username) {
}
}
export async function getAllSessions() {
if (!db) {
return [];
}
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,
}));
} catch (error) {
console.error('Error obteniendo todas las sesiones:', error.message);
return [];
}
}
// Funciones para artículos
export async function getArticle(platform, id) {
export async function getArticle(platform, id, currentUsername = null) {
if (!db) {
return null;
}
try {
const articlesCollection = db.collection('articles');
return await articlesCollection.findOne({ platform, id });
const article = await articlesCollection.findOne({ platform, id });
if (!article) {
return null;
}
// Transformar el artículo similar a getNotifiedArticles para incluir is_favorite
const result = {
...article,
_id: article._id.toString(),
expiresAt: article.expiresAt?.getTime() || null,
};
const fallbackTime =
article.createdAt?.getTime?.() ||
(typeof article.createdAt === 'number' ? article.createdAt : null) ||
article.modified_at?.getTime?.() ||
null;
// Si hay un usuario actual, buscar su is_favorite específico
if (currentUsername) {
const currentUserInfo = (article.user_info || []).find(
ui => ui.username === currentUsername
);
if (currentUserInfo) {
result.is_favorite = currentUserInfo.is_favorite || false;
result.notifiedAt =
currentUserInfo.notified_at?.getTime?.() ||
(typeof currentUserInfo.notified_at === 'number' ? currentUserInfo.notified_at : null) ||
fallbackTime;
} else {
// Si no hay user_info para este usuario, usar false
result.is_favorite = false;
}
} else {
// Sin usuario específico, usar el primer user_info o datos generales
const firstUserInfo = (article.user_info || [])[0];
if (firstUserInfo) {
result.username = firstUserInfo.username;
result.worker_name = firstUserInfo.worker_name;
result.is_favorite = firstUserInfo.is_favorite || false;
result.notifiedAt =
firstUserInfo.notified_at?.getTime?.() ||
(typeof firstUserInfo.notified_at === 'number' ? firstUserInfo.notified_at : null) ||
fallbackTime;
} else {
// Compatibilidad con estructura antigua
result.username = article.username;
result.worker_name = article.worker_name;
result.is_favorite = article.is_favorite || false;
result.notifiedAt =
article.notifiedAt?.getTime?.() ||
(typeof article.notifiedAt === 'number' ? article.notifiedAt : null) ||
fallbackTime;
}
}
return result;
} catch (error) {
console.error('Error obteniendo artículo:', error.message);
return null;

View File

@@ -1,322 +0,0 @@
import redis from 'redis';
import yaml from 'yaml';
import { readFileSync, existsSync } from 'fs';
import bcrypt from 'bcrypt';
import { RateLimiterRedis } from 'rate-limiter-flexible';
import { PATHS } from '../config/constants.js';
import { RATE_LIMIT } from '../config/constants.js';
import { readJSON } from '../utils/fileUtils.js';
let redisClient = null;
let rateLimiter = null;
let config = null;
// Inicializar Redis si está configurado
export async function initRedis() {
try {
config = yaml.parse(readFileSync(PATHS.CONFIG, 'utf8'));
const cacheConfig = config?.cache;
if (cacheConfig?.type === 'redis') {
const redisConfig = cacheConfig.redis;
// En Docker, usar el nombre del servicio si no se especifica host
const redisHost = process.env.REDIS_HOST || redisConfig.host || 'localhost';
redisClient = redis.createClient({
socket: {
host: redisHost,
port: redisConfig.port || 6379,
},
password: redisConfig.password || undefined,
database: redisConfig.db || 0,
});
redisClient.on('error', (err) => console.error('Redis Client Error', err));
await redisClient.connect();
console.log('✅ Conectado a Redis');
// Inicializar rate limiter con Redis
try {
// Crear un cliente Redis adicional para el rate limiter
const rateLimiterClient = redis.createClient({
socket: {
host: redisHost,
port: redisConfig.port || 6379,
},
password: redisConfig.password || undefined,
database: redisConfig.db || 0,
});
await rateLimiterClient.connect();
rateLimiter = new RateLimiterRedis({
storeClient: rateLimiterClient,
keyPrefix: 'rl:',
points: RATE_LIMIT.POINTS,
duration: RATE_LIMIT.DURATION,
blockDuration: RATE_LIMIT.BLOCK_DURATION,
});
console.log('✅ Rate limiter inicializado con Redis');
} catch (error) {
console.error('Error inicializando rate limiter:', error.message);
}
// Inicializar usuario admin por defecto si no existe
await initDefaultAdmin();
// Migrar workers.json a Redis para admin si no existe
await migrateWorkersFromFile();
} else {
console.log(' Redis no configurado, usando modo memoria');
console.log('⚠️ Rate limiting y autenticación requieren Redis');
}
} catch (error) {
console.error('Error inicializando Redis:', error.message);
}
}
// Inicializar usuario admin por defecto
async function initDefaultAdmin() {
if (!redisClient) return;
try {
const adminExists = await redisClient.exists('user:admin');
if (!adminExists) {
// Crear usuario admin por defecto con contraseña "admin"
// En producción, esto debería cambiarse
const defaultPassword = 'admin';
const hashedPassword = await bcrypt.hash(defaultPassword, 10);
await redisClient.hSet('user:admin', {
username: 'admin',
passwordHash: hashedPassword,
createdAt: new Date().toISOString(),
});
console.log('✅ Usuario admin creado por defecto (usuario: admin, contraseña: admin)');
console.log('⚠️ IMPORTANTE: Cambia la contraseña por defecto en producción');
}
} catch (error) {
console.error('Error inicializando usuario admin:', error.message);
}
}
// Getters
export function getRedisClient() {
return redisClient;
}
export function getRateLimiter() {
return rateLimiter;
}
export function getConfig() {
return config;
}
export function reloadConfig() {
try {
config = yaml.parse(readFileSync(PATHS.CONFIG, 'utf8'));
return config;
} catch (error) {
console.error('Error recargando configuración:', error.message);
return config;
}
}
// Funciones de utilidad para artículos
export async function getNotifiedArticles() {
if (!redisClient) {
return [];
}
try {
const keys = await redisClient.keys('notified:*');
const articles = [];
for (const key of keys) {
const parts = key.split(':');
if (parts.length >= 3) {
const platform = parts[1];
const id = parts.slice(2).join(':');
const ttl = await redisClient.ttl(key);
const value = await redisClient.get(key);
// Intentar parsear como JSON (nuevo formato con toda la info)
let articleData = {};
try {
if (value && value !== '1') {
articleData = JSON.parse(value);
}
} catch (e) {
// Si no es JSON válido, usar valor por defecto
}
articles.push({
platform: articleData.platform || platform,
id: articleData.id || id,
title: articleData.title || null,
description: articleData.description || null,
price: articleData.price || null,
currency: articleData.currency || null,
location: articleData.location || null,
allows_shipping: articleData.allows_shipping !== undefined ? articleData.allows_shipping : null,
url: articleData.url || null,
images: articleData.images || [],
modified_at: articleData.modified_at || null,
username: articleData.username || null,
worker_name: articleData.worker_name || null,
notifiedAt: Date.now() - (7 * 24 * 60 * 60 - ttl) * 1000,
expiresAt: Date.now() + ttl * 1000,
});
}
}
return articles;
} catch (error) {
console.error('Error obteniendo artículos de Redis:', error.message);
return [];
}
}
export async function getFavorites() {
if (!redisClient) {
return [];
}
try {
const keys = await redisClient.keys('notified:*');
const favorites = [];
for (const key of keys) {
const value = await redisClient.get(key);
if (value) {
try {
const articleData = JSON.parse(value);
if (articleData.is_favorite === true) {
favorites.push(articleData);
}
} catch (e) {
// Si no es JSON válido, ignorar
}
}
}
return favorites;
} catch (error) {
console.error('Error obteniendo favoritos de Redis:', error.message);
return [];
}
}
// Inicializar claves conocidas para evitar notificar artículos existentes
export async function initNotifiedArticleKeys() {
if (!redisClient) {
return new Set();
}
try {
const initialKeys = await redisClient.keys('notified:*');
const keysSet = new Set(initialKeys);
console.log(`📋 ${keysSet.size} artículos ya notificados detectados`);
return keysSet;
} catch (error) {
console.error('Error inicializando claves de artículos:', error.message);
return new Set();
}
}
// Funciones para manejar workers por usuario
export async function getWorkers(username) {
if (!redisClient) {
throw new Error('Redis no está disponible');
}
try {
const workersKey = `workers:${username}`;
const workersData = await redisClient.get(workersKey);
if (!workersData) {
// Retornar estructura vacía por defecto
return {
general: {
title_exclude: [],
description_exclude: []
},
items: [],
disabled: []
};
}
return JSON.parse(workersData);
} catch (error) {
console.error(`Error obteniendo workers para ${username}:`, error.message);
throw error;
}
}
export async function setWorkers(username, workers) {
if (!redisClient) {
throw new Error('Redis no está disponible');
}
try {
const workersKey = `workers:${username}`;
const workersData = JSON.stringify(workers);
await redisClient.set(workersKey, workersData);
return true;
} catch (error) {
console.error(`Error guardando workers para ${username}:`, error.message);
throw error;
}
}
// Migrar workers.json a Redis para el usuario admin si no existe
async function migrateWorkersFromFile() {
if (!redisClient) {
return;
}
try {
const adminWorkersKey = 'workers:admin';
const adminWorkersExists = await redisClient.exists(adminWorkersKey);
// Si ya existen workers para admin en Redis, no migrar
if (adminWorkersExists) {
console.log(' Workers de admin ya existen en Redis, omitiendo migración');
return;
}
// Intentar leer workers.json
if (!existsSync(PATHS.WORKERS)) {
console.log(' workers.json no existe, creando estructura vacía para admin');
// Crear estructura vacía por defecto
const defaultWorkers = {
general: {
title_exclude: [],
description_exclude: []
},
items: [],
disabled: []
};
await setWorkers('admin', defaultWorkers);
return;
}
// Leer workers.json y migrar a Redis
const workersData = readJSON(PATHS.WORKERS, {
general: {
title_exclude: [],
description_exclude: []
},
items: [],
disabled: []
});
// Guardar en Redis para admin
await setWorkers('admin', workersData);
console.log(`✅ Workers migrados desde workers.json al usuario admin (${workersData.items?.length || 0} items)`);
} catch (error) {
console.error('Error migrando workers.json a Redis:', error.message);
// No lanzar error, solo registrar
}
}

View File

@@ -0,0 +1,165 @@
import crypto from 'crypto';
/**
* Genera un fingerprint del dispositivo basado en headers HTTP
* @param {Object} req - Request object de Express
* @returns {Object} Objeto con fingerprint hash y metadata del dispositivo
*/
export function generateDeviceFingerprint(req) {
// Extraer información del dispositivo desde headers
const userAgent = req.headers['user-agent'] || '';
const acceptLanguage = req.headers['accept-language'] || '';
const acceptEncoding = req.headers['accept-encoding'] || '';
const accept = req.headers['accept'] || '';
const connection = req.headers['connection'] || '';
const upgradeInsecureRequests = req.headers['upgrade-insecure-requests'] || '';
// IP del cliente (considerando proxies)
const ip = req.ip ||
req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection?.remoteAddress ||
'unknown';
// Crear string combinado para el hash
const fingerprintString = [
userAgent,
acceptLanguage,
acceptEncoding,
accept,
connection,
upgradeInsecureRequests,
ip
].join('|');
// Generar hash SHA-256
const fingerprintHash = crypto
.createHash('sha256')
.update(fingerprintString)
.digest('hex');
// Extraer información legible del User-Agent
const deviceInfo = parseUserAgent(userAgent);
return {
fingerprint: fingerprintHash,
deviceInfo: {
...deviceInfo,
ip: ip,
userAgent: userAgent.substring(0, 200), // Limitar longitud
}
};
}
/**
* Parsea el User-Agent para extraer información legible
* @param {string} userAgent - User-Agent string
* @returns {Object} Información del dispositivo
*/
function parseUserAgent(userAgent) {
if (!userAgent) {
return {
browser: 'Unknown',
browserVersion: '',
os: 'Unknown',
osVersion: '',
device: 'Unknown',
};
}
const ua = userAgent.toLowerCase();
// Detectar navegador
let browser = 'Unknown';
let browserVersion = '';
if (ua.includes('chrome') && !ua.includes('edg') && !ua.includes('opr')) {
browser = 'Chrome';
const match = ua.match(/chrome\/([\d.]+)/);
browserVersion = match ? match[1] : '';
} else if (ua.includes('firefox')) {
browser = 'Firefox';
const match = ua.match(/firefox\/([\d.]+)/);
browserVersion = match ? match[1] : '';
} else if (ua.includes('safari') && !ua.includes('chrome')) {
browser = 'Safari';
const match = ua.match(/version\/([\d.]+)/);
browserVersion = match ? match[1] : '';
} else if (ua.includes('edg')) {
browser = 'Edge';
const match = ua.match(/edg\/([\d.]+)/);
browserVersion = match ? match[1] : '';
} else if (ua.includes('opr')) {
browser = 'Opera';
const match = ua.match(/opr\/([\d.]+)/);
browserVersion = match ? match[1] : '';
}
// Detectar sistema operativo
let os = 'Unknown';
let osVersion = '';
if (ua.includes('windows')) {
os = 'Windows';
if (ua.includes('windows nt 10')) osVersion = '10/11';
else if (ua.includes('windows nt 6.3')) osVersion = '8.1';
else if (ua.includes('windows nt 6.2')) osVersion = '8';
else if (ua.includes('windows nt 6.1')) osVersion = '7';
} else if (ua.includes('mac os x') || ua.includes('macintosh')) {
os = 'macOS';
const match = ua.match(/mac os x ([\d_]+)/);
osVersion = match ? match[1].replace(/_/g, '.') : '';
} else if (ua.includes('linux')) {
os = 'Linux';
} else if (ua.includes('android')) {
os = 'Android';
const match = ua.match(/android ([\d.]+)/);
osVersion = match ? match[1] : '';
} else if (ua.includes('ios') || ua.includes('iphone') || ua.includes('ipad')) {
os = 'iOS';
const match = ua.match(/os ([\d_]+)/);
osVersion = match ? match[1].replace(/_/g, '.') : '';
}
// Detectar tipo de dispositivo
let device = 'Desktop';
if (ua.includes('mobile') || ua.includes('android') || ua.includes('iphone')) {
device = 'Mobile';
} else if (ua.includes('tablet') || ua.includes('ipad')) {
device = 'Tablet';
}
return {
browser,
browserVersion,
os,
osVersion,
device,
};
}
/**
* Genera un fingerprint desde el frontend (cuando se envía desde el cliente)
* @param {string} fingerprintHash - Hash del fingerprint generado en el cliente
* @param {Object} deviceInfo - Información del dispositivo del cliente
* @param {Object} req - Request object de Express
* @returns {Object} Objeto con fingerprint y metadata combinada
*/
export function combineFingerprint(fingerprintHash, deviceInfo, req) {
const serverFingerprint = generateDeviceFingerprint(req);
// Si el cliente envió un fingerprint, combinarlo con el del servidor
// Esto permite usar librerías avanzadas del cliente pero validar con servidor
const combinedFingerprint = fingerprintHash
? crypto.createHash('sha256').update(fingerprintHash + serverFingerprint.fingerprint).digest('hex')
: serverFingerprint.fingerprint;
return {
fingerprint: combinedFingerprint,
deviceInfo: {
...serverFingerprint.deviceInfo,
...deviceInfo, // El del cliente tiene prioridad si existe
}
};
}