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