From 05f045574421d431b38403dbbaac234114865b8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20S=C3=A1nchez=20Pizarro?= Date: Tue, 20 Jan 2026 20:06:28 +0100 Subject: [PATCH] fixes: favoritos y mas cosas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Omar Sánchez Pizarro --- web/backend/middlewares/auth.js | 4 +- web/backend/routes/admin.js | 81 +++ web/backend/routes/articles.js | 42 +- web/backend/routes/users.js | 10 +- web/backend/server.js | 5 + web/backend/services/mongodb.js | 175 ++++++- web/backend/services/redis.js | 322 ------------ web/backend/utils/fingerprint.js | 165 ++++++ web/frontend/package-lock.json | 7 + web/frontend/package.json | 14 +- web/frontend/src/App.vue | 4 + web/frontend/src/components/ArticleCard.vue | 40 +- .../src/components/ToastNotification.vue | 22 +- web/frontend/src/main.js | 6 + web/frontend/src/services/api.js | 22 + web/frontend/src/services/auth.js | 22 +- web/frontend/src/services/fingerprint.js | 154 ++++++ web/frontend/src/views/ArticleDetail.vue | 476 ++++++++++++++++++ web/frontend/src/views/RateLimiter.vue | 209 ++++++++ web/frontend/src/views/Sessions.vue | 261 ++++++++++ 20 files changed, 1691 insertions(+), 350 deletions(-) create mode 100644 web/backend/routes/admin.js delete mode 100644 web/backend/services/redis.js create mode 100644 web/backend/utils/fingerprint.js create mode 100644 web/frontend/src/services/fingerprint.js create mode 100644 web/frontend/src/views/ArticleDetail.vue create mode 100644 web/frontend/src/views/RateLimiter.vue create mode 100644 web/frontend/src/views/Sessions.vue diff --git a/web/backend/middlewares/auth.js b/web/backend/middlewares/auth.js index d69a410..61f236a 100644 --- a/web/backend/middlewares/auth.js +++ b/web/backend/middlewares/auth.js @@ -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 diff --git a/web/backend/routes/admin.js b/web/backend/routes/admin.js new file mode 100644 index 0000000..0e31d44 --- /dev/null +++ b/web/backend/routes/admin.js @@ -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; + diff --git a/web/backend/routes/articles.js b/web/backend/routes/articles.js index bb116d3..32f90c3 100644 --- a/web/backend/routes/articles.js +++ b/web/backend/routes/articles.js @@ -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; diff --git a/web/backend/routes/users.js b/web/backend/routes/users.js index 8d4ab46..29d82f9 100644 --- a/web/backend/routes/users.js +++ b/web/backend/routes/users.js @@ -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'; diff --git a/web/backend/server.js b/web/backend/server.js index be3c659..d0c05d1 100644 --- a/web/backend/server.js +++ b/web/backend/server.js @@ -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() { diff --git a/web/backend/services/mongodb.js b/web/backend/services/mongodb.js index c69b142..98cca41 100644 --- a/web/backend/services/mongodb.js +++ b/web/backend/services/mongodb.js @@ -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; diff --git a/web/backend/services/redis.js b/web/backend/services/redis.js deleted file mode 100644 index e84f5c9..0000000 --- a/web/backend/services/redis.js +++ /dev/null @@ -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 - } -} - diff --git a/web/backend/utils/fingerprint.js b/web/backend/utils/fingerprint.js new file mode 100644 index 0000000..e9d151b --- /dev/null +++ b/web/backend/utils/fingerprint.js @@ -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 + } + }; +} + diff --git a/web/frontend/package-lock.json b/web/frontend/package-lock.json index 28ef621..2daf3e3 100644 --- a/web/frontend/package-lock.json +++ b/web/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "wallabicher-frontend", "version": "1.0.0", "dependencies": { + "@fingerprintjs/fingerprintjs": "^5.0.1", "@heroicons/vue": "^2.1.1", "axios": "^1.6.0", "chart.js": "^4.4.0", @@ -473,6 +474,12 @@ "node": ">=12" } }, + "node_modules/@fingerprintjs/fingerprintjs": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@fingerprintjs/fingerprintjs/-/fingerprintjs-5.0.1.tgz", + "integrity": "sha512-KbaeE/rk2WL8MfpRP6jTI4lSr42SJPjvkyrjP3QU6uUDkOMWWYC2Ts1sNSYcegHC8avzOoYTHBj+2fTqvZWQBA==", + "license": "MIT" + }, "node_modules/@heroicons/vue": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz", diff --git a/web/frontend/package.json b/web/frontend/package.json index 17eca20..d6df72d 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -8,19 +8,19 @@ "preview": "vite preview" }, "dependencies": { - "vue": "^3.3.4", - "vue-router": "^4.2.5", - "axios": "^1.6.0", + "@fingerprintjs/fingerprintjs": "^5.0.1", "@heroicons/vue": "^2.1.1", + "axios": "^1.6.0", "chart.js": "^4.4.0", - "vue-chartjs": "^5.2.0" + "vue": "^3.3.4", + "vue-chartjs": "^5.2.0", + "vue-router": "^4.2.5" }, "devDependencies": { "@vitejs/plugin-vue": "^4.4.0", - "vite": "^5.0.0", "autoprefixer": "^10.4.16", "postcss": "^8.4.31", - "tailwindcss": "^3.3.5" + "tailwindcss": "^3.3.5", + "vite": "^5.0.0" } } - diff --git a/web/frontend/src/App.vue b/web/frontend/src/App.vue index fc95a47..dce5ecd 100644 --- a/web/frontend/src/App.vue +++ b/web/frontend/src/App.vue @@ -166,6 +166,8 @@ import { Cog6ToothIcon, UserGroupIcon, DocumentMagnifyingGlassIcon, + ShieldExclamationIcon, + ClockIcon, Bars3Icon, XMarkIcon, SunIcon, @@ -187,6 +189,8 @@ const allNavItems = [ { path: '/workers', name: 'Workers', icon: Cog6ToothIcon, adminOnly: false }, { path: '/users', name: 'Usuarios', icon: UserGroupIcon, adminOnly: false }, { path: '/logs', name: 'Logs', icon: DocumentMagnifyingGlassIcon, adminOnly: true }, + { path: '/rate-limiter', name: 'Rate Limiter', icon: ShieldExclamationIcon, adminOnly: true }, + { path: '/sessions', name: 'Sesiones', icon: ClockIcon, adminOnly: true }, ]; const router = useRouter(); diff --git a/web/frontend/src/components/ArticleCard.vue b/web/frontend/src/components/ArticleCard.vue index 4f3d3f3..6d5f552 100644 --- a/web/frontend/src/components/ArticleCard.vue +++ b/web/frontend/src/components/ArticleCard.vue @@ -9,7 +9,11 @@
-
+
-
+
Sin imagen
@@ -51,7 +59,11 @@
-

