mongodb
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
30
web/backend/middlewares/adminAuth.js
Normal file
30
web/backend/middlewares/adminAuth.js
Normal file
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
227
web/backend/package-lock.json
generated
227
web/backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('@')) {
|
||||
|
||||
@@ -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` });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
879
web/backend/services/mongodb.js
Normal file
879
web/backend/services/mongodb.js
Normal file
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user