feat: implement user authentication and login modal, refactor backend
This commit is contained in:
115
web/backend/services/articleMonitor.js
Normal file
115
web/backend/services/articleMonitor.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import { getRedisClient, initNotifiedArticleKeys } from './redis.js';
|
||||
import { broadcast } from './websocket.js';
|
||||
import { sendPushNotifications } from './webPush.js';
|
||||
import { ARTICLE_MONITORING } from '../config/constants.js';
|
||||
|
||||
let notifiedArticleKeys = new Set();
|
||||
let articlesCheckInterval = null;
|
||||
|
||||
// Función para detectar y enviar artículos nuevos
|
||||
async function checkForNewArticles() {
|
||||
const redisClient = getRedisClient();
|
||||
if (!redisClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentKeys = await redisClient.keys('notified:*');
|
||||
const currentKeysSet = new Set(currentKeys);
|
||||
|
||||
// Encontrar claves nuevas
|
||||
const newKeys = currentKeys.filter(key => !notifiedArticleKeys.has(key));
|
||||
|
||||
if (newKeys.length > 0) {
|
||||
// Obtener los artículos nuevos
|
||||
const newArticles = [];
|
||||
for (const key of newKeys) {
|
||||
try {
|
||||
const value = await redisClient.get(key);
|
||||
if (value) {
|
||||
// Intentar parsear como JSON
|
||||
let articleData = {};
|
||||
try {
|
||||
articleData = JSON.parse(value);
|
||||
} catch (e) {
|
||||
// Si no es JSON válido, extraer información de la key
|
||||
const parts = key.split(':');
|
||||
if (parts.length >= 3) {
|
||||
articleData = {
|
||||
platform: parts[1],
|
||||
id: parts.slice(2).join(':'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Añadir información adicional si está disponible
|
||||
if (articleData.platform && articleData.id) {
|
||||
newArticles.push({
|
||||
platform: articleData.platform || 'unknown',
|
||||
id: articleData.id || 'unknown',
|
||||
title: articleData.title || null,
|
||||
price: articleData.price || null,
|
||||
currency: articleData.currency || '€',
|
||||
url: articleData.url || null,
|
||||
images: articleData.images || [],
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error obteniendo artículo de Redis (${key}):`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Enviar artículos nuevos por WebSocket
|
||||
if (newArticles.length > 0) {
|
||||
broadcast({
|
||||
type: 'new_articles',
|
||||
data: newArticles
|
||||
});
|
||||
|
||||
// Enviar notificaciones push para cada artículo nuevo
|
||||
for (const article of newArticles) {
|
||||
await sendPushNotifications({
|
||||
title: `Nuevo artículo en ${article.platform?.toUpperCase() || 'Wallabicher'}`,
|
||||
body: article.title || 'Artículo nuevo disponible',
|
||||
icon: '/android-chrome-192x192.png',
|
||||
image: article.images?.[0] || null,
|
||||
url: article.url || '/',
|
||||
platform: article.platform,
|
||||
price: article.price,
|
||||
currency: article.currency || '€',
|
||||
id: article.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Actualizar el set de claves notificadas
|
||||
notifiedArticleKeys = currentKeysSet;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error verificando artículos nuevos:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Inicializar el check de artículos cuando Redis esté listo
|
||||
export async function startArticleMonitoring() {
|
||||
const redisClient = getRedisClient();
|
||||
if (redisClient) {
|
||||
// Inicializar claves conocidas
|
||||
notifiedArticleKeys = await initNotifiedArticleKeys();
|
||||
|
||||
// Iniciar intervalo para verificar nuevos artículos
|
||||
articlesCheckInterval = setInterval(checkForNewArticles, ARTICLE_MONITORING.CHECK_INTERVAL);
|
||||
console.log('✅ Monitoreo de artículos nuevos iniciado');
|
||||
}
|
||||
}
|
||||
|
||||
// Detener el monitoreo
|
||||
export function stopArticleMonitoring() {
|
||||
if (articlesCheckInterval) {
|
||||
clearInterval(articlesCheckInterval);
|
||||
articlesCheckInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
41
web/backend/services/fileWatcher.js
Normal file
41
web/backend/services/fileWatcher.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { watch } from 'chokidar';
|
||||
import { existsSync } from 'fs';
|
||||
import { PATHS } from '../config/constants.js';
|
||||
import { readJSON } from '../utils/fileUtils.js';
|
||||
import { broadcast } from './websocket.js';
|
||||
|
||||
let watcher = null;
|
||||
|
||||
// Inicializar file watcher
|
||||
export function initFileWatcher() {
|
||||
// Watch files for changes
|
||||
const filesToWatch = [PATHS.WORKERS].filter(p => existsSync(p));
|
||||
|
||||
if (filesToWatch.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
watcher = watch(filesToWatch, {
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
});
|
||||
|
||||
watcher.on('change', async (path) => {
|
||||
console.log(`Archivo cambiado: ${path}`);
|
||||
if (path === PATHS.WORKERS) {
|
||||
const workers = readJSON(PATHS.WORKERS);
|
||||
broadcast({ type: 'workers_updated', data: workers });
|
||||
}
|
||||
});
|
||||
|
||||
console.log('✅ File watcher inicializado');
|
||||
}
|
||||
|
||||
// Detener file watcher
|
||||
export function stopFileWatcher() {
|
||||
if (watcher) {
|
||||
watcher.close();
|
||||
watcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
219
web/backend/services/redis.js
Normal file
219
web/backend/services/redis.js
Normal file
@@ -0,0 +1,219 @@
|
||||
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';
|
||||
|
||||
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();
|
||||
|
||||
} 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,
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
79
web/backend/services/webPush.js
Normal file
79
web/backend/services/webPush.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import webpush from 'web-push';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { PATHS, VAPID_CONTACT } from '../config/constants.js';
|
||||
import { readJSON, writeJSON } from '../utils/fileUtils.js';
|
||||
|
||||
let vapidKeys = null;
|
||||
|
||||
// Inicializar VAPID keys para Web Push
|
||||
export function initVAPIDKeys() {
|
||||
try {
|
||||
if (existsSync(PATHS.VAPID_KEYS)) {
|
||||
vapidKeys = JSON.parse(readFileSync(PATHS.VAPID_KEYS, 'utf8'));
|
||||
console.log('✅ VAPID keys cargadas desde archivo');
|
||||
} else {
|
||||
// Generar nuevas VAPID keys
|
||||
vapidKeys = webpush.generateVAPIDKeys();
|
||||
writeFileSync(PATHS.VAPID_KEYS, JSON.stringify(vapidKeys, null, 2), 'utf8');
|
||||
console.log('✅ Nuevas VAPID keys generadas y guardadas');
|
||||
}
|
||||
|
||||
// Configurar web-push con las VAPID keys
|
||||
webpush.setVapidDetails(
|
||||
VAPID_CONTACT,
|
||||
vapidKeys.publicKey,
|
||||
vapidKeys.privateKey
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error inicializando VAPID keys:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener clave pública VAPID
|
||||
export function getPublicKey() {
|
||||
if (!vapidKeys || !vapidKeys.publicKey) {
|
||||
throw new Error('VAPID keys no están configuradas');
|
||||
}
|
||||
return vapidKeys.publicKey;
|
||||
}
|
||||
|
||||
// Obtener suscripciones push guardadas
|
||||
export function getPushSubscriptions() {
|
||||
return readJSON(PATHS.PUSH_SUBSCRIPTIONS, []);
|
||||
}
|
||||
|
||||
// Guardar suscripciones push
|
||||
export function savePushSubscriptions(subscriptions) {
|
||||
return writeJSON(PATHS.PUSH_SUBSCRIPTIONS, subscriptions);
|
||||
}
|
||||
|
||||
// Enviar notificación push a todas las suscripciones
|
||||
export async function sendPushNotifications(notificationData) {
|
||||
const subscriptions = getPushSubscriptions();
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify(notificationData);
|
||||
const promises = subscriptions.map(async (subscription) => {
|
||||
try {
|
||||
await webpush.sendNotification(subscription, payload);
|
||||
console.log('✅ Notificación push enviada');
|
||||
} catch (error) {
|
||||
console.error('Error enviando notificación push:', error);
|
||||
|
||||
// Si la suscripción es inválida (404, 410), eliminarla
|
||||
if (error.statusCode === 404 || error.statusCode === 410) {
|
||||
const updatedSubscriptions = getPushSubscriptions().filter(
|
||||
sub => sub.endpoint !== subscription.endpoint
|
||||
);
|
||||
savePushSubscriptions(updatedSubscriptions);
|
||||
console.log(`Suscripción inválida eliminada. Total: ${updatedSubscriptions.length}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
39
web/backend/services/websocket.js
Normal file
39
web/backend/services/websocket.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
let wss = null;
|
||||
|
||||
// Inicializar WebSocket Server
|
||||
export function initWebSocket(server) {
|
||||
wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
console.log('Cliente WebSocket conectado');
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('Cliente WebSocket desconectado');
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('Error WebSocket:', error);
|
||||
});
|
||||
});
|
||||
|
||||
return wss;
|
||||
}
|
||||
|
||||
// Broadcast a todos los clientes WebSocket
|
||||
export function broadcast(data) {
|
||||
if (!wss) return;
|
||||
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === 1) { // WebSocket.OPEN
|
||||
client.send(JSON.stringify(data));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Obtener instancia del WebSocket Server
|
||||
export function getWebSocketServer() {
|
||||
return wss;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user