+

{{ article.title || 'Sin título' }}

@@ -130,11 +142,14 @@ diff --git a/web/frontend/src/main.js b/web/frontend/src/main.js index f457b8d..66afc98 100644 --- a/web/frontend/src/main.js +++ b/web/frontend/src/main.js @@ -3,10 +3,13 @@ import { createRouter, createWebHistory } from 'vue-router'; import App from './App.vue'; import Dashboard from './views/Dashboard.vue'; import Articles from './views/Articles.vue'; +import ArticleDetail from './views/ArticleDetail.vue'; import Favorites from './views/Favorites.vue'; import Workers from './views/Workers.vue'; import Users from './views/Users.vue'; import Logs from './views/Logs.vue'; +import RateLimiter from './views/RateLimiter.vue'; +import Sessions from './views/Sessions.vue'; import Login from './views/Login.vue'; import './style.css'; import authService from './services/auth'; @@ -15,10 +18,13 @@ const routes = [ { path: '/login', component: Login, name: 'login' }, { path: '/', component: Dashboard, meta: { requiresAuth: true } }, { path: '/articles', component: Articles, meta: { requiresAuth: true } }, + { path: '/articles/:platform/:id', component: ArticleDetail, meta: { requiresAuth: true } }, { path: '/favorites', component: Favorites, meta: { requiresAuth: true } }, { path: '/workers', component: Workers, meta: { requiresAuth: true } }, { path: '/users', component: Users, meta: { requiresAuth: true } }, { path: '/logs', component: Logs, meta: { requiresAuth: true } }, + { path: '/rate-limiter', component: RateLimiter, meta: { requiresAuth: true } }, + { path: '/sessions', component: Sessions, meta: { requiresAuth: true } }, ]; const router = createRouter({ diff --git a/web/frontend/src/services/api.js b/web/frontend/src/services/api.js index 5ff6d14..233ed81 100644 --- a/web/frontend/src/services/api.js +++ b/web/frontend/src/services/api.js @@ -97,6 +97,11 @@ export default { return response.data; }, + async getArticle(platform, id) { + const response = await api.get(`/articles/${platform}/${id}`); + return response.data; + }, + // Logs async getLogs(limit = 500, sinceLine = null) { const params = { limit }; @@ -149,5 +154,22 @@ export default { const response = await api.post('/users/change-password', passwordData); return response.data; }, + + // Admin - Rate Limiter + async getRateLimiterInfo() { + const response = await api.get('/admin/rate-limiter'); + return response.data; + }, + + // Admin - Sessions + async getSessions() { + const response = await api.get('/admin/sessions'); + return response.data; + }, + + async deleteSession(token) { + const response = await api.delete(`/admin/sessions/${token}`); + return response.data; + }, }; diff --git a/web/frontend/src/services/auth.js b/web/frontend/src/services/auth.js index 7946846..f916ba1 100644 --- a/web/frontend/src/services/auth.js +++ b/web/frontend/src/services/auth.js @@ -1,4 +1,5 @@ // Servicio de autenticación para gestionar tokens +import { getDeviceFingerprint } from './fingerprint.js'; const AUTH_STORAGE_KEY = 'wallabicher_token'; const USERNAME_STORAGE_KEY = 'wallabicher_username'; @@ -109,12 +110,31 @@ class AuthService { // Hacer login (llamar al endpoint de login) async login(username, password) { try { + // Obtener fingerprint del dispositivo + let fingerprintData = null; + try { + fingerprintData = await getDeviceFingerprint(); + } catch (error) { + console.warn('Error obteniendo fingerprint, continuando sin él:', error); + } + + const requestBody = { + username, + password, + }; + + // Agregar fingerprint si está disponible + if (fingerprintData) { + requestBody.fingerprint = fingerprintData.fingerprint; + requestBody.deviceInfo = fingerprintData.deviceInfo; + } + const response = await fetch('/api/users/login', { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ username, password }), + body: JSON.stringify(requestBody), }); const data = await response.json(); diff --git a/web/frontend/src/services/fingerprint.js b/web/frontend/src/services/fingerprint.js new file mode 100644 index 0000000..b4da9e8 --- /dev/null +++ b/web/frontend/src/services/fingerprint.js @@ -0,0 +1,154 @@ +import FingerprintJS from '@fingerprintjs/fingerprintjs'; + +let fpPromise = null; +let cachedFingerprint = null; +let cachedDeviceInfo = null; + +/** + * Inicializa FingerprintJS (solo una vez) + */ +function initFingerprintJS() { + if (!fpPromise) { + fpPromise = FingerprintJS.load(); + } + return fpPromise; +} + +/** + * Obtiene el fingerprint del dispositivo + * @returns {Promise<{fingerprint: string, deviceInfo: Object}>} + */ +export async function getDeviceFingerprint() { + // Si ya tenemos el fingerprint en caché, devolverlo + if (cachedFingerprint && cachedDeviceInfo) { + return { + fingerprint: cachedFingerprint, + deviceInfo: cachedDeviceInfo, + }; + } + + try { + const fp = await initFingerprintJS(); + const result = await fp.get(); + + // Extraer información del dispositivo desde los componentes + const deviceInfo = extractDeviceInfo(result.components); + + cachedFingerprint = result.visitorId; + cachedDeviceInfo = deviceInfo; + + return { + fingerprint: result.visitorId, + deviceInfo: deviceInfo, + }; + } catch (error) { + console.error('Error obteniendo fingerprint:', error); + // Fallback: generar un fingerprint básico + return { + fingerprint: generateFallbackFingerprint(), + deviceInfo: { + browser: navigator.userAgent.includes('Chrome') ? 'Chrome' : + navigator.userAgent.includes('Firefox') ? 'Firefox' : + navigator.userAgent.includes('Safari') ? 'Safari' : 'Unknown', + os: navigator.platform, + device: 'Unknown', + }, + }; + } +} + +/** + * Extrae información legible del dispositivo desde los componentes de FingerprintJS + * @param {Object} components - Componentes de FingerprintJS + * @returns {Object} Información del dispositivo + */ +function extractDeviceInfo(components) { + const info = { + browser: 'Unknown', + browserVersion: '', + os: 'Unknown', + osVersion: '', + device: 'Unknown', + screenResolution: '', + timezone: '', + language: navigator.language || '', + }; + + // Información del navegador + if (components.browserName) { + info.browser = components.browserName.value || 'Unknown'; + } + if (components.browserVersion) { + info.browserVersion = components.browserVersion.value || ''; + } + + // Información del sistema operativo + if (components.os) { + info.os = components.os.value || 'Unknown'; + } + if (components.osVersion) { + info.osVersion = components.osVersion.value || ''; + } + + // Información del dispositivo + if (components.deviceMemory) { + info.device = components.deviceMemory.value ? 'Desktop' : 'Mobile'; + } + if (components.platform) { + const platform = components.platform.value?.toLowerCase() || ''; + if (platform.includes('mobile') || platform.includes('android') || platform.includes('iphone')) { + info.device = 'Mobile'; + } else if (platform.includes('tablet') || platform.includes('ipad')) { + info.device = 'Tablet'; + } else { + info.device = 'Desktop'; + } + } + + // Resolución de pantalla + if (components.screenResolution) { + const res = components.screenResolution.value; + if (res && res.length >= 2) { + info.screenResolution = `${res[0]}x${res[1]}`; + } + } + + // Zona horaria + if (components.timezone) { + info.timezone = components.timezone.value || ''; + } + + return info; +} + +/** + * Genera un fingerprint básico como fallback + * @returns {string} Hash del fingerprint + */ +function generateFallbackFingerprint() { + const data = [ + navigator.userAgent, + navigator.language, + navigator.platform, + screen.width + 'x' + screen.height, + new Date().getTimezoneOffset(), + ].join('|'); + + // Simple hash (no usar en producción, solo como fallback) + let hash = 0; + for (let i = 0; i < data.length; i++) { + const char = data.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash).toString(36); +} + +/** + * Limpia el caché del fingerprint (útil para testing) + */ +export function clearFingerprintCache() { + cachedFingerprint = null; + cachedDeviceInfo = null; +} + diff --git a/web/frontend/src/views/ArticleDetail.vue b/web/frontend/src/views/ArticleDetail.vue new file mode 100644 index 0000000..dad62a3 --- /dev/null +++ b/web/frontend/src/views/ArticleDetail.vue @@ -0,0 +1,476 @@ + + + + + diff --git a/web/frontend/src/views/RateLimiter.vue b/web/frontend/src/views/RateLimiter.vue new file mode 100644 index 0000000..f897b4b --- /dev/null +++ b/web/frontend/src/views/RateLimiter.vue @@ -0,0 +1,209 @@ + + + + diff --git a/web/frontend/src/views/Sessions.vue b/web/frontend/src/views/Sessions.vue new file mode 100644 index 0000000..195a705 --- /dev/null +++ b/web/frontend/src/views/Sessions.vue @@ -0,0 +1,261 @@ + + + +