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 pero mantener notified_at original existingUserInfo[existingUserInfoIndex] = { ...existingUserInfo[existingUserInfoIndex], worker_name: worker_name || existingUserInfo[existingUserInfoIndex].worker_name, notified: true, // NO actualizar notified_at, mantener el valor existente }; } else { // Añadir nuevo user_info existingUserInfo.push(userInfoEntry); } // Solo actualizar precio si es diferente, no actualizar fechas de notificación const existingPrice = existing.price; const newPrice = articleFields.price; if (existingPrice === newPrice) { // Si el precio es el mismo, solo actualizar user_info await articlesCollection.updateOne( { platform, id }, { $set: { user_info: existingUserInfo, } } ); } else { // Precio diferente, actualizar artículo completo // Mantener updatedAt para saber cuándo cambió el precio // pero NO actualizar notified_at (ya se mantiene arriba) 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'); } }