Files
wallabicher/web/backend/services/mongodb.js
Omar Sánchez Pizarro 084e2eebff no update fechas
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
2026-01-20 12:55:53 +01:00

827 lines
25 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
// 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 });
// Índices para ordenamiento (NO usar user_info.notified_at porque es un array)
await db.collection('articles').createIndex({ createdAt: -1 });
await db.collection('articles').createIndex({ modified_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 });
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;
}
// Ordenar SOLO por campos a nivel de artículo, NO por user_info.notified_at
// porque user_info es un array y puede causar orden incorrecto
const articles = await articlesCollection
.find(query)
.sort({ createdAt: -1, modified_at: -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
const fallbackTime =
article.createdAt?.getTime?.() ||
(typeof article.createdAt === 'number' ? article.createdAt : null) ||
article.modified_at?.getTime?.() ||
null;
if (relevantUserInfo) {
result.username = relevantUserInfo.username;
result.worker_name = relevantUserInfo.worker_name;
result.is_favorite = relevantUserInfo.is_favorite || false;
// NO usar Date.now() como fallback, para no mover artículos antiguos
result.notifiedAt =
relevantUserInfo.notified_at?.getTime?.() ||
(typeof relevantUserInfo.notified_at === 'number' ? relevantUserInfo.notified_at : null) ||
fallbackTime;
} 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?.() ||
(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;
}).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({ createdAt: -1, modified_at: -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,
// NOTA: no usar Date.now() como fallback, para no mover favoritos antiguos
notifiedAt:
userInfo.notified_at?.getTime?.() ||
(typeof userInfo.notified_at === 'number' ? userInfo.notified_at : null) ||
article.createdAt?.getTime?.() ||
(typeof article.createdAt === 'number' ? article.createdAt : null) ||
article.modified_at?.getTime?.() ||
null,
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,
// NOTA: no usar Date.now() como fallback, para no mover favoritos antiguos
notifiedAt:
userInfo.notified_at?.getTime?.() ||
(typeof userInfo.notified_at === 'number' ? userInfo.notified_at : null) ||
article.createdAt?.getTime?.() ||
(typeof article.createdAt === 'number' ? article.createdAt : null) ||
article.modified_at?.getTime?.() ||
null,
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 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');
}
}