fixes: favoritos y mas cosas

Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
Omar Sánchez Pizarro
2026-01-20 20:06:28 +01:00
parent d5f0ba4e03
commit 05f0455744
20 changed files with 1691 additions and 350 deletions

View File

@@ -88,6 +88,8 @@ async function createIndexes() {
// Índices para sesiones (con TTL)
await db.collection('sessions').createIndex({ token: 1 }, { unique: true });
await db.collection('sessions').createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
await db.collection('sessions').createIndex({ username: 1, fingerprint: 1 });
await db.collection('sessions').createIndex({ fingerprint: 1 });
// Índices para workers
await db.collection('workers').createIndex({ username: 1 });
@@ -259,6 +261,54 @@ export function getRateLimiter() {
return rateLimiter;
}
// Obtener información del rate limiter y bloqueos (para memoria)
export async function getRateLimiterInfo() {
if (!rateLimiter) {
return {
enabled: false,
type: 'none',
blocks: [],
stats: {
totalBlocks: 0,
activeBlocks: 0,
},
};
}
try {
// RateLimiterMemory no expone directamente las claves bloqueadas
// Necesitamos usar una aproximación diferente
// Por ahora, retornamos información básica
return {
enabled: true,
type: 'memory',
blocks: [],
stats: {
totalBlocks: 0,
activeBlocks: 0,
},
config: {
points: RATE_LIMIT.POINTS,
duration: RATE_LIMIT.DURATION,
blockDuration: RATE_LIMIT.BLOCK_DURATION,
},
note: 'Rate limiter en memoria no expone información detallada de bloqueos',
};
} catch (error) {
console.error('Error obteniendo información del rate limiter:', error.message);
return {
enabled: true,
type: 'memory',
blocks: [],
stats: {
totalBlocks: 0,
activeBlocks: 0,
},
error: error.message,
};
}
}
export function getConfig() {
return config;
}
@@ -303,6 +353,9 @@ export async function getNotifiedArticles(filter = {}) {
.sort({ createdAt: -1, modified_at: -1 })
.toArray();
// Obtener el username del usuario actual para incluir su is_favorite
const currentUsername = filter.currentUsername || filter.username;
// Filtrar y transformar artículos según el usuario solicitado
return articles.map(article => {
// Si hay filtro de username, solo devolver el user_info correspondiente
@@ -350,7 +403,15 @@ export async function getNotifiedArticles(filter = {}) {
if (firstUserInfo) {
result.username = firstUserInfo.username;
result.worker_name = firstUserInfo.worker_name;
result.is_favorite = firstUserInfo.is_favorite || false;
// Si hay un usuario actual, buscar su is_favorite específico, sino usar el del primer user_info
if (currentUsername) {
const currentUserInfo = (article.user_info || []).find(
ui => ui.username === currentUsername
);
result.is_favorite = currentUserInfo ? (currentUserInfo.is_favorite || false) : false;
} else {
result.is_favorite = firstUserInfo.is_favorite || false;
}
result.notifiedAt =
firstUserInfo.notified_at?.getTime?.() ||
(typeof firstUserInfo.notified_at === 'number' ? firstUserInfo.notified_at : null) ||
@@ -579,6 +640,9 @@ export async function searchArticles(searchQuery, filter = {}, searchMode = 'AND
.sort({ createdAt: -1, modified_at: -1 })
.toArray();
// Obtener el username del usuario actual para incluir su is_favorite
const currentUsername = filter.currentUsername || filter.username;
// Transformar artículos según el usuario solicitado (similar a getNotifiedArticles)
return articles.map(article => {
let relevantUserInfo = null;
@@ -619,7 +683,15 @@ export async function searchArticles(searchQuery, filter = {}, searchMode = 'AND
if (firstUserInfo) {
result.username = firstUserInfo.username;
result.worker_name = firstUserInfo.worker_name;
result.is_favorite = firstUserInfo.is_favorite || false;
// Si hay un usuario actual, buscar su is_favorite específico, sino usar el del primer user_info
if (currentUsername) {
const currentUserInfo = (article.user_info || []).find(
ui => ui.username === currentUsername
);
result.is_favorite = currentUserInfo ? (currentUserInfo.is_favorite || false) : false;
} else {
result.is_favorite = firstUserInfo.is_favorite || false;
}
result.notifiedAt =
firstUserInfo.notified_at?.getTime?.() ||
(typeof firstUserInfo.notified_at === 'number' ? firstUserInfo.notified_at : null) ||
@@ -978,7 +1050,7 @@ export async function setTelegramConfig(username, telegramConfig) {
}
// Funciones para sesiones
export async function createSession(username) {
export async function createSession(username, fingerprint = null, deviceInfo = null) {
if (!db) {
throw new Error('MongoDB no está disponible');
}
@@ -989,9 +1061,22 @@ export async function createSession(username) {
try {
const sessionsCollection = db.collection('sessions');
// Si hay fingerprint, eliminar sesiones anteriores del mismo dispositivo/usuario
if (fingerprint) {
await sessionsCollection.deleteMany({
username,
fingerprint,
expiresAt: { $gt: new Date() } // Solo eliminar sesiones activas
});
}
// Crear nueva sesión con fingerprint
await sessionsCollection.insertOne({
token,
username,
fingerprint: fingerprint || null,
deviceInfo: deviceInfo || null,
createdAt: new Date(),
expiresAt,
});
@@ -1046,15 +1131,95 @@ export async function deleteUserSessions(username) {
}
}
export async function getAllSessions() {
if (!db) {
return [];
}
try {
const sessionsCollection = db.collection('sessions');
const sessions = await sessionsCollection.find({}).toArray();
return sessions.map(session => ({
token: session.token,
username: session.username,
fingerprint: session.fingerprint || null,
deviceInfo: session.deviceInfo || null,
createdAt: session.createdAt,
expiresAt: session.expiresAt,
isExpired: session.expiresAt ? new Date(session.expiresAt) < new Date() : false,
}));
} catch (error) {
console.error('Error obteniendo todas las sesiones:', error.message);
return [];
}
}
// Funciones para artículos
export async function getArticle(platform, id) {
export async function getArticle(platform, id, currentUsername = null) {
if (!db) {
return null;
}
try {
const articlesCollection = db.collection('articles');
return await articlesCollection.findOne({ platform, id });
const article = await articlesCollection.findOne({ platform, id });
if (!article) {
return null;
}
// Transformar el artículo similar a getNotifiedArticles para incluir is_favorite
const result = {
...article,
_id: article._id.toString(),
expiresAt: article.expiresAt?.getTime() || null,
};
const fallbackTime =
article.createdAt?.getTime?.() ||
(typeof article.createdAt === 'number' ? article.createdAt : null) ||
article.modified_at?.getTime?.() ||
null;
// Si hay un usuario actual, buscar su is_favorite específico
if (currentUsername) {
const currentUserInfo = (article.user_info || []).find(
ui => ui.username === currentUsername
);
if (currentUserInfo) {
result.is_favorite = currentUserInfo.is_favorite || false;
result.notifiedAt =
currentUserInfo.notified_at?.getTime?.() ||
(typeof currentUserInfo.notified_at === 'number' ? currentUserInfo.notified_at : null) ||
fallbackTime;
} else {
// Si no hay user_info para este usuario, usar false
result.is_favorite = false;
}
} else {
// Sin usuario específico, usar el primer user_info o datos generales
const firstUserInfo = (article.user_info || [])[0];
if (firstUserInfo) {
result.username = firstUserInfo.username;
result.worker_name = firstUserInfo.worker_name;
result.is_favorite = firstUserInfo.is_favorite || false;
result.notifiedAt =
firstUserInfo.notified_at?.getTime?.() ||
(typeof firstUserInfo.notified_at === 'number' ? firstUserInfo.notified_at : null) ||
fallbackTime;
} else {
// Compatibilidad con estructura antigua
result.username = article.username;
result.worker_name = article.worker_name;
result.is_favorite = article.is_favorite || false;
result.notifiedAt =
article.notifiedAt?.getTime?.() ||
(typeof article.notifiedAt === 'number' ? article.notifiedAt : null) ||
fallbackTime;
}
}
return result;
} catch (error) {
console.error('Error obteniendo artículo:', error.message);
return null;

View File

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