fixes: favoritos y mas cosas
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user