diff --git a/web/backend/middlewares/adminAuth.js b/web/backend/middlewares/adminAuth.js new file mode 100644 index 0000000..7955e86 --- /dev/null +++ b/web/backend/middlewares/adminAuth.js @@ -0,0 +1,30 @@ +import { getUser } from '../services/mongodb.js'; + +// Middleware para verificar que el usuario es administrador +export async function adminAuthMiddleware(req, res, next) { + // Debe estar autenticado primero (requiere basicAuthMiddleware antes) + if (!req.user || !req.user.username) { + return res.status(401).json({ error: 'Authentication required', message: 'Se requiere autenticación' }); + } + + try { + const user = await getUser(req.user.username); + + if (!user) { + return res.status(401).json({ error: 'Invalid user', message: 'Usuario no encontrado' }); + } + + const userRole = user.role || 'user'; + + if (userRole !== 'admin') { + return res.status(403).json({ error: 'Forbidden', message: 'Se requieren permisos de administrador' }); + } + + // Usuario es admin, continuar + next(); + } catch (error) { + console.error('Error verificando permisos de admin:', error); + res.status(500).json({ error: 'Internal server error', message: 'Error verificando permisos' }); + } +} + diff --git a/web/backend/middlewares/auth.js b/web/backend/middlewares/auth.js index 82e35c5..d69a410 100644 --- a/web/backend/middlewares/auth.js +++ b/web/backend/middlewares/auth.js @@ -1,8 +1,8 @@ import crypto from 'crypto'; -import { getRedisClient } from '../services/redis.js'; +import { getDB, getSession, deleteSession as deleteSessionFromDB, deleteUserSessions as deleteUserSessionsFromDB, getUser } from '../services/mongodb.js'; -// Duración de la sesión en segundos (24 horas) -const SESSION_DURATION = 24 * 60 * 60; +// Duración de la sesión en milisegundos (24 horas) +const SESSION_DURATION = 24 * 60 * 60 * 1000; // Generar token seguro function generateToken() { @@ -11,10 +11,10 @@ function generateToken() { // Autenticación por token Middleware export async function authMiddleware(req, res, next) { - const redisClient = getRedisClient(); + const db = getDB(); - if (!redisClient) { - return res.status(500).json({ error: 'Redis no está disponible. La autenticación requiere Redis.' }); + if (!db) { + return res.status(500).json({ error: 'MongoDB no está disponible. La autenticación requiere MongoDB.' }); } const authHeader = req.headers.authorization; @@ -30,32 +30,41 @@ export async function authMiddleware(req, res, next) { } try { - // Verificar token en Redis - const sessionKey = `session:${token}`; - const sessionData = await redisClient.get(sessionKey); + // Verificar token en MongoDB + const session = await getSession(token); - if (!sessionData) { + if (!session) { return res.status(401).json({ error: 'Invalid token', message: 'Token inválido o sesión expirada' }); } - // Parsear datos de sesión - const session = JSON.parse(sessionData); + // Verificar que la sesión no haya expirado + if (session.expiresAt && new Date(session.expiresAt) < new Date()) { + await deleteSessionFromDB(token); + return res.status(401).json({ error: 'Invalid token', message: 'Sesión expirada' }); + } // Verificar que el usuario aún existe - const userKey = `user:${session.username}`; - const userExists = await redisClient.exists(userKey); + const user = await getUser(session.username); - if (!userExists) { + if (!user) { // Eliminar sesión si el usuario ya no existe - await redisClient.del(sessionKey); + await deleteSessionFromDB(token); return res.status(401).json({ error: 'Invalid token', message: 'Usuario no encontrado' }); } - // Actualizar TTL de la sesión (refresh) - await redisClient.expire(sessionKey, SESSION_DURATION); + // Actualizar expiración de la sesión (refresh) + const sessionsCollection = db.collection('sessions'); + const newExpiresAt = new Date(Date.now() + SESSION_DURATION); + await sessionsCollection.updateOne( + { token }, + { $set: { expiresAt: newExpiresAt } } + ); - // Autenticación exitosa - req.user = { username: session.username }; + // Autenticación exitosa - incluir rol en req.user + req.user = { + username: session.username, + role: user.role || 'user' // Por defecto 'user' si no tiene rol + }; req.token = token; next(); } catch (error) { @@ -69,35 +78,14 @@ export const basicAuthMiddleware = authMiddleware; // Función para crear sesión export async function createSession(username) { - const redisClient = getRedisClient(); - if (!redisClient) { - throw new Error('Redis no está disponible'); - } - - const token = generateToken(); - const sessionKey = `session:${token}`; - const sessionData = { - username, - createdAt: new Date().toISOString(), - }; - - // Almacenar sesión en Redis con TTL - await redisClient.setEx(sessionKey, SESSION_DURATION, JSON.stringify(sessionData)); - - return token; + const { createSession: createSessionInDB } = await import('../services/mongodb.js'); + return await createSessionInDB(username); } // Función para invalidar sesión export async function invalidateSession(token) { - const redisClient = getRedisClient(); - if (!redisClient) { - return false; - } - try { - const sessionKey = `session:${token}`; - await redisClient.del(sessionKey); - return true; + return await deleteSessionFromDB(token); } catch (error) { console.error('Error invalidando sesión:', error); return false; @@ -106,31 +94,11 @@ export async function invalidateSession(token) { // Función para invalidar todas las sesiones de un usuario export async function invalidateUserSessions(username) { - const redisClient = getRedisClient(); - if (!redisClient) { - return false; - } - try { - // Buscar todas las sesiones del usuario - const keys = await redisClient.keys('session:*'); - let count = 0; - - for (const key of keys) { - const sessionData = await redisClient.get(key); - if (sessionData) { - const session = JSON.parse(sessionData); - if (session.username === username) { - await redisClient.del(key); - count++; - } - } - } - - return count; + return await deleteUserSessionsFromDB(username); } catch (error) { console.error('Error invalidando sesiones del usuario:', error); - return false; + return 0; } } diff --git a/web/backend/middlewares/rateLimit.js b/web/backend/middlewares/rateLimit.js index 9034e45..ec74819 100644 --- a/web/backend/middlewares/rateLimit.js +++ b/web/backend/middlewares/rateLimit.js @@ -1,4 +1,4 @@ -import { getRateLimiter } from '../services/redis.js'; +import { getRateLimiter } from '../services/mongodb.js'; // Rate Limiter Middleware export async function rateLimitMiddleware(req, res, next) { diff --git a/web/backend/package-lock.json b/web/backend/package-lock.json index 1a4c637..9f134ec 100644 --- a/web/backend/package-lock.json +++ b/web/backend/package-lock.json @@ -13,8 +13,8 @@ "chokidar": "^3.5.3", "cors": "^2.8.5", "express": "^4.18.2", + "mongodb": "^6.3.0", "rate-limiter-flexible": "^5.0.3", - "redis": "^4.6.10", "web-push": "^3.6.7", "ws": "^8.14.2", "yaml": "^2.3.4" @@ -88,63 +88,28 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/@redis/bloom": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", - "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", - "license": "MIT", - "peerDependencies": { - "@redis/client": "^1.0.0" - } - }, - "node_modules/@redis/client": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", - "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.5.tgz", + "integrity": "sha512-k64Lbyb7ycCSXHSLzxVdb2xsKGPMvYZfCICXvDsI8Z65CeWQzTEKS4YmGbnqw+U9RBvLPTsB6UCmwkgsDTGWIw==", "license": "MIT", "dependencies": { - "cluster-key-slot": "1.1.2", - "generic-pool": "3.9.0", - "yallist": "4.0.0" - }, - "engines": { - "node": ">=14" + "sparse-bitfield": "^3.0.3" } }, - "node_modules/@redis/graph": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", - "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", - "license": "MIT", - "peerDependencies": { - "@redis/client": "^1.0.0" - } + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" }, - "node_modules/@redis/json": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", - "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", "license": "MIT", - "peerDependencies": { - "@redis/client": "^1.0.0" - } - }, - "node_modules/@redis/search": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", - "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", - "license": "MIT", - "peerDependencies": { - "@redis/client": "^1.0.0" - } - }, - "node_modules/@redis/time-series": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", - "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", - "license": "MIT", - "peerDependencies": { - "@redis/client": "^1.0.0" + "dependencies": { + "@types/webidl-conversions": "*" } }, "node_modules/abbrev": { @@ -319,6 +284,15 @@ "node": ">=8" } }, + "node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -396,15 +370,6 @@ "node": ">=10" } }, - "node_modules/cluster-key-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", - "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -775,15 +740,6 @@ "node": ">=10" } }, - "node_modules/generic-pool": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", - "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1113,6 +1069,12 @@ "node": ">= 0.6" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, "node_modules/merge-descriptors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", @@ -1237,6 +1199,96 @@ "node": ">=10" } }, + "node_modules/mongodb": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.21.0.tgz", + "integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.3.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/mongodb-connection-string-url/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/mongodb-connection-string-url/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/mongodb-connection-string-url/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -1406,6 +1458,15 @@ "node": ">= 0.10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", @@ -1477,23 +1538,6 @@ "node": ">=8.10.0" } }, - "node_modules/redis": { - "version": "4.7.1", - "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", - "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", - "license": "MIT", - "workspaces": [ - "./packages/*" - ], - "dependencies": { - "@redis/bloom": "1.2.0", - "@redis/client": "1.6.1", - "@redis/graph": "1.1.1", - "@redis/json": "1.0.7", - "@redis/search": "1.2.0", - "@redis/time-series": "1.1.0" - } - }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -1683,6 +1727,15 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/web/backend/package.json b/web/backend/package.json index c29031d..2f03ce6 100644 --- a/web/backend/package.json +++ b/web/backend/package.json @@ -21,7 +21,7 @@ "cors": "^2.8.5", "express": "^4.18.2", "rate-limiter-flexible": "^5.0.3", - "redis": "^4.6.10", + "mongodb": "^6.3.0", "web-push": "^3.6.7", "ws": "^8.14.2", "yaml": "^2.3.4" diff --git a/web/backend/routes/articles.js b/web/backend/routes/articles.js index 771f534..d5e08c4 100644 --- a/web/backend/routes/articles.js +++ b/web/backend/routes/articles.js @@ -1,16 +1,50 @@ import express from 'express'; -import { getNotifiedArticles } from '../services/redis.js'; +import { getNotifiedArticles } from '../services/mongodb.js'; +import { basicAuthMiddleware } from '../middlewares/auth.js'; const router = express.Router(); -// Obtener artículos notificados -router.get('/', async (req, res) => { +router.delete('/', async (req, res) => { try { - const articles = await getNotifiedArticles(); + const count = await clearAllArticles(); + res.json({ success: true, message: `Todos los artículos eliminados: ${count} artículos borrados`, count }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Obtener artículos notificados (requiere autenticación obligatoria) +router.get('/', basicAuthMiddleware, async (req, res) => { + try { + // Obtener usuario autenticado (requerido) + const user = req.user; + const isAdmin = user.role === 'admin'; + + // Construir filtro + const filter = {}; + + // Si no es admin, solo mostrar sus artículos + if (!isAdmin) { + filter.username = user.username; + } else if (req.query.username) { + // Admin puede filtrar por username + filter.username = req.query.username; + } + + if (req.query.worker_name) filter.worker_name = req.query.worker_name; + if (req.query.platform) filter.platform = req.query.platform; + + const articles = await getNotifiedArticles(filter); + const limit = parseInt(req.query.limit) || 100; const offset = parseInt(req.query.offset) || 0; - const sorted = articles.sort((a, b) => b.notifiedAt - a.notifiedAt); + // Los artículos ya vienen ordenados de MongoDB, pero asegurémonos + const sorted = articles.sort((a, b) => { + const aTime = typeof a.notifiedAt === 'number' ? a.notifiedAt : new Date(a.notifiedAt).getTime(); + const bTime = typeof b.notifiedAt === 'number' ? b.notifiedAt : new Date(b.notifiedAt).getTime(); + return bTime - aTime; + }); const paginated = sorted.slice(offset, offset + limit); res.json({ @@ -24,16 +58,34 @@ router.get('/', async (req, res) => { } }); -// Buscar artículos en Redis -router.get('/search', async (req, res) => { +// Buscar artículos en MongoDB (requiere autenticación obligatoria) +router.get('/search', basicAuthMiddleware, async (req, res) => { try { const query = req.query.q || ''; if (!query.trim()) { return res.json({ articles: [], total: 0 }); } + // Obtener usuario autenticado (requerido) + const user = req.user; + const isAdmin = user.role === 'admin'; + + // Construir filtro adicional si se proporciona + const filter = {}; + + // Si no es admin, solo buscar sus artículos + if (!isAdmin) { + filter.username = user.username; + } else if (req.query.username) { + // Admin puede filtrar por username + filter.username = req.query.username; + } + + if (req.query.worker_name) filter.worker_name = req.query.worker_name; + if (req.query.platform) filter.platform = req.query.platform; + const searchTerm = query.toLowerCase().trim(); - const allArticles = await getNotifiedArticles(); + const allArticles = await getNotifiedArticles(filter); // Filtrar artículos que coincidan con la búsqueda const filtered = allArticles.filter(article => { @@ -61,11 +113,23 @@ router.get('/search', async (req, res) => { const id = String(article.id || '').toLowerCase(); if (id.includes(searchTerm)) return true; + // Buscar en username + const username = (article.username || '').toLowerCase(); + if (username.includes(searchTerm)) return true; + + // Buscar en worker_name + const worker_name = (article.worker_name || '').toLowerCase(); + if (worker_name.includes(searchTerm)) return true; + return false; }); // Ordenar por fecha de notificación (más recientes primero) - const sorted = filtered.sort((a, b) => b.notifiedAt - a.notifiedAt); + const sorted = filtered.sort((a, b) => { + const aTime = typeof a.notifiedAt === 'number' ? a.notifiedAt : new Date(a.notifiedAt).getTime(); + const bTime = typeof b.notifiedAt === 'number' ? b.notifiedAt : new Date(b.notifiedAt).getTime(); + return bTime - aTime; + }); res.json({ articles: sorted, diff --git a/web/backend/routes/config.js b/web/backend/routes/config.js index 3934927..e160ef3 100644 --- a/web/backend/routes/config.js +++ b/web/backend/routes/config.js @@ -1,6 +1,6 @@ import express from 'express'; import { basicAuthMiddleware } from '../middlewares/auth.js'; -import { getConfig, reloadConfig } from '../services/redis.js'; +import { getConfig, reloadConfig } from '../services/mongodb.js'; import { readFileSync } from 'fs'; import yaml from 'yaml'; import { PATHS } from '../config/constants.js'; diff --git a/web/backend/routes/favorites.js b/web/backend/routes/favorites.js index 6af0442..63a4ae5 100644 --- a/web/backend/routes/favorites.js +++ b/web/backend/routes/favorites.js @@ -1,14 +1,19 @@ import express from 'express'; -import { getFavorites, getRedisClient } from '../services/redis.js'; +import { getFavorites, getDB, updateArticleFavorite, getArticle } from '../services/mongodb.js'; import { basicAuthMiddleware } from '../middlewares/auth.js'; import { broadcast } from '../services/websocket.js'; const router = express.Router(); -// Obtener favoritos -router.get('/', async (req, res) => { +// Obtener favoritos (requiere autenticación) +router.get('/', basicAuthMiddleware, async (req, res) => { try { - const favorites = await getFavorites(); + // Obtener usuario autenticado (requerido) + const user = req.user; + + // Todos los usuarios (incluidos admins) ven solo sus propios favoritos + const username = user.username; + const favorites = await getFavorites(username); res.json(favorites); } catch (error) { res.status(500).json({ error: error.message }); @@ -18,41 +23,40 @@ router.get('/', async (req, res) => { // Añadir favorito (requiere autenticación) router.post('/', basicAuthMiddleware, async (req, res) => { try { - const redisClient = getRedisClient(); - if (!redisClient) { - return res.status(500).json({ error: 'Redis no está disponible' }); + const db = getDB(); + if (!db) { + return res.status(500).json({ error: 'MongoDB no está disponible' }); } + // Verificar que el usuario está autenticado (middleware ya lo valida, pero doble verificación) + if (!req.user || !req.user.username) { + return res.status(401).json({ error: 'Se requiere autenticación', message: 'Se requiere autenticación para marcar favoritos' }); + } + + const username = req.user.username; const { platform, id } = req.body; if (!platform || !id) { return res.status(400).json({ error: 'platform e id son requeridos' }); } - const key = `notified:${platform}:${id}`; - const value = await redisClient.get(key); + // Convertir id a string para consistencia + const idStr = String(id); - if (!value) { - return res.status(404).json({ error: 'Artículo no encontrado' }); + // Verificar si el artículo existe + const article = await getArticle(platform, idStr); + if (!article) { + return res.status(404).json({ error: `Artículo no encontrado en MongoDB: ${platform}:${idStr}` }); } - try { - const articleData = JSON.parse(value); - articleData.is_favorite = true; - // Mantener el TTL existente - const ttl = await redisClient.ttl(key); - if (ttl > 0) { - await redisClient.setex(key, ttl, JSON.stringify(articleData)); - } else { - await redisClient.set(key, JSON.stringify(articleData)); - } - - const favorites = await getFavorites(); - broadcast({ type: 'favorites_updated', data: favorites }); - res.json({ success: true, favorites }); - } catch (e) { - res.status(500).json({ error: 'Error procesando artículo' }); - } + // Actualizar favorito para el usuario autenticado + await updateArticleFavorite(platform, idStr, true, username); + + // Obtener favoritos del usuario autenticado (todos ven solo los suyos) + const favorites = await getFavorites(username); + broadcast({ type: 'favorites_updated', data: favorites, username }); + res.json({ success: true, favorites }); } catch (error) { + console.error('Error en POST /favorites:', error); res.status(500).json({ error: error.message }); } }); @@ -60,37 +64,35 @@ router.post('/', basicAuthMiddleware, async (req, res) => { // Eliminar favorito (requiere autenticación) router.delete('/:platform/:id', basicAuthMiddleware, async (req, res) => { try { - const redisClient = getRedisClient(); - if (!redisClient) { - return res.status(500).json({ error: 'Redis no está disponible' }); + const db = getDB(); + if (!db) { + return res.status(500).json({ error: 'MongoDB no está disponible' }); } - const { platform, id } = req.params; - const key = `notified:${platform}:${id}`; - const value = await redisClient.get(key); + // Verificar que el usuario está autenticado (middleware ya lo valida, pero doble verificación) + if (!req.user || !req.user.username) { + return res.status(401).json({ error: 'Se requiere autenticación', message: 'Se requiere autenticación para eliminar favoritos' }); + } - if (!value) { + const username = req.user.username; + const { platform, id } = req.params; + const idStr = String(id); + + // Verificar si el artículo existe + const article = await getArticle(platform, idStr); + if (!article) { return res.status(404).json({ error: 'Artículo no encontrado' }); } - try { - const articleData = JSON.parse(value); - articleData.is_favorite = false; - // Mantener el TTL existente - const ttl = await redisClient.ttl(key); - if (ttl > 0) { - await redisClient.setex(key, ttl, JSON.stringify(articleData)); - } else { - await redisClient.set(key, JSON.stringify(articleData)); - } - - const favorites = await getFavorites(); - broadcast({ type: 'favorites_updated', data: favorites }); - res.json({ success: true, favorites }); - } catch (e) { - res.status(500).json({ error: 'Error procesando artículo' }); - } + // Actualizar favorito para el usuario autenticado + await updateArticleFavorite(platform, idStr, false, username); + + // Obtener favoritos del usuario autenticado (todos ven solo los suyos) + const favorites = await getFavorites(username); + broadcast({ type: 'favorites_updated', data: favorites, username }); + res.json({ success: true, favorites }); } catch (error) { + console.error('Error en DELETE /favorites:', error); res.status(500).json({ error: error.message }); } }); diff --git a/web/backend/routes/index.js b/web/backend/routes/index.js index 139e64f..9a7e0dd 100644 --- a/web/backend/routes/index.js +++ b/web/backend/routes/index.js @@ -1,22 +1,57 @@ import express from 'express'; -import { readJSON } from '../utils/fileUtils.js'; -import { PATHS } from '../config/constants.js'; -import { getFavorites, getNotifiedArticles, getRedisClient } from '../services/redis.js'; +import { getFavorites, getNotifiedArticles, getDB, getWorkers, clearAllArticles } from '../services/mongodb.js'; import { basicAuthMiddleware } from '../middlewares/auth.js'; +import { adminAuthMiddleware } from '../middlewares/adminAuth.js'; import { broadcast } from '../services/websocket.js'; const router = express.Router(); -// Obtener estadísticas -router.get('/stats', async (req, res) => { +// Obtener estadísticas (requiere autenticación obligatoria) +router.get('/stats', basicAuthMiddleware, async (req, res) => { try { - const workers = readJSON(PATHS.WORKERS, { items: [] }); - const favorites = await getFavorites(); - const notifiedArticles = await getNotifiedArticles(); + const db = getDB(); + if (!db) { + return res.status(500).json({ error: 'MongoDB no está disponible' }); + } + + // Obtener usuario autenticado (requerido) + const user = req.user; + const isAdmin = user.role === 'admin'; + + let totalWorkers = 0; + let activeWorkers = 0; + let favorites = []; + let notifiedArticles = []; + + if (isAdmin) { + // Admin: estadísticas globales de todos los usuarios + const workersCollection = db.collection('workers'); + const allWorkers = await workersCollection.find({}).toArray(); + + for (const userWorkers of allWorkers) { + const items = userWorkers.items || []; + const disabled = userWorkers.disabled || []; + totalWorkers += items.length; + activeWorkers += items.filter(w => !disabled.includes(w.name) && !disabled.includes(w.id)).length; + } + + favorites = await getFavorites(null); // Todos los favoritos + notifiedArticles = await getNotifiedArticles(); // Todos los artículos + } else { + // Usuario normal: solo sus estadísticas + const workers = await getWorkers(user.username); + const items = workers.items || []; + const disabled = workers.disabled || []; + totalWorkers = items.length; + activeWorkers = items.filter(w => !disabled.includes(w.name) && !disabled.includes(w.id)).length; + + favorites = await getFavorites(user.username); // Solo sus favoritos + notifiedArticles = await getNotifiedArticles({ username: user.username }); // Solo sus artículos + } const stats = { - totalWorkers: workers.items?.length || 0, - activeWorkers: (workers.items || []).filter(w => !workers.disabled?.includes(w.name)).length, + totalWorkers, + activeWorkers, totalFavorites: favorites.length, totalNotified: notifiedArticles.length, platforms: { @@ -27,34 +62,21 @@ router.get('/stats', async (req, res) => { res.json(stats); } catch (error) { + console.error('Error obteniendo estadísticas:', error); res.status(500).json({ error: error.message }); } }); -// Limpiar toda la caché de Redis (requiere autenticación) -router.delete('/cache', basicAuthMiddleware, async (req, res) => { +// Limpiar toda la caché de MongoDB (requiere autenticación de administrador) +router.delete('/cache', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => { try { - const redisClient = getRedisClient(); - if (!redisClient) { - return res.status(500).json({ error: 'Redis no está disponible' }); + const db = getDB(); + if (!db) { + return res.status(500).json({ error: 'MongoDB no está disponible' }); } - // Obtener todas las claves que empiezan con 'notified:' - const keys = await redisClient.keys('notified:*'); - - if (!keys || keys.length === 0) { - return res.json({ - success: true, - message: 'Cache ya está vacío', - count: 0 - }); - } - - // Eliminar todas las claves - const count = keys.length; - for (const key of keys) { - await redisClient.del(key); - } + // Eliminar todos los artículos + const count = await clearAllArticles(); // Notificar a los clientes WebSocket broadcast({ @@ -62,17 +84,55 @@ router.delete('/cache', basicAuthMiddleware, async (req, res) => { data: { count, timestamp: Date.now() } }); + // También notificar actualización de artículos (ahora está vacío) + broadcast({ type: 'articles_updated', data: [] }); + // También actualizar favoritos (debería estar vacío ahora) - const favorites = await getFavorites(); - broadcast({ type: 'favorites_updated', data: favorites }); + const favorites = await getFavorites(null); + broadcast({ type: 'favorites_updated', data: favorites, username: null }); res.json({ success: true, - message: `Cache limpiado: ${count} artículos eliminados`, + message: `Todos los artículos eliminados: ${count} artículos borrados`, count }); } catch (error) { - console.error('Error limpiando cache de Redis:', error); + console.error('Error limpiando cache de MongoDB:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Endpoint específico para borrar artículos (alias de /cache para claridad) +router.delete('/articles', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => { + try { + const db = getDB(); + if (!db) { + return res.status(500).json({ error: 'MongoDB no está disponible' }); + } + + // Eliminar todos los artículos + const count = await clearAllArticles(); + + // Notificar a los clientes WebSocket + broadcast({ + type: 'articles_cleared', + data: { count, timestamp: Date.now() } + }); + + // También notificar actualización de artículos (ahora está vacío) + broadcast({ type: 'articles_updated', data: [] }); + + // También actualizar favoritos (debería estar vacío ahora) + const favorites = await getFavorites(null); + broadcast({ type: 'favorites_updated', data: favorites, username: null }); + + res.json({ + success: true, + message: `Todos los artículos eliminados: ${count} artículos borrados`, + count + }); + } catch (error) { + console.error('Error borrando artículos:', error); res.status(500).json({ error: error.message }); } }); diff --git a/web/backend/routes/logs.js b/web/backend/routes/logs.js index af30939..2a7918f 100644 --- a/web/backend/routes/logs.js +++ b/web/backend/routes/logs.js @@ -1,11 +1,12 @@ import express from 'express'; import { basicAuthMiddleware } from '../middlewares/auth.js'; +import { adminAuthMiddleware } from '../middlewares/adminAuth.js'; import { getLogPath, readLogs } from '../utils/fileUtils.js'; const router = express.Router(); -// Obtener logs (últimas líneas o nuevas líneas desde un número de línea) -router.get('/', basicAuthMiddleware, (req, res) => { +// Obtener logs (requiere autenticación de administrador obligatoria) +router.get('/', basicAuthMiddleware, adminAuthMiddleware, (req, res) => { try { const logPath = getLogPath(); const sinceLine = parseInt(req.query.since) || 0; diff --git a/web/backend/routes/telegram.js b/web/backend/routes/telegram.js index 9fa1b36..a9e9b51 100644 --- a/web/backend/routes/telegram.js +++ b/web/backend/routes/telegram.js @@ -1,27 +1,66 @@ import express from 'express'; import { basicAuthMiddleware } from '../middlewares/auth.js'; -import { getConfig, reloadConfig } from '../services/redis.js'; -import { readFileSync } from 'fs'; -import yaml from 'yaml'; -import { PATHS } from '../config/constants.js'; +import { getTelegramConfig, setTelegramConfig } from '../services/mongodb.js'; const router = express.Router(); +// Obtener configuración de Telegram del usuario autenticado +router.get('/config', basicAuthMiddleware, async (req, res) => { + try { + const username = req.user.username; + const config = await getTelegramConfig(username); + + if (!config) { + return res.json({ + token: '', + channel: '', + enable_polling: false + }); + } + + res.json(config); + } catch (error) { + console.error('Error obteniendo configuración de Telegram:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Guardar configuración de Telegram del usuario autenticado +router.put('/config', basicAuthMiddleware, async (req, res) => { + try { + const username = req.user.username; + const { token, channel, enable_polling } = req.body; + + if (!token || !channel) { + return res.status(400).json({ error: 'Token y channel son requeridos' }); + } + + await setTelegramConfig(username, { + token, + channel, + enable_polling: enable_polling || false + }); + + res.json({ success: true }); + } catch (error) { + console.error('Error guardando configuración de Telegram:', error); + res.status(500).json({ error: error.message }); + } +}); + // Obtener threads/topics de Telegram router.get('/threads', basicAuthMiddleware, async (req, res) => { try { - let config = getConfig(); - if (!config) { - config = yaml.parse(readFileSync(PATHS.CONFIG, 'utf8')); - } + const username = req.user.username; + const config = await getTelegramConfig(username); - const token = config?.telegram_token; - const channel = config?.telegram_channel; - - if (!token || !channel) { + if (!config || !config.token || !config.channel) { return res.status(400).json({ error: 'Token o canal de Telegram no configurados' }); } + const token = config.token; + const channel = config.channel; + // Convertir el canal a chat_id si es necesario let chatId = channel; if (channel.startsWith('@')) { diff --git a/web/backend/routes/users.js b/web/backend/routes/users.js index 85ac962..8d4ab46 100644 --- a/web/backend/routes/users.js +++ b/web/backend/routes/users.js @@ -1,16 +1,17 @@ import express from 'express'; import bcrypt from 'bcrypt'; -import { getRedisClient } from '../services/redis.js'; +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'; const router = express.Router(); // Endpoint de login (público) router.post('/login', async (req, res) => { try { - const redisClient = getRedisClient(); - if (!redisClient) { - return res.status(500).json({ error: 'Redis no está disponible' }); + const db = getDB(); + if (!db) { + return res.status(500).json({ error: 'MongoDB no está disponible' }); } const { username, password } = req.body; @@ -19,17 +20,15 @@ router.post('/login', async (req, res) => { return res.status(400).json({ error: 'username y password son requeridos' }); } - // Buscar usuario en Redis - const userKey = `user:${username}`; - const userExists = await redisClient.exists(userKey); + // Buscar usuario en MongoDB + const user = await getUser(username); - if (!userExists) { + if (!user) { return res.status(401).json({ error: 'Invalid credentials', message: 'Usuario o contraseña incorrectos' }); } // Obtener hash de la contraseña - const userData = await redisClient.hGetAll(userKey); - const passwordHash = userData.passwordHash; + const passwordHash = user.passwordHash; if (!passwordHash) { return res.status(401).json({ error: 'Invalid credentials', message: 'Usuario o contraseña incorrectos' }); @@ -45,11 +44,15 @@ router.post('/login', async (req, res) => { // Crear sesión/token const token = await createSession(username); - console.log(`✅ Login exitoso: ${username}`); + // Obtener rol del usuario + const userRole = user.role || 'user'; + + console.log(`✅ Login exitoso: ${username} (${userRole})`); res.json({ success: true, token, username, + role: userRole, message: 'Login exitoso' }); } catch (error) { @@ -78,9 +81,11 @@ router.post('/logout', basicAuthMiddleware, async (req, res) => { // Verificar token (para validar si la sesión sigue activa) router.get('/me', basicAuthMiddleware, async (req, res) => { try { + const user = await getUser(req.user.username); res.json({ success: true, username: req.user.username, + role: user?.role || 'user', authenticated: true }); } catch (error) { @@ -91,9 +96,9 @@ router.get('/me', basicAuthMiddleware, async (req, res) => { // Cambiar contraseña de usuario router.post('/change-password', basicAuthMiddleware, async (req, res) => { try { - const redisClient = getRedisClient(); - if (!redisClient) { - return res.status(500).json({ error: 'Redis no está disponible' }); + const db = getDB(); + if (!db) { + return res.status(500).json({ error: 'MongoDB no está disponible' }); } const { currentPassword, newPassword } = req.body; @@ -107,26 +112,21 @@ router.post('/change-password', basicAuthMiddleware, async (req, res) => { return res.status(400).json({ error: 'La nueva contraseña debe tener al menos 6 caracteres' }); } - const userKey = `user:${username}`; - const userData = await redisClient.hGetAll(userKey); + const user = await getUser(username); - if (!userData || !userData.passwordHash) { + if (!user || !user.passwordHash) { return res.status(404).json({ error: 'Usuario no encontrado' }); } // Verificar contraseña actual - const match = await bcrypt.compare(currentPassword, userData.passwordHash); + const match = await bcrypt.compare(currentPassword, user.passwordHash); if (!match) { return res.status(401).json({ error: 'Contraseña actual incorrecta' }); } // Hashear nueva contraseña y actualizar const newPasswordHash = await bcrypt.hash(newPassword, 10); - await redisClient.hSet(userKey, { - ...userData, - passwordHash: newPasswordHash, - updatedAt: new Date().toISOString(), - }); + await updateUserPassword(username, newPasswordHash); // Invalidar todas las sesiones del usuario (requiere nuevo login) await invalidateUserSessions(username); @@ -139,40 +139,37 @@ router.post('/change-password', basicAuthMiddleware, async (req, res) => { } }); -// Obtener lista de usuarios (requiere autenticación admin) +// Obtener lista de usuarios (requiere autenticación, admin ve todos, user ve solo suyo) router.get('/', basicAuthMiddleware, async (req, res) => { try { - const redisClient = getRedisClient(); - if (!redisClient) { - return res.status(500).json({ error: 'Redis no está disponible' }); + const db = getDB(); + if (!db) { + return res.status(500).json({ error: 'MongoDB no está disponible' }); } - // Obtener todas las claves de usuarios - const userKeys = await redisClient.keys('user:*'); - const users = []; + const users = await getAllUsers(req.user.username); - for (const key of userKeys) { - const username = key.replace('user:', ''); - const userData = await redisClient.hGetAll(key); - - if (userData && userData.username) { - users.push({ - username: userData.username, - createdAt: userData.createdAt || null, - updatedAt: userData.updatedAt || null, - createdBy: userData.createdBy || null, - }); + // Convertir ObjectId a string y formatear fechas + const formattedUsers = users.map(user => { + const formatted = { ...user }; + formatted._id = user._id?.toString(); + if (user.createdAt && typeof user.createdAt === 'object') { + formatted.createdAt = user.createdAt.toISOString(); } - } + if (user.updatedAt && typeof user.updatedAt === 'object') { + formatted.updatedAt = user.updatedAt.toISOString(); + } + return formatted; + }); // Ordenar por fecha de creación (más recientes primero) - users.sort((a, b) => { + formattedUsers.sort((a, b) => { const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0; const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0; return dateB - dateA; }); - res.json({ users, total: users.length }); + res.json({ users: formattedUsers, total: formattedUsers.length }); } catch (error) { console.error('Error obteniendo usuarios:', error); res.status(500).json({ error: error.message }); @@ -180,11 +177,11 @@ router.get('/', basicAuthMiddleware, async (req, res) => { }); // Crear nuevo usuario (requiere autenticación admin) -router.post('/', basicAuthMiddleware, async (req, res) => { +router.post('/', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => { try { - const redisClient = getRedisClient(); - if (!redisClient) { - return res.status(500).json({ error: 'Redis no está disponible' }); + const db = getDB(); + if (!db) { + return res.status(500).json({ error: 'MongoDB no está disponible' }); } const { username, password } = req.body; @@ -201,19 +198,17 @@ router.post('/', basicAuthMiddleware, async (req, res) => { return res.status(400).json({ error: 'La contraseña debe tener al menos 6 caracteres' }); } - const userKey = `user:${username}`; - const userExists = await redisClient.exists(userKey); - - if (userExists) { + // Verificar si el usuario ya existe + const existingUser = await getUser(username); + if (existingUser) { return res.status(409).json({ error: 'El usuario ya existe' }); } // Hashear contraseña y crear usuario const passwordHash = await bcrypt.hash(password, 10); - await redisClient.hSet(userKey, { + await createUser({ username, passwordHash, - createdAt: new Date().toISOString(), createdBy: req.user.username, }); @@ -221,16 +216,20 @@ router.post('/', basicAuthMiddleware, async (req, res) => { res.json({ success: true, message: 'Usuario creado correctamente', username }); } catch (error) { console.error('Error creando usuario:', error); + // Manejar error de duplicado + if (error.code === 11000) { + return res.status(409).json({ error: 'El usuario ya existe' }); + } res.status(500).json({ error: error.message }); } }); // Eliminar usuario (requiere autenticación admin) -router.delete('/:username', basicAuthMiddleware, async (req, res) => { +router.delete('/:username', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => { try { - const redisClient = getRedisClient(); - if (!redisClient) { - return res.status(500).json({ error: 'Redis no está disponible' }); + const db = getDB(); + if (!db) { + return res.status(500).json({ error: 'MongoDB no está disponible' }); } const { username } = req.params; @@ -241,15 +240,19 @@ router.delete('/:username', basicAuthMiddleware, async (req, res) => { return res.status(400).json({ error: 'No puedes eliminar tu propio usuario' }); } - const userKey = `user:${username}`; - const userExists = await redisClient.exists(userKey); - - if (!userExists) { + // Verificar si el usuario existe + const user = await getUser(username); + if (!user) { return res.status(404).json({ error: 'Usuario no encontrado' }); } - // Eliminar usuario - await redisClient.del(userKey); + // Eliminar usuario y sus sesiones + await deleteUserFromDB(username); + await invalidateUserSessions(username); + + // También eliminar sus workers + const workersCollection = db.collection('workers'); + await workersCollection.deleteOne({ username }); console.log(`✅ Usuario eliminado: ${username} por ${currentUser}`); res.json({ success: true, message: `Usuario ${username} eliminado correctamente` }); diff --git a/web/backend/routes/workers.js b/web/backend/routes/workers.js index 0c40838..b2e3f09 100644 --- a/web/backend/routes/workers.js +++ b/web/backend/routes/workers.js @@ -1,32 +1,65 @@ import express from 'express'; -import { readJSON, writeJSON } from '../utils/fileUtils.js'; -import { PATHS } from '../config/constants.js'; import { basicAuthMiddleware } from '../middlewares/auth.js'; import { broadcast } from '../services/websocket.js'; +import { getWorkers, setWorkers, getDB } from '../services/mongodb.js'; const router = express.Router(); -// Obtener workers (requiere autenticación - solo administradores) -router.get('/', basicAuthMiddleware, (req, res) => { +// Obtener workers del usuario autenticado (requiere autenticación) +router.get('/', basicAuthMiddleware, async (req, res) => { try { - const workers = readJSON(PATHS.WORKERS, { items: [], general: {}, disabled: [] }); + const db = getDB(); + if (!db) { + return res.status(500).json({ error: 'MongoDB no está disponible' }); + } + + const username = req.user.username; + const workers = await getWorkers(username); res.json(workers); } catch (error) { + console.error('Error obteniendo workers:', error); res.status(500).json({ error: error.message }); } }); -// Actualizar workers (requiere autenticación) -router.put('/', basicAuthMiddleware, (req, res) => { +// Actualizar workers del usuario autenticado (requiere autenticación) +router.put('/', basicAuthMiddleware, async (req, res) => { try { - const workers = req.body; - if (writeJSON(PATHS.WORKERS, workers)) { - broadcast({ type: 'workers_updated', data: workers }); - res.json({ success: true }); - } else { - res.status(500).json({ error: 'Error guardando workers' }); + const db = getDB(); + if (!db) { + return res.status(500).json({ error: 'MongoDB no está disponible' }); } + + const username = req.user.username; + const workers = req.body; + + // Validar estructura básica + if (!workers || typeof workers !== 'object') { + return res.status(400).json({ error: 'Formato de workers inválido' }); + } + + // Asegurar estructura mínima + const workersData = { + general: workers.general || { + title_exclude: [], + description_exclude: [] + }, + items: workers.items || [], + disabled: workers.disabled || [] + }; + + await setWorkers(username, workersData); + + // Notificar a todos los clientes WebSocket del usuario + broadcast({ + type: 'workers_updated', + data: workersData, + username // Incluir username para que los clientes sepan de quién son los workers + }); + + res.json({ success: true }); } catch (error) { + console.error('Error guardando workers:', error); res.status(500).json({ error: error.message }); } }); diff --git a/web/backend/server.js b/web/backend/server.js index cc43756..be3c659 100644 --- a/web/backend/server.js +++ b/web/backend/server.js @@ -3,7 +3,7 @@ import cors from 'cors'; import { createServer } from 'http'; import { PATHS, SERVER } from './config/constants.js'; import { rateLimitMiddleware } from './middlewares/rateLimit.js'; -import { initRedis } from './services/redis.js'; +import { initMongoDB } from './services/mongodb.js'; import { initVAPIDKeys } from './services/webPush.js'; import { initWebSocket } from './services/websocket.js'; import { startArticleMonitoring } from './services/articleMonitor.js'; @@ -48,8 +48,8 @@ app.use('/api/users', usersRouter); // Inicializar servidor async function startServer() { try { - // Inicializar Redis - await initRedis(); + // Inicializar MongoDB + await initMongoDB(); // Iniciar monitoreo de artículos nuevos await startArticleMonitoring(); diff --git a/web/backend/services/articleMonitor.js b/web/backend/services/articleMonitor.js index cae7626..42b5e08 100644 --- a/web/backend/services/articleMonitor.js +++ b/web/backend/services/articleMonitor.js @@ -1,74 +1,57 @@ -import { getRedisClient, initNotifiedArticleKeys } from './redis.js'; +import { getDB, initNotifiedArticleKeys } from './mongodb.js'; import { broadcast } from './websocket.js'; import { sendPushNotifications } from './webPush.js'; import { ARTICLE_MONITORING } from '../config/constants.js'; -let notifiedArticleKeys = new Set(); +let notifiedArticleIds = new Set(); let articlesCheckInterval = null; // Función para detectar y enviar artículos nuevos async function checkForNewArticles() { - const redisClient = getRedisClient(); - if (!redisClient) { + const db = getDB(); + if (!db) { return; } try { - const currentKeys = await redisClient.keys('notified:*'); - const currentKeysSet = new Set(currentKeys); + const articlesCollection = db.collection('articles'); + // Obtener todos los artículos con sus IDs + const allArticles = await articlesCollection.find( + {}, + { projection: { platform: 1, id: 1, title: 1, price: 1, currency: 1, url: 1, images: 1 } } + ).toArray(); - // Encontrar claves nuevas - const newKeys = currentKeys.filter(key => !notifiedArticleKeys.has(key)); + const currentIds = new Set( + allArticles.map(a => `${a.platform}:${a.id}`) + ); - if (newKeys.length > 0) { - // Obtener los artículos nuevos - const newArticles = []; - for (const key of newKeys) { - try { - const value = await redisClient.get(key); - if (value) { - // Intentar parsear como JSON - let articleData = {}; - try { - articleData = JSON.parse(value); - } catch (e) { - // Si no es JSON válido, extraer información de la key - const parts = key.split(':'); - if (parts.length >= 3) { - articleData = { - platform: parts[1], - id: parts.slice(2).join(':'), - }; - } - } - - // Añadir información adicional si está disponible - if (articleData.platform && articleData.id) { - newArticles.push({ - platform: articleData.platform || 'unknown', - id: articleData.id || 'unknown', - title: articleData.title || null, - price: articleData.price || null, - currency: articleData.currency || '€', - url: articleData.url || null, - images: articleData.images || [], - }); - } - } - } catch (error) { - console.error(`Error obteniendo artículo de Redis (${key}):`, error.message); - } - } + // Encontrar artículos nuevos + const newArticles = allArticles.filter(article => { + const articleId = `${article.platform}:${article.id}`; + return !notifiedArticleIds.has(articleId); + }); + + if (newArticles.length > 0) { + // Preparar artículos para enviar + const articlesToSend = newArticles.map(article => ({ + platform: article.platform || 'unknown', + id: article.id || 'unknown', + title: article.title || null, + price: article.price || null, + currency: article.currency || '€', + url: article.url || null, + images: article.images || [], + })); // Enviar artículos nuevos por WebSocket - if (newArticles.length > 0) { + if (articlesToSend.length > 0) { broadcast({ type: 'new_articles', - data: newArticles + data: articlesToSend }); // Enviar notificaciones push para cada artículo nuevo - for (const article of newArticles) { + for (const article of articlesToSend) { await sendPushNotifications({ title: `Nuevo artículo en ${article.platform?.toUpperCase() || 'Wallabicher'}`, body: article.title || 'Artículo nuevo disponible', @@ -83,20 +66,29 @@ async function checkForNewArticles() { } } - // Actualizar el set de claves notificadas - notifiedArticleKeys = currentKeysSet; + // Actualizar el set de IDs notificadas + notifiedArticleIds = currentIds; } } catch (error) { console.error('Error verificando artículos nuevos:', error.message); } } -// Inicializar el check de artículos cuando Redis esté listo +// Inicializar el check de artículos cuando MongoDB esté listo export async function startArticleMonitoring() { - const redisClient = getRedisClient(); - if (redisClient) { - // Inicializar claves conocidas - notifiedArticleKeys = await initNotifiedArticleKeys(); + const db = getDB(); + if (db) { + // Inicializar IDs conocidas + const keys = await initNotifiedArticleKeys(); + notifiedArticleIds = new Set( + Array.from(keys).map(key => { + const parts = key.replace('notified:', '').split(':'); + if (parts.length >= 2) { + return `${parts[0]}:${parts.slice(1).join(':')}`; + } + return key; + }) + ); // Iniciar intervalo para verificar nuevos artículos articlesCheckInterval = setInterval(checkForNewArticles, ARTICLE_MONITORING.CHECK_INTERVAL); diff --git a/web/backend/services/fileWatcher.js b/web/backend/services/fileWatcher.js index 7e91157..9c2fc96 100644 --- a/web/backend/services/fileWatcher.js +++ b/web/backend/services/fileWatcher.js @@ -1,34 +1,13 @@ -import { watch } from 'chokidar'; -import { existsSync } from 'fs'; -import { PATHS } from '../config/constants.js'; -import { readJSON } from '../utils/fileUtils.js'; -import { broadcast } from './websocket.js'; +// File watcher ya no es necesario ya que los workers se almacenan en MongoDB +// Los cambios se notifican directamente a través de WebSocket cuando se actualizan via API let watcher = null; -// Inicializar file watcher +// Inicializar file watcher (ahora vacío, mantenido para compatibilidad) export function initFileWatcher() { - // Watch files for changes - const filesToWatch = [PATHS.WORKERS].filter(p => existsSync(p)); - - if (filesToWatch.length === 0) { - return; - } - - watcher = watch(filesToWatch, { - persistent: true, - ignoreInitial: true, - }); - - watcher.on('change', async (path) => { - console.log(`Archivo cambiado: ${path}`); - if (path === PATHS.WORKERS) { - const workers = readJSON(PATHS.WORKERS); - broadcast({ type: 'workers_updated', data: workers }); - } - }); - - console.log('✅ File watcher inicializado'); + // Los workers ahora se almacenan en MongoDB y se notifican directamente + // a través de WebSocket cuando se actualizan via API + console.log('ℹ️ File watcher no es necesario (workers en MongoDB)'); } // Detener file watcher diff --git a/web/backend/services/mongodb.js b/web/backend/services/mongodb.js new file mode 100644 index 0000000..e9a91c8 --- /dev/null +++ b/web/backend/services/mongodb.js @@ -0,0 +1,879 @@ +import { MongoClient } from 'mongodb'; +import yaml from 'yaml'; +import { readFileSync, existsSync } from 'fs'; +import bcrypt from 'bcrypt'; +import { RateLimiterMemory } from 'rate-limiter-flexible'; +import { PATHS } from '../config/constants.js'; +import { RATE_LIMIT } from '../config/constants.js'; +import { readJSON } from '../utils/fileUtils.js'; + +let mongoClient = null; +let db = null; +let rateLimiter = null; +let config = null; + +// Duración de sesión en milisegundos (24 horas) +const SESSION_DURATION = 24 * 60 * 60 * 1000; + +// TTL de artículos notificados en milisegundos (7 días) +const NOTIFIED_ARTICLE_TTL = 7 * 24 * 60 * 60 * 1000; + +// Inicializar MongoDB si está configurado +export async function initMongoDB() { + try { + config = yaml.parse(readFileSync(PATHS.CONFIG, 'utf8')); + const cacheConfig = config?.cache; + + if (cacheConfig?.type === 'mongodb') { + const mongodbConfig = cacheConfig.mongodb; + // En Docker, usar el nombre del servicio si no se especifica host + const mongodbHost = process.env.MONGODB_HOST || mongodbConfig.host || 'localhost'; + const mongodbPort = process.env.MONGODB_PORT || mongodbConfig.port || 27017; + const database = process.env.MONGODB_DATABASE || mongodbConfig.database || 'wallabicher'; + const username = process.env.MONGODB_USERNAME || mongodbConfig.username; + const password = process.env.MONGODB_PASSWORD || mongodbConfig.password; + const authSource = mongodbConfig.auth_source || 'admin'; + + // Construir URL de conexión + let connectionString = 'mongodb://'; + if (username && password) { + connectionString += `${encodeURIComponent(username)}:${encodeURIComponent(password)}@`; + } + connectionString += `${mongodbHost}:${mongodbPort}`; + if (username && password) { + connectionString += `/?authSource=${authSource}`; + } + + mongoClient = new MongoClient(connectionString); + await mongoClient.connect(); + db = mongoClient.db(database); + + console.log(`✅ Conectado a MongoDB (${database})`); + + // Crear índices + await createIndexes(); + + // Inicializar rate limiter con memoria (MongoDB no tiene rate limiter nativo, usar memoria) + try { + rateLimiter = new RateLimiterMemory({ + points: RATE_LIMIT.POINTS, + duration: RATE_LIMIT.DURATION, + blockDuration: RATE_LIMIT.BLOCK_DURATION, + }); + console.log('✅ Rate limiter inicializado con memoria'); + } catch (error) { + console.error('Error inicializando rate limiter:', error.message); + } + + // Inicializar usuario admin por defecto si no existe + await initDefaultAdmin(); + + // Migrar workers.json a MongoDB para admin si no existe + await migrateWorkersFromFile(); + + } else { + console.log('ℹ️ MongoDB no configurado, usando modo memoria'); + console.log('⚠️ Rate limiting y autenticación requieren MongoDB'); + } + } catch (error) { + console.error('Error inicializando MongoDB:', error.message); + } +} + +// Crear índices necesarios +async function createIndexes() { + if (!db) return; + + try { + // Índices para usuarios + await db.collection('users').createIndex({ username: 1 }, { unique: true }); + + // Índices para sesiones (con TTL) + await db.collection('sessions').createIndex({ token: 1 }, { unique: true }); + await db.collection('sessions').createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + + // Índices para workers + await db.collection('workers').createIndex({ username: 1 }); + + // Índices para artículos notificados + await db.collection('articles').createIndex({ platform: 1, id: 1 }, { unique: true }); + await db.collection('articles').createIndex({ 'user_info.username': 1 }); + await db.collection('articles').createIndex({ 'user_info.worker_name': 1 }); + await db.collection('articles').createIndex({ 'user_info.is_favorite': 1 }); + await db.collection('articles').createIndex({ 'user_info.notified_at': -1 }); + await db.collection('articles').createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + // Índices de compatibilidad con estructura antigua + await db.collection('articles').createIndex({ username: 1 }); + await db.collection('articles').createIndex({ worker_name: 1 }); + await db.collection('articles').createIndex({ notifiedAt: -1 }); + + console.log('✅ Índices de MongoDB creados'); + } catch (error) { + console.error('Error creando índices de MongoDB:', error.message); + } +} + +// Inicializar usuario admin por defecto +async function initDefaultAdmin() { + if (!db) return; + + try { + const usersCollection = db.collection('users'); + const adminExists = await usersCollection.findOne({ username: '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 usersCollection.insertOne({ + username: 'admin', + passwordHash: hashedPassword, + role: 'admin', + createdAt: new Date(), + }); + console.log('✅ Usuario admin creado por defecto (usuario: admin, contraseña: admin)'); + console.log('⚠️ IMPORTANTE: Cambia la contraseña por defecto en producción'); + } else { + // Asegurar que el usuario admin tiene el rol correcto (para usuarios existentes) + await usersCollection.updateOne( + { username: 'admin' }, + { $set: { role: 'admin' } } + ); + } + } catch (error) { + console.error('Error inicializando usuario admin:', error.message); + } +} + +// Migrar workers.json a MongoDB para el usuario admin si no existe +async function migrateWorkersFromFile() { + if (!db) return; + + try { + const workersCollection = db.collection('workers'); + const adminWorkers = await workersCollection.findOne({ username: 'admin' }); + + // Si ya existen workers para admin en MongoDB, no migrar + if (adminWorkers) { + console.log('ℹ️ Workers de admin ya existen en MongoDB, 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 = { + username: 'admin', + general: { + title_exclude: [], + description_exclude: [] + }, + items: [], + disabled: [], + updatedAt: new Date(), + createdAt: new Date() + }; + await workersCollection.insertOne(defaultWorkers); + return; + } + + // Leer workers.json y migrar a MongoDB + const workersData = readJSON(PATHS.WORKERS, { + general: { + title_exclude: [], + description_exclude: [] + }, + items: [], + disabled: [] + }); + + // Guardar en MongoDB para admin + await workersCollection.insertOne({ + username: 'admin', + ...workersData, + updatedAt: new Date(), + createdAt: new Date() + }); + console.log(`✅ Workers migrados desde workers.json al usuario admin (${workersData.items?.length || 0} items)`); + + } catch (error) { + console.error('Error migrando workers.json a MongoDB:', error.message); + // No lanzar error, solo registrar + } +} + +// Getters +export function getMongoDBClient() { + return mongoClient; +} + +export function getDB() { + return db; +} + +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(filter = {}) { + if (!db) { + return []; + } + + try { + const articlesCollection = db.collection('articles'); + + // Construir query de filtro + const query = {}; + if (filter.platform) query.platform = filter.platform; + + // Si se especifica username, buscar en user_info + if (filter.username) { + query['user_info.username'] = filter.username; + } + + // Si se especifica worker_name, buscar en user_info + if (filter.worker_name) { + query['user_info.worker_name'] = filter.worker_name; + } + + const articles = await articlesCollection + .find(query) + .sort({ 'user_info.notified_at': -1, createdAt: -1 }) + .toArray(); + + // 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 + let relevantUserInfo = null; + if (filter.username) { + relevantUserInfo = (article.user_info || []).find( + ui => ui.username === filter.username + ); + // Si no hay user_info para este usuario, no incluir el artículo + if (!relevantUserInfo) return null; + } else if (filter.worker_name) { + // Si solo hay filtro de worker, buscar el primer user_info con ese worker + relevantUserInfo = (article.user_info || []).find( + ui => ui.worker_name === filter.worker_name + ); + if (!relevantUserInfo) return null; + } + + // Construir el artículo con la información relevante + const result = { + ...article, + _id: article._id.toString(), + expiresAt: article.expiresAt?.getTime() || null, + }; + + // Si hay un user_info específico, usar sus datos + if (relevantUserInfo) { + result.username = relevantUserInfo.username; + result.worker_name = relevantUserInfo.worker_name; + result.is_favorite = relevantUserInfo.is_favorite || false; + result.notifiedAt = relevantUserInfo.notified_at?.getTime() || Date.now(); + } else { + // Sin filtro específico, mostrar 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() || Date.now(); + } 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() || Date.now(); + } + } + + return result; + }).filter(article => article !== null); + } catch (error) { + console.error('Error obteniendo artículos de MongoDB:', error.message); + return []; + } +} + +export async function getFavorites(username = null) { + if (!db) { + return []; + } + + try { + const articlesCollection = db.collection('articles'); + + // Si se especifica username, buscar solo favoritos de ese usuario + let query = {}; + if (username) { + query['user_info.username'] = username; + query['user_info.is_favorite'] = true; + } else { + // Sin username, buscar cualquier artículo con algún favorito + query['user_info.is_favorite'] = true; + } + + const articles = await articlesCollection + .find(query) + .sort({ 'user_info.notified_at': -1, createdAt: -1 }) + .toArray(); + + // Filtrar y transformar para devolver solo los favoritos relevantes + const favorites = []; + for (const article of articles) { + const userInfoList = article.user_info || []; + + if (username) { + // Solo devolver favoritos del usuario especificado + const userInfo = userInfoList.find(ui => + ui.username === username && ui.is_favorite === true + ); + if (userInfo) { + favorites.push({ + ...article, + _id: article._id.toString(), + username: userInfo.username, + worker_name: userInfo.worker_name, + is_favorite: true, + notifiedAt: userInfo.notified_at?.getTime() || Date.now(), + expiresAt: article.expiresAt?.getTime() || null, + }); + } + } else { + // Sin filtro de usuario, devolver todos los favoritos (uno por user_info) + for (const userInfo of userInfoList) { + if (userInfo.is_favorite === true) { + favorites.push({ + ...article, + _id: article._id.toString(), + username: userInfo.username, + worker_name: userInfo.worker_name, + is_favorite: true, + notifiedAt: userInfo.notified_at?.getTime() || Date.now(), + expiresAt: article.expiresAt?.getTime() || null, + }); + } + } + } + } + + return favorites; + } catch (error) { + console.error('Error obteniendo favoritos de MongoDB:', error.message); + return []; + } +} + +// Inicializar claves conocidas para evitar notificar artículos existentes +export async function initNotifiedArticleKeys() { + if (!db) { + return new Set(); + } + + try { + const articlesCollection = db.collection('articles'); + const articles = await articlesCollection.find({}, { projection: { platform: 1, id: 1 } }).toArray(); + const keysSet = new Set(articles.map(a => `notified:${a.platform}:${a.id}`)); + 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 (!db) { + throw new Error('MongoDB no está disponible'); + } + + try { + const workersCollection = db.collection('workers'); + const workersData = await workersCollection.findOne({ username }); + + if (!workersData) { + // Retornar estructura vacía por defecto + return { + general: { + title_exclude: [], + description_exclude: [] + }, + items: [], + disabled: [] + }; + } + + // Remover campos de MongoDB y devolver solo los datos relevantes + const { _id, username: _, updatedAt, createdAt, ...data } = workersData; + return data; + } catch (error) { + console.error(`Error obteniendo workers para ${username}:`, error.message); + throw error; + } +} + +export async function setWorkers(username, workers) { + if (!db) { + throw new Error('MongoDB no está disponible'); + } + + try { + const workersCollection = db.collection('workers'); + + // Usar upsert para insertar o actualizar + await workersCollection.updateOne( + { username }, + { + $set: { + ...workers, + username, + updatedAt: new Date(), + }, + $setOnInsert: { + createdAt: new Date(), + } + }, + { upsert: true } + ); + + return true; + } catch (error) { + console.error(`Error guardando workers para ${username}:`, error.message); + throw error; + } +} + +// Funciones para usuarios +export async function getUser(username) { + if (!db) { + return null; + } + + try { + const usersCollection = db.collection('users'); + const user = await usersCollection.findOne({ username }); + // Si el usuario no tiene rol, asignar 'user' por defecto (para usuarios antiguos) + if (user && !user.role) { + await usersCollection.updateOne( + { username }, + { $set: { role: username === 'admin' ? 'admin' : 'user' } } + ); + user.role = username === 'admin' ? 'admin' : 'user'; + } + return user; + } catch (error) { + console.error(`Error obteniendo usuario ${username}:`, error.message); + return null; + } +} + +export async function createUser(userData) { + if (!db) { + throw new Error('MongoDB no está disponible'); + } + + try { + const usersCollection = db.collection('users'); + const result = await usersCollection.insertOne({ + ...userData, + role: userData.role || 'user', // Por defecto 'user', a menos que se especifique + createdAt: new Date(), + }); + return result.insertedId; + } catch (error) { + console.error('Error creando usuario:', error.message); + throw error; + } +} + +export async function deleteUser(username) { + if (!db) { + return false; + } + + try { + const usersCollection = db.collection('users'); + const result = await usersCollection.deleteOne({ username }); + return result.deletedCount > 0; + } catch (error) { + console.error(`Error eliminando usuario ${username}:`, error.message); + return false; + } +} + +export async function getAllUsers(currentUser = null) { + if (!db) { + return []; + } + + try { + const usersCollection = db.collection('users'); + + // Si hay un usuario actual, verificar si es admin + if (currentUser) { + const currentUserData = await getUser(currentUser); // Usar getUser para asegurar que tiene rol + // Si es admin, puede ver todos los usuarios + if (currentUserData && currentUserData.role === 'admin') { + const users = await usersCollection.find({}, { projection: { passwordHash: 0 } }).toArray(); + return users; + } + // Si no es admin, solo puede ver su propio usuario + const user = await usersCollection.findOne( + { username: currentUser }, + { projection: { passwordHash: 0 } } + ); + return user ? [user] : []; + } + + // Sin usuario actual, devolver todos (compatibilidad) + const users = await usersCollection.find({}, { projection: { passwordHash: 0 } }).toArray(); + return users; + } catch (error) { + console.error('Error obteniendo usuarios:', error.message); + return []; + } +} + +export async function updateUserPassword(username, passwordHash) { + if (!db) { + throw new Error('MongoDB no está disponible'); + } + + try { + const usersCollection = db.collection('users'); + await usersCollection.updateOne( + { username }, + { $set: { passwordHash, updatedAt: new Date() } } + ); + return true; + } catch (error) { + console.error(`Error actualizando contraseña de ${username}:`, error.message); + throw error; + } +} + +// Funciones para configuración de Telegram +export async function getTelegramConfig(username) { + if (!db) { + return null; + } + + try { + const usersCollection = db.collection('users'); + const user = await usersCollection.findOne({ username }); + + if (user && user.telegram) { + return { + token: user.telegram.token || '', + channel: user.telegram.channel || '', + enable_polling: user.telegram.enable_polling || false + }; + } + + return null; + } catch (error) { + console.error(`Error obteniendo configuración de Telegram para ${username}:`, error.message); + return null; + } +} + +export async function setTelegramConfig(username, telegramConfig) { + if (!db) { + throw new Error('MongoDB no está disponible'); + } + + try { + const usersCollection = db.collection('users'); + + // Verificar que el usuario existe + const user = await usersCollection.findOne({ username }); + if (!user) { + throw new Error(`Usuario ${username} no existe`); + } + + // Actualizar configuración de Telegram + await usersCollection.updateOne( + { username }, + { + $set: { + telegram: { + token: telegramConfig.token || '', + channel: telegramConfig.channel || '', + enable_polling: telegramConfig.enable_polling || false + }, + updatedAt: new Date() + } + } + ); + + return true; + } catch (error) { + console.error(`Error guardando configuración de Telegram para ${username}:`, error.message); + throw error; + } +} + +// Funciones para sesiones +export async function createSession(username) { + if (!db) { + throw new Error('MongoDB no está disponible'); + } + + const crypto = await import('crypto'); + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + SESSION_DURATION); + + try { + const sessionsCollection = db.collection('sessions'); + await sessionsCollection.insertOne({ + token, + username, + createdAt: new Date(), + expiresAt, + }); + return token; + } catch (error) { + console.error('Error creando sesión:', error.message); + throw error; + } +} + +export async function getSession(token) { + if (!db) { + return null; + } + + try { + const sessionsCollection = db.collection('sessions'); + return await sessionsCollection.findOne({ token }); + } catch (error) { + console.error('Error obteniendo sesión:', error.message); + return null; + } +} + +export async function deleteSession(token) { + if (!db) { + return false; + } + + try { + const sessionsCollection = db.collection('sessions'); + const result = await sessionsCollection.deleteOne({ token }); + return result.deletedCount > 0; + } catch (error) { + console.error('Error eliminando sesión:', error.message); + return false; + } +} + +export async function deleteUserSessions(username) { + if (!db) { + return 0; + } + + try { + const sessionsCollection = db.collection('sessions'); + const result = await sessionsCollection.deleteMany({ username }); + return result.deletedCount; + } catch (error) { + console.error(`Error eliminando sesiones de ${username}:`, error.message); + return 0; + } +} + +// Funciones para artículos +export async function saveArticle(articleData) { + if (!db) { + throw new Error('MongoDB no está disponible'); + } + + try { + const articlesCollection = db.collection('articles'); + const expiresAt = new Date(Date.now() + NOTIFIED_ARTICLE_TTL); + + // Extraer datos del artículo (sin user_info) + const { + platform, + id, + username, + worker_name, + ...articleFields + } = articleData; + + // Buscar artículo existente + const existing = await articlesCollection.findOne({ platform, id }); + + // Preparar user_info para este usuario/worker + const userInfoEntry = { + username: username || null, + worker_name: worker_name || null, + notified: true, + notified_at: new Date(), + is_favorite: false, + }; + + if (existing) { + // Artículo existe, actualizar o añadir user_info + const existingUserInfo = existing.user_info || []; + + // Buscar si ya existe un user_info para este usuario + const existingUserInfoIndex = existingUserInfo.findIndex( + ui => ui.username === username + ); + + if (existingUserInfoIndex >= 0) { + // Actualizar user_info existente + existingUserInfo[existingUserInfoIndex] = { + ...existingUserInfo[existingUserInfoIndex], + worker_name: worker_name || existingUserInfo[existingUserInfoIndex].worker_name, + notified: true, + notified_at: new Date(), + }; + } else { + // Añadir nuevo user_info + existingUserInfo.push(userInfoEntry); + } + + await articlesCollection.updateOne( + { platform, id }, + { + $set: { + ...articleFields, + user_info: existingUserInfo, + expiresAt, + updatedAt: new Date(), + } + } + ); + } else { + // Artículo nuevo, crear con user_info + await articlesCollection.insertOne({ + platform, + id, + ...articleFields, + user_info: [userInfoEntry], + expiresAt, + createdAt: new Date(), + updatedAt: new Date(), + }); + } + + return true; + } catch (error) { + console.error('Error guardando artículo:', error.message); + throw error; + } +} + +export async function getArticle(platform, id) { + if (!db) { + return null; + } + + try { + const articlesCollection = db.collection('articles'); + return await articlesCollection.findOne({ platform, id }); + } catch (error) { + console.error('Error obteniendo artículo:', error.message); + return null; + } +} + +export async function updateArticleFavorite(platform, id, is_favorite, username) { + if (!db) { + throw new Error('MongoDB no está disponible'); + } + + if (!username) { + throw new Error('username es requerido para actualizar favoritos'); + } + + try { + const articlesCollection = db.collection('articles'); + const article = await articlesCollection.findOne({ platform, id }); + + if (!article) { + throw new Error('Artículo no encontrado'); + } + + const userInfoList = article.user_info || []; + const userInfoIndex = userInfoList.findIndex(ui => ui.username === username); + + if (userInfoIndex >= 0) { + // Actualizar user_info existente + userInfoList[userInfoIndex].is_favorite = is_favorite; + } else { + // Si no existe user_info para este usuario, crear uno + userInfoList.push({ + username, + worker_name: null, + notified: false, + notified_at: null, + is_favorite: is_favorite, + }); + } + + await articlesCollection.updateOne( + { platform, id }, + { + $set: { + user_info: userInfoList, + updatedAt: new Date() + } + } + ); + + return true; + } catch (error) { + console.error('Error actualizando favorito:', error.message); + throw error; + } +} + +export async function clearAllArticles() { + if (!db) { + return 0; + } + + try { + const articlesCollection = db.collection('articles'); + const result = await articlesCollection.deleteMany({}); + return result.deletedCount; + } catch (error) { + console.error('Error limpiando artículos:', error.message); + return 0; + } +} + +// Cerrar conexión +export async function closeMongoDB() { + if (mongoClient) { + await mongoClient.close(); + mongoClient = null; + db = null; + console.log('✅ Conexión a MongoDB cerrada'); + } +} + diff --git a/web/backend/services/redis.js b/web/backend/services/redis.js index 141864c..e84f5c9 100644 --- a/web/backend/services/redis.js +++ b/web/backend/services/redis.js @@ -5,6 +5,7 @@ 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; @@ -61,6 +62,9 @@ export async function initRedis() { // 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'); @@ -157,6 +161,8 @@ export async function getNotifiedArticles() { 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, }); @@ -217,3 +223,100 @@ export async function initNotifiedArticleKeys() { } } +// 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/frontend/package-lock.json b/web/frontend/package-lock.json index 42892b1..28ef621 100644 --- a/web/frontend/package-lock.json +++ b/web/frontend/package-lock.json @@ -1,5 +1,5 @@ { - "name": "wallabicher-frontend", + "name": "wallabicher-frontend", "version": "1.0.0", "lockfileVersion": 3, "requires": true, diff --git a/web/frontend/src/App.vue b/web/frontend/src/App.vue index e929e0b..604b8d0 100644 --- a/web/frontend/src/App.vue +++ b/web/frontend/src/App.vue @@ -1,30 +1,103 @@