feat: implement user authentication and login modal, refactor backend

This commit is contained in:
Omar Sánchez Pizarro
2026-01-20 00:39:28 +01:00
parent 9a61f16959
commit e99424c9ba
29 changed files with 3061 additions and 855 deletions

BIN
logo.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

4
vapid-keys.json Normal file
View File

@@ -0,0 +1,4 @@
{
"publicKey": "BL7OVKXYntCaPe1ZW8rd2VyKnks7QV8UjekJItGqsJ1S8dfXggPeqIbZ8aOopkIpFMwwhqh1WA9xZp_gDiDYPAU",
"privateKey": "gZTPDru8a6WetWfkBtscyeUx7A9TNGPOq5zDPTyFkNQ"
}

104
web/backend/README.md Normal file
View File

@@ -0,0 +1,104 @@
# Backend - Estructura del Código
Este directorio contiene el backend de la aplicación Wallabicher, organizado en una estructura modular y mantenible.
## Estructura de Carpetas
```
backend/
├── config/ # Configuración y constantes
│ └── constants.js # Constantes del sistema (paths, puertos, límites)
├── middlewares/ # Middlewares de Express
│ ├── auth.js # Autenticación básica
│ └── rateLimit.js # Rate limiting
├── services/ # Servicios y lógica de negocio
│ ├── redis.js # Cliente Redis y operaciones de caché
│ ├── webPush.js # Gestión de notificaciones push (VAPID)
│ ├── websocket.js # Gestión de conexiones WebSocket
│ ├── articleMonitor.js # Monitoreo de artículos nuevos
│ └── fileWatcher.js # Vigilancia de cambios en archivos
├── routes/ # Rutas API organizadas por dominio
│ ├── index.js # Rutas principales (stats, cache)
│ ├── workers.js # Gestión de workers
│ ├── articles.js # Artículos y búsqueda
│ ├── favorites.js # Favoritos
│ ├── logs.js # Logs del sistema
│ ├── config.js # Configuración
│ ├── telegram.js # Integración con Telegram
│ ├── push.js # Notificaciones push
│ └── users.js # Gestión de usuarios
├── utils/ # Utilidades
│ └── fileUtils.js # Operaciones de archivos
└── server.js # Archivo principal de entrada
```
## Descripción de Módulos
### Config (`config/`)
- **constants.js**: Centraliza todas las constantes del sistema (paths de archivos, puertos, configuración de rate limiting, etc.)
### Middlewares (`middlewares/`)
- **auth.js**: Middleware de autenticación básica usando Redis para almacenar usuarios
- **rateLimit.js**: Middleware de rate limiting usando Redis
### Services (`services/`)
- **redis.js**: Cliente Redis, inicialización, y funciones para trabajar con artículos y usuarios
- **webPush.js**: Gestión de VAPID keys y envío de notificaciones push
- **websocket.js**: Gestión del servidor WebSocket y broadcasting
- **articleMonitor.js**: Monitorea Redis para detectar nuevos artículos y enviar notificaciones
- **fileWatcher.js**: Vigila cambios en archivos (workers.json) y emite eventos WebSocket
### Routes (`routes/`)
Cada archivo maneja un conjunto relacionado de endpoints:
- **index.js**: `/api/stats`, `/api/cache`
- **workers.js**: `/api/workers` (GET, PUT)
- **articles.js**: `/api/articles` (GET, search)
- **favorites.js**: `/api/favorites` (GET, POST, DELETE)
- **logs.js**: `/api/logs` (GET)
- **config.js**: `/api/config` (GET)
- **telegram.js**: `/api/telegram/threads` (GET)
- **push.js**: `/api/push/*` (public-key, subscribe, unsubscribe)
- **users.js**: `/api/users` (GET, POST, DELETE, change-password)
### Utils (`utils/`)
- **fileUtils.js**: Funciones auxiliares para leer/escribir JSON y logs
### Server (`server.js`)
Archivo principal que:
1. Inicializa Express y el servidor HTTP
2. Configura middlewares globales
3. Registra todas las rutas
4. Inicializa servicios (Redis, WebPush, WebSocket)
5. Inicia monitoreo y watchers
6. Arranca el servidor
## Inicio del Servidor
```bash
npm start
# o para desarrollo con auto-reload
npm run dev
```
El servidor se inicia en el puerto definido en `SERVER.PORT` (por defecto 3001).
## Flujo de Inicialización
1. **server.js** carga y configura Express
2. Inicializa VAPID keys para push notifications
3. Crea el servidor WebSocket
4. Registra todas las rutas API
5. Inicializa Redis (conexión, rate limiter, usuario admin por defecto)
6. Inicia el monitoreo de artículos nuevos
7. Inicia el file watcher para workers.json
8. Inicia el servidor HTTP
## Notas de Migración
El código original en `server.js` (1190 líneas) ha sido dividido en módulos más pequeños y mantenibles. Cada módulo tiene una responsabilidad única:
- **Separación de responsabilidades**: Cada archivo tiene un propósito claro
- **Reutilización**: Servicios y utilidades pueden ser reutilizados fácilmente
- **Testabilidad**: Cada módulo puede ser probado de forma independiente
- **Mantenibilidad**: Es más fácil encontrar y modificar código específico

View File

@@ -0,0 +1,34 @@
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// En Docker, usar PROJECT_ROOT de env, sino usar ruta relativa
// Desde config/constants.js necesitamos ir 3 niveles arriba para llegar al root del proyecto
const PROJECT_ROOT = process.env.PROJECT_ROOT || join(__dirname, '../../..');
export const PATHS = {
PROJECT_ROOT,
CONFIG: join(PROJECT_ROOT, 'config.yaml'),
WORKERS: join(PROJECT_ROOT, 'workers.json'),
PUSH_SUBSCRIPTIONS: join(PROJECT_ROOT, 'push-subscriptions.json'),
VAPID_KEYS: join(PROJECT_ROOT, 'vapid-keys.json'),
};
export const SERVER = {
PORT: process.env.PORT || 3001,
};
export const RATE_LIMIT = {
POINTS: 100,
DURATION: 60, // segundos
BLOCK_DURATION: 60, // segundos
};
export const ARTICLE_MONITORING = {
CHECK_INTERVAL: 3000, // milisegundos
};
export const VAPID_CONTACT = 'mailto:admin@pribyte.cloud';

View File

@@ -0,0 +1,65 @@
import bcrypt from 'bcrypt';
import { getRedisClient } from '../services/redis.js';
// Autenticación básica Middleware
export async function basicAuthMiddleware(req, res, next) {
const redisClient = getRedisClient();
if (!redisClient) {
return res.status(500).json({ error: 'Redis no está disponible. La autenticación requiere Redis.' });
}
const authHeader = req.headers.authorization;
if (!authHeader) {
// NO enviar WWW-Authenticate para evitar el diálogo nativo del navegador
// En su lugar, devolveremos un 401 y el frontend manejará el modal personalizado
return res.status(401).json({ error: 'Authentication required', message: 'Se requiere autenticación para esta operación' });
}
const [scheme, encoded] = authHeader.split(' ');
if (scheme !== 'Basic') {
return res.status(400).json({ error: 'Bad request', message: 'Solo se admite autenticación Basic' });
}
try {
const buffer = Buffer.from(encoded, 'base64');
const [username, password] = buffer.toString().split(':');
if (!username || !password) {
return res.status(401).json({ error: 'Invalid credentials', message: 'Usuario o contraseña no proporcionados' });
}
// Buscar usuario en Redis
const userKey = `user:${username}`;
const userExists = await redisClient.exists(userKey);
if (!userExists) {
return res.status(401).json({ error: 'Invalid credentials', message: 'Usuario o contraseña incorrectos' });
}
// Obtener hash de la contraseña
const userData = await redisClient.hGetAll(userKey);
const passwordHash = userData.passwordHash;
if (!passwordHash) {
return res.status(401).json({ error: 'Invalid credentials', message: 'Usuario o contraseña incorrectos' });
}
// Verificar contraseña
const match = await bcrypt.compare(password, passwordHash);
if (!match) {
return res.status(401).json({ error: 'Invalid credentials', message: 'Usuario o contraseña incorrectos' });
}
// Autenticación exitosa
req.user = { username };
next();
} catch (error) {
console.error('Error en autenticación:', error);
res.status(500).json({ error: 'Internal server error', message: 'Error procesando autenticación' });
}
}

View File

@@ -0,0 +1,30 @@
import { getRateLimiter } from '../services/redis.js';
// Rate Limiter Middleware
export async function rateLimitMiddleware(req, res, next) {
const rateLimiter = getRateLimiter();
if (!rateLimiter) {
// Si no hay rate limiter configurado, continuar sin límite
return next();
}
try {
// Usar IP como clave para el rate limiting
const key = req.ip || req.socket.remoteAddress || 'unknown';
await rateLimiter.consume(key);
next();
} catch (rejRes) {
// Se excedió el límite de peticiones
const retryAfter = Math.round(rejRes.msBeforeNext / 1000) || 60;
res.set('Retry-After', String(retryAfter));
res.set('X-RateLimit-Limit', '100');
res.set('X-RateLimit-Remaining', '0');
res.status(429).json({
error: 'Too Many Requests',
message: `Has excedido el límite de peticiones. Intenta de nuevo en ${retryAfter} segundos.`,
retryAfter
});
}
}

View File

@@ -9,15 +9,85 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"bcrypt": "^5.1.1",
"chokidar": "^3.5.3",
"cors": "^2.8.5",
"express": "^4.18.2",
"rate-limiter-flexible": "^5.0.3",
"redis": "^4.6.10",
"web-push": "^3.6.7",
"ws": "^8.14.2",
"yaml": "^2.3.4"
}
},
"node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
"integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
"license": "BSD-3-Clause",
"dependencies": {
"detect-libc": "^2.0.0",
"https-proxy-agent": "^5.0.0",
"make-dir": "^3.1.0",
"node-fetch": "^2.6.7",
"nopt": "^5.0.0",
"npmlog": "^5.0.1",
"rimraf": "^3.0.2",
"semver": "^7.3.5",
"tar": "^6.1.11"
},
"bin": {
"node-pre-gyp": "bin/node-pre-gyp"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/@redis/bloom": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
@@ -77,6 +147,12 @@
"@redis/client": "^1.0.0"
}
},
"node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"license": "ISC"
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -99,6 +175,15 @@
"node": ">= 14"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -112,6 +197,26 @@
"node": ">= 8"
}
},
"node_modules/aproba": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
"integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
"license": "ISC"
},
"node_modules/are-we-there-yet": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
"integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"dependencies": {
"delegates": "^1.0.0",
"readable-stream": "^3.6.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -130,6 +235,26 @@
"safer-buffer": "^2.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/bcrypt": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz",
"integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@mapbox/node-pre-gyp": "^1.0.11",
"node-addon-api": "^5.0.0"
},
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -172,6 +297,16 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
@@ -252,6 +387,15 @@
"fsevents": "~2.3.2"
}
},
"node_modules/chownr": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
"integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
@@ -261,6 +405,27 @@
"node": ">=0.10.0"
}
},
"node_modules/color-support": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
"license": "ISC",
"bin": {
"color-support": "bin.js"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
"node_modules/console-control-strings": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
"integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
"license": "ISC"
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -319,6 +484,12 @@
"ms": "2.0.0"
}
},
"node_modules/delegates": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
"license": "MIT"
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -338,6 +509,15 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -367,6 +547,12 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -515,6 +701,36 @@
"node": ">= 0.6"
}
},
"node_modules/fs-minipass": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
"integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
"license": "ISC",
"dependencies": {
"minipass": "^3.0.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/fs-minipass/node_modules/minipass": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -538,6 +754,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gauge": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
"integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"dependencies": {
"aproba": "^1.0.3 || ^2.0.0",
"color-support": "^1.1.2",
"console-control-strings": "^1.0.0",
"has-unicode": "^2.0.1",
"object-assign": "^4.1.1",
"signal-exit": "^3.0.0",
"string-width": "^4.2.3",
"strip-ansi": "^6.0.1",
"wide-align": "^1.1.2"
},
"engines": {
"node": ">=10"
}
},
"node_modules/generic-pool": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
@@ -584,6 +821,27 @@
"node": ">= 0.4"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
@@ -620,6 +878,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-unicode": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
"license": "ISC"
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -709,6 +973,17 @@
"node": ">=0.10.0"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -745,6 +1020,15 @@
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -787,6 +1071,30 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
"license": "MIT",
"dependencies": {
"semver": "^6.0.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -862,6 +1170,18 @@
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -871,6 +1191,52 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
"license": "ISC",
"engines": {
"node": ">=8"
}
},
"node_modules/minizlib": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
"integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
"license": "MIT",
"dependencies": {
"minipass": "^3.0.0",
"yallist": "^4.0.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/minizlib/node_modules/minipass": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/mkdirp": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
"license": "MIT",
"bin": {
"mkdirp": "bin/cmd.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -886,6 +1252,47 @@
"node": ">= 0.6"
}
},
"node_modules/node-addon-api": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==",
"license": "MIT"
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/nopt": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
"integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
"license": "ISC",
"dependencies": {
"abbrev": "1"
},
"bin": {
"nopt": "bin/nopt.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -895,6 +1302,19 @@
"node": ">=0.10.0"
}
},
"node_modules/npmlog": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
"integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"dependencies": {
"are-we-there-yet": "^2.0.0",
"console-control-strings": "^1.1.0",
"gauge": "^3.0.0",
"set-blocking": "^2.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -928,6 +1348,15 @@
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -937,6 +1366,15 @@
"node": ">= 0.8"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
@@ -992,6 +1430,12 @@
"node": ">= 0.6"
}
},
"node_modules/rate-limiter-flexible": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-5.0.5.tgz",
"integrity": "sha512-+/dSQfo+3FYwYygUs/V2BBdwGa9nFtakDwKt4l0bnvNB53TNT++QSFewwHX9qXrZJuMe9j+TUaU21lm5ARgqdQ==",
"license": "ISC"
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
@@ -1007,6 +1451,20 @@
"node": ">= 0.8"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -1036,6 +1494,22 @@
"@redis/time-series": "1.1.0"
}
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1062,6 +1536,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@@ -1107,6 +1593,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -1185,6 +1677,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1194,6 +1692,59 @@
"node": ">= 0.8"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
"deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me",
"license": "ISC",
"dependencies": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
"minipass": "^5.0.0",
"minizlib": "^2.1.1",
"mkdirp": "^1.0.3",
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -1215,6 +1766,12 @@
"node": ">=0.6"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -1237,6 +1794,12 @@
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -1274,6 +1837,37 @@
"node": ">= 16"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/wide-align": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
"integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
"license": "ISC",
"dependencies": {
"string-width": "^1.0.2 || 2 || 3 || 4"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",

View File

@@ -16,9 +16,11 @@
"author": "",
"license": "MIT",
"dependencies": {
"bcrypt": "^5.1.1",
"chokidar": "^3.5.3",
"cors": "^2.8.5",
"express": "^4.18.2",
"rate-limiter-flexible": "^5.0.3",
"redis": "^4.6.10",
"web-push": "^3.6.7",
"ws": "^8.14.2",

View File

@@ -0,0 +1,81 @@
import express from 'express';
import { getNotifiedArticles } from '../services/redis.js';
const router = express.Router();
// Obtener artículos notificados
router.get('/', async (req, res) => {
try {
const articles = await getNotifiedArticles();
const limit = parseInt(req.query.limit) || 100;
const offset = parseInt(req.query.offset) || 0;
const sorted = articles.sort((a, b) => b.notifiedAt - a.notifiedAt);
const paginated = sorted.slice(offset, offset + limit);
res.json({
articles: paginated,
total: articles.length,
limit,
offset,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Buscar artículos en Redis
router.get('/search', async (req, res) => {
try {
const query = req.query.q || '';
if (!query.trim()) {
return res.json({ articles: [], total: 0 });
}
const searchTerm = query.toLowerCase().trim();
const allArticles = await getNotifiedArticles();
// Filtrar artículos que coincidan con la búsqueda
const filtered = allArticles.filter(article => {
// Buscar en título
const title = (article.title || '').toLowerCase();
if (title.includes(searchTerm)) return true;
// Buscar en descripción
const description = (article.description || '').toLowerCase();
if (description.includes(searchTerm)) return true;
// Buscar en localidad
const location = (article.location || '').toLowerCase();
if (location.includes(searchTerm)) return true;
// Buscar en precio (como número o texto)
const price = String(article.price || '').toLowerCase();
if (price.includes(searchTerm)) return true;
// Buscar en plataforma
const platform = (article.platform || '').toLowerCase();
if (platform.includes(searchTerm)) return true;
// Buscar en ID
const id = String(article.id || '').toLowerCase();
if (id.includes(searchTerm)) return true;
return false;
});
// Ordenar por fecha de notificación (más recientes primero)
const sorted = filtered.sort((a, b) => b.notifiedAt - a.notifiedAt);
res.json({
articles: sorted,
total: sorted.length,
query: query,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,29 @@
import express from 'express';
import { basicAuthMiddleware } from '../middlewares/auth.js';
import { getConfig, reloadConfig } from '../services/redis.js';
import { readFileSync } from 'fs';
import yaml from 'yaml';
import { PATHS } from '../config/constants.js';
const router = express.Router();
// Obtener configuración
router.get('/', basicAuthMiddleware, (req, res) => {
try {
let config = getConfig();
if (!config) {
config = yaml.parse(readFileSync(PATHS.CONFIG, 'utf8'));
}
// No enviar token por seguridad
const safeConfig = { ...config };
if (safeConfig.telegram_token) {
safeConfig.telegram_token = '***';
}
res.json(safeConfig);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,99 @@
import express from 'express';
import { getFavorites, getRedisClient } from '../services/redis.js';
import { basicAuthMiddleware } from '../middlewares/auth.js';
import { broadcast } from '../services/websocket.js';
const router = express.Router();
// Obtener favoritos
router.get('/', async (req, res) => {
try {
const favorites = await getFavorites();
res.json(favorites);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Añadir favorito (requiere autenticación)
router.post('/', basicAuthMiddleware, async (req, res) => {
try {
const redisClient = getRedisClient();
if (!redisClient) {
return res.status(500).json({ error: 'Redis no está disponible' });
}
const { platform, id } = req.body;
if (!platform || !id) {
return res.status(400).json({ error: 'platform e id son requeridos' });
}
const key = `notified:${platform}:${id}`;
const value = await redisClient.get(key);
if (!value) {
return res.status(404).json({ error: 'Artículo no encontrado' });
}
try {
const articleData = JSON.parse(value);
articleData.is_favorite = true;
// Mantener el TTL existente
const ttl = await redisClient.ttl(key);
if (ttl > 0) {
await redisClient.setex(key, ttl, JSON.stringify(articleData));
} else {
await redisClient.set(key, JSON.stringify(articleData));
}
const favorites = await getFavorites();
broadcast({ type: 'favorites_updated', data: favorites });
res.json({ success: true, favorites });
} catch (e) {
res.status(500).json({ error: 'Error procesando artículo' });
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Eliminar favorito (requiere autenticación)
router.delete('/:platform/:id', basicAuthMiddleware, async (req, res) => {
try {
const redisClient = getRedisClient();
if (!redisClient) {
return res.status(500).json({ error: 'Redis no está disponible' });
}
const { platform, id } = req.params;
const key = `notified:${platform}:${id}`;
const value = await redisClient.get(key);
if (!value) {
return res.status(404).json({ error: 'Artículo no encontrado' });
}
try {
const articleData = JSON.parse(value);
articleData.is_favorite = false;
// Mantener el TTL existente
const ttl = await redisClient.ttl(key);
if (ttl > 0) {
await redisClient.setex(key, ttl, JSON.stringify(articleData));
} else {
await redisClient.set(key, JSON.stringify(articleData));
}
const favorites = await getFavorites();
broadcast({ type: 'favorites_updated', data: favorites });
res.json({ success: true, favorites });
} catch (e) {
res.status(500).json({ error: 'Error procesando artículo' });
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,81 @@
import express from 'express';
import { readJSON } from '../utils/fileUtils.js';
import { PATHS } from '../config/constants.js';
import { getFavorites, getNotifiedArticles, getRedisClient } from '../services/redis.js';
import { basicAuthMiddleware } from '../middlewares/auth.js';
import { broadcast } from '../services/websocket.js';
const router = express.Router();
// Obtener estadísticas
router.get('/stats', async (req, res) => {
try {
const workers = readJSON(PATHS.WORKERS, { items: [] });
const favorites = await getFavorites();
const notifiedArticles = await getNotifiedArticles();
const stats = {
totalWorkers: workers.items?.length || 0,
activeWorkers: (workers.items || []).filter(w => !workers.disabled?.includes(w.name)).length,
totalFavorites: favorites.length,
totalNotified: notifiedArticles.length,
platforms: {
wallapop: notifiedArticles.filter(a => a.platform === 'wallapop').length,
vinted: notifiedArticles.filter(a => a.platform === 'vinted').length,
},
};
res.json(stats);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Limpiar toda la caché de Redis (requiere autenticación)
router.delete('/cache', basicAuthMiddleware, async (req, res) => {
try {
const redisClient = getRedisClient();
if (!redisClient) {
return res.status(500).json({ error: 'Redis no está disponible' });
}
// Obtener todas las claves que empiezan con 'notified:'
const keys = await redisClient.keys('notified:*');
if (!keys || keys.length === 0) {
return res.json({
success: true,
message: 'Cache ya está vacío',
count: 0
});
}
// Eliminar todas las claves
const count = keys.length;
for (const key of keys) {
await redisClient.del(key);
}
// Notificar a los clientes WebSocket
broadcast({
type: 'cache_cleared',
data: { count, timestamp: Date.now() }
});
// También actualizar favoritos (debería estar vacío ahora)
const favorites = await getFavorites();
broadcast({ type: 'favorites_updated', data: favorites });
res.json({
success: true,
message: `Cache limpiado: ${count} artículos eliminados`,
count
});
} catch (error) {
console.error('Error limpiando cache de Redis:', error);
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,22 @@
import express from 'express';
import { basicAuthMiddleware } from '../middlewares/auth.js';
import { getLogPath, readLogs } from '../utils/fileUtils.js';
const router = express.Router();
// Obtener logs (últimas líneas o nuevas líneas desde un número de línea)
router.get('/', basicAuthMiddleware, (req, res) => {
try {
const logPath = getLogPath();
const sinceLine = parseInt(req.query.since) || 0;
const limit = parseInt(req.query.limit) || 500;
const result = readLogs(logPath, { sinceLine, limit });
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,73 @@
import express from 'express';
import { getPublicKey, getPushSubscriptions, savePushSubscriptions } from '../services/webPush.js';
const router = express.Router();
// Obtener clave pública VAPID
router.get('/public-key', (req, res) => {
try {
const publicKey = getPublicKey();
res.json({ publicKey });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Suscribirse a notificaciones push
router.post('/subscribe', async (req, res) => {
try {
const subscription = req.body;
if (!subscription || !subscription.endpoint) {
return res.status(400).json({ error: 'Suscripción inválida' });
}
const subscriptions = getPushSubscriptions();
// Verificar si ya existe esta suscripción
const existingIndex = subscriptions.findIndex(
sub => sub.endpoint === subscription.endpoint
);
if (existingIndex >= 0) {
subscriptions[existingIndex] = subscription;
} else {
subscriptions.push(subscription);
}
savePushSubscriptions(subscriptions);
console.log(`✅ Nueva suscripción push guardada. Total: ${subscriptions.length}`);
res.json({ success: true, totalSubscriptions: subscriptions.length });
} catch (error) {
console.error('Error guardando suscripción push:', error);
res.status(500).json({ error: error.message });
}
});
// Cancelar suscripción push
router.post('/unsubscribe', async (req, res) => {
try {
const subscription = req.body;
if (!subscription || !subscription.endpoint) {
return res.status(400).json({ error: 'Suscripción inválida' });
}
const subscriptions = getPushSubscriptions();
const filtered = subscriptions.filter(
sub => sub.endpoint !== subscription.endpoint
);
savePushSubscriptions(filtered);
console.log(`✅ Suscripción push cancelada. Total: ${filtered.length}`);
res.json({ success: true, totalSubscriptions: filtered.length });
} catch (error) {
console.error('Error cancelando suscripción push:', error);
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,70 @@
import express from 'express';
import { basicAuthMiddleware } from '../middlewares/auth.js';
import { getConfig, reloadConfig } from '../services/redis.js';
import { readFileSync } from 'fs';
import yaml from 'yaml';
import { PATHS } from '../config/constants.js';
const router = express.Router();
// Obtener threads/topics de Telegram
router.get('/threads', basicAuthMiddleware, async (req, res) => {
try {
let config = getConfig();
if (!config) {
config = yaml.parse(readFileSync(PATHS.CONFIG, 'utf8'));
}
const token = config?.telegram_token;
const channel = config?.telegram_channel;
if (!token || !channel) {
return res.status(400).json({ error: 'Token o canal de Telegram no configurados' });
}
// Convertir el canal a chat_id si es necesario
let chatId = channel;
if (channel.startsWith('@')) {
// Para canales con @, necesitamos obtener el chat_id primero
const getChatUrl = `https://api.telegram.org/bot${token}/getChat?chat_id=${encodeURIComponent(channel)}`;
const chatResponse = await fetch(getChatUrl);
const chatData = await chatResponse.json();
if (!chatData.ok) {
return res.status(400).json({ error: `Error obteniendo chat: ${chatData.description || 'Chat no encontrado'}` });
}
chatId = chatData.result.id;
}
// Intentar obtener forum topics
const forumTopicsUrl = `https://api.telegram.org/bot${token}/getForumTopics?chat_id=${chatId}&limit=100`;
const topicsResponse = await fetch(forumTopicsUrl);
const topicsData = await topicsResponse.json();
if (topicsData.ok && topicsData.result?.topics) {
const threads = topicsData.result.topics.map(topic => ({
id: topic.message_thread_id,
name: topic.name || `Thread ${topic.message_thread_id}`,
icon_color: topic.icon_color,
icon_custom_emoji_id: topic.icon_custom_emoji_id,
}));
return res.json({ threads, success: true });
} else {
// Si no hay forum topics, devolver un mensaje informativo
return res.json({
threads: [],
success: false,
message: 'El chat no tiene forum topics habilitados o no se pudieron obtener. Puedes obtener el Thread ID manualmente copiando el enlace del tema.',
info: 'Para obtener el Thread ID manualmente: 1. Haz clic derecho en el tema/hilo en Telegram 2. Selecciona "Copiar enlace del tema" 3. El número al final de la URL es el Thread ID (ej: t.me/c/1234567890/8 → Thread ID = 8)'
});
}
} catch (error) {
console.error('Error obteniendo threads de Telegram:', error.message);
res.status(500).json({ error: error.message });
}
});
export default router;

177
web/backend/routes/users.js Normal file
View File

@@ -0,0 +1,177 @@
import express from 'express';
import bcrypt from 'bcrypt';
import { getRedisClient } from '../services/redis.js';
import { basicAuthMiddleware } from '../middlewares/auth.js';
const router = express.Router();
// Cambiar contraseña de usuario
router.post('/change-password', basicAuthMiddleware, async (req, res) => {
try {
const redisClient = getRedisClient();
if (!redisClient) {
return res.status(500).json({ error: 'Redis no está disponible' });
}
const { currentPassword, newPassword } = req.body;
const username = req.user.username;
if (!currentPassword || !newPassword) {
return res.status(400).json({ error: 'currentPassword y newPassword son requeridos' });
}
if (newPassword.length < 6) {
return res.status(400).json({ error: 'La nueva contraseña debe tener al menos 6 caracteres' });
}
const userKey = `user:${username}`;
const userData = await redisClient.hGetAll(userKey);
if (!userData || !userData.passwordHash) {
return res.status(404).json({ error: 'Usuario no encontrado' });
}
// Verificar contraseña actual
const match = await bcrypt.compare(currentPassword, userData.passwordHash);
if (!match) {
return res.status(401).json({ error: 'Contraseña actual incorrecta' });
}
// Hashear nueva contraseña y actualizar
const newPasswordHash = await bcrypt.hash(newPassword, 10);
await redisClient.hSet(userKey, {
...userData,
passwordHash: newPasswordHash,
updatedAt: new Date().toISOString(),
});
console.log(`✅ Contraseña actualizada para usuario: ${username}`);
res.json({ success: true, message: 'Contraseña actualizada correctamente' });
} catch (error) {
console.error('Error cambiando contraseña:', error);
res.status(500).json({ error: error.message });
}
});
// Obtener lista de usuarios (requiere autenticación admin)
router.get('/', basicAuthMiddleware, async (req, res) => {
try {
const redisClient = getRedisClient();
if (!redisClient) {
return res.status(500).json({ error: 'Redis no está disponible' });
}
// Obtener todas las claves de usuarios
const userKeys = await redisClient.keys('user:*');
const users = [];
for (const key of userKeys) {
const username = key.replace('user:', '');
const userData = await redisClient.hGetAll(key);
if (userData && userData.username) {
users.push({
username: userData.username,
createdAt: userData.createdAt || null,
updatedAt: userData.updatedAt || null,
createdBy: userData.createdBy || null,
});
}
}
// Ordenar por fecha de creación (más recientes primero)
users.sort((a, b) => {
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return dateB - dateA;
});
res.json({ users, total: users.length });
} catch (error) {
console.error('Error obteniendo usuarios:', error);
res.status(500).json({ error: error.message });
}
});
// Crear nuevo usuario (requiere autenticación admin)
router.post('/', basicAuthMiddleware, async (req, res) => {
try {
const redisClient = getRedisClient();
if (!redisClient) {
return res.status(500).json({ error: 'Redis no está disponible' });
}
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'username y password son requeridos' });
}
if (username.length < 3) {
return res.status(400).json({ error: 'El nombre de usuario debe tener al menos 3 caracteres' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'La contraseña debe tener al menos 6 caracteres' });
}
const userKey = `user:${username}`;
const userExists = await redisClient.exists(userKey);
if (userExists) {
return res.status(409).json({ error: 'El usuario ya existe' });
}
// Hashear contraseña y crear usuario
const passwordHash = await bcrypt.hash(password, 10);
await redisClient.hSet(userKey, {
username,
passwordHash,
createdAt: new Date().toISOString(),
createdBy: req.user.username,
});
console.log(`✅ Usuario creado: ${username} por ${req.user.username}`);
res.json({ success: true, message: 'Usuario creado correctamente', username });
} catch (error) {
console.error('Error creando usuario:', error);
res.status(500).json({ error: error.message });
}
});
// Eliminar usuario (requiere autenticación admin)
router.delete('/:username', basicAuthMiddleware, async (req, res) => {
try {
const redisClient = getRedisClient();
if (!redisClient) {
return res.status(500).json({ error: 'Redis no está disponible' });
}
const { username } = req.params;
const currentUser = req.user.username;
// No permitir eliminar el propio usuario
if (username === currentUser) {
return res.status(400).json({ error: 'No puedes eliminar tu propio usuario' });
}
const userKey = `user:${username}`;
const userExists = await redisClient.exists(userKey);
if (!userExists) {
return res.status(404).json({ error: 'Usuario no encontrado' });
}
// Eliminar usuario
await redisClient.del(userKey);
console.log(`✅ Usuario eliminado: ${username} por ${currentUser}`);
res.json({ success: true, message: `Usuario ${username} eliminado correctamente` });
} catch (error) {
console.error('Error eliminando usuario:', error);
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,35 @@
import express from 'express';
import { readJSON, writeJSON } from '../utils/fileUtils.js';
import { PATHS } from '../config/constants.js';
import { basicAuthMiddleware } from '../middlewares/auth.js';
import { broadcast } from '../services/websocket.js';
const router = express.Router();
// Obtener workers (requiere autenticación - solo administradores)
router.get('/', basicAuthMiddleware, (req, res) => {
try {
const workers = readJSON(PATHS.WORKERS, { items: [], general: {}, disabled: [] });
res.json(workers);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Actualizar workers (requiere autenticación)
router.put('/', basicAuthMiddleware, (req, res) => {
try {
const workers = req.body;
if (writeJSON(PATHS.WORKERS, workers)) {
broadcast({ type: 'workers_updated', data: workers });
res.json({ success: true });
} else {
res.status(500).json({ error: 'Error guardando workers' });
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -1,875 +1,71 @@
import express from 'express';
import cors from 'cors';
import { WebSocketServer } from 'ws';
import { createServer } from 'http';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { readFileSync, writeFileSync, existsSync, statSync } from 'fs';
import { watch } from 'chokidar';
import yaml from 'yaml';
import redis from 'redis';
import webpush from 'web-push';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// En Docker, usar PROJECT_ROOT de env, sino usar ruta relativa
const PROJECT_ROOT = process.env.PROJECT_ROOT || join(__dirname, '../..');
import { PATHS, SERVER } from './config/constants.js';
import { rateLimitMiddleware } from './middlewares/rateLimit.js';
import { initRedis } from './services/redis.js';
import { initVAPIDKeys } from './services/webPush.js';
import { initWebSocket } from './services/websocket.js';
import { startArticleMonitoring } from './services/articleMonitor.js';
import { initFileWatcher } from './services/fileWatcher.js';
import routes from './routes/index.js';
import workersRouter from './routes/workers.js';
import articlesRouter from './routes/articles.js';
import favoritesRouter from './routes/favorites.js';
import logsRouter from './routes/logs.js';
import configRouter from './routes/config.js';
import telegramRouter from './routes/telegram.js';
import pushRouter from './routes/push.js';
import usersRouter from './routes/users.js';
const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ server, path: '/ws' });
// Middlewares globales
app.use(cors());
app.use(express.json());
// Configuración
const CONFIG_PATH = join(PROJECT_ROOT, 'config.yaml');
const WORKERS_PATH = join(PROJECT_ROOT, 'workers.json');
const PUSH_SUBSCRIPTIONS_PATH = join(PROJECT_ROOT, 'push-subscriptions.json');
// Aplicar rate limiting a todas las rutas API
app.use('/api', rateLimitMiddleware);
// Inicializar VAPID keys para Web Push
let vapidKeys = null;
const VAPID_KEYS_PATH = join(PROJECT_ROOT, 'vapid-keys.json');
function initVAPIDKeys() {
try {
if (existsSync(VAPID_KEYS_PATH)) {
vapidKeys = JSON.parse(readFileSync(VAPID_KEYS_PATH, 'utf8'));
console.log('✅ VAPID keys cargadas desde archivo');
} else {
// Generar nuevas VAPID keys
vapidKeys = webpush.generateVAPIDKeys();
writeFileSync(VAPID_KEYS_PATH, JSON.stringify(vapidKeys, null, 2), 'utf8');
console.log('✅ Nuevas VAPID keys generadas y guardadas');
}
// Configurar web-push con las VAPID keys
webpush.setVapidDetails(
'mailto:admin@pribyte.cloud', // Contacto (puedes cambiarlo)
vapidKeys.publicKey,
vapidKeys.privateKey
);
} catch (error) {
console.error('Error inicializando VAPID keys:', error.message);
}
}
// Inicializar VAPID keys al arrancar
initVAPIDKeys();
// Función para obtener la ruta del log (en Docker puede estar en /data/logs)
function getLogPath() {
const logsDirPath = join(PROJECT_ROOT, 'logs', 'monitor.log');
const rootLogPath = join(PROJECT_ROOT, 'monitor.log');
if (existsSync(logsDirPath)) {
return logsDirPath;
}
return rootLogPath;
}
const LOG_PATH = getLogPath();
let redisClient = null;
let config = null;
// Inicializar Redis si está configurado
async function initRedis() {
try {
config = yaml.parse(readFileSync(CONFIG_PATH, '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 claves conocidas para evitar notificar artículos existentes
try {
const initialKeys = await redisClient.keys('notified:*');
notifiedArticleKeys = new Set(initialKeys);
console.log(`📋 ${notifiedArticleKeys.size} artículos ya notificados detectados`);
} catch (error) {
console.error('Error inicializando claves de artículos:', error.message);
}
} else {
console.log(' Redis no configurado, usando modo memoria');
}
} catch (error) {
console.error('Error inicializando Redis:', error.message);
}
}
// Broadcast a todos los clientes WebSocket
function broadcast(data) {
wss.clients.forEach((client) => {
if (client.readyState === 1) { // WebSocket.OPEN
client.send(JSON.stringify(data));
}
});
}
// Leer archivo JSON de forma segura
function readJSON(path, defaultValue = {}) {
try {
if (existsSync(path)) {
return JSON.parse(readFileSync(path, 'utf8'));
}
return defaultValue;
} catch (error) {
console.error(`Error leyendo ${path}:`, error.message);
return defaultValue;
}
}
// Escribir archivo JSON
function writeJSON(path, data) {
try {
writeFileSync(path, JSON.stringify(data, null, 2), 'utf8');
return true;
} catch (error) {
console.error(`Error escribiendo ${path}:`, error.message);
return false;
}
}
// Obtener artículos notificados desde Redis
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 [];
}
}
// API Routes
// Obtener favoritos desde Redis
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 [];
}
}
// Obtener estadísticas
app.get('/api/stats', async (req, res) => {
try {
const workers = readJSON(WORKERS_PATH, { items: [] });
const favorites = await getFavorites();
const notifiedArticles = await getNotifiedArticles();
const stats = {
totalWorkers: workers.items?.length || 0,
activeWorkers: (workers.items || []).filter(w => !workers.disabled?.includes(w.name)).length,
totalFavorites: favorites.length,
totalNotified: notifiedArticles.length,
platforms: {
wallapop: notifiedArticles.filter(a => a.platform === 'wallapop').length,
vinted: notifiedArticles.filter(a => a.platform === 'vinted').length,
},
};
res.json(stats);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Obtener workers
app.get('/api/workers', (req, res) => {
try {
const workers = readJSON(WORKERS_PATH, { items: [], general: {}, disabled: [] });
res.json(workers);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Actualizar workers
app.put('/api/workers', (req, res) => {
try {
const workers = req.body;
if (writeJSON(WORKERS_PATH, workers)) {
broadcast({ type: 'workers_updated', data: workers });
res.json({ success: true });
} else {
res.status(500).json({ error: 'Error guardando workers' });
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Obtener favoritos
app.get('/api/favorites', async (req, res) => {
try {
const favorites = await getFavorites();
res.json(favorites);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Añadir favorito
app.post('/api/favorites', async (req, res) => {
try {
if (!redisClient) {
return res.status(500).json({ error: 'Redis no está disponible' });
}
const { platform, id } = req.body;
if (!platform || !id) {
return res.status(400).json({ error: 'platform e id son requeridos' });
}
const key = `notified:${platform}:${id}`;
const value = await redisClient.get(key);
if (!value) {
return res.status(404).json({ error: 'Artículo no encontrado' });
}
try {
const articleData = JSON.parse(value);
articleData.is_favorite = true;
// Mantener el TTL existente
const ttl = await redisClient.ttl(key);
if (ttl > 0) {
await redisClient.setex(key, ttl, JSON.stringify(articleData));
} else {
await redisClient.set(key, JSON.stringify(articleData));
}
const favorites = await getFavorites();
broadcast({ type: 'favorites_updated', data: favorites });
res.json({ success: true, favorites });
} catch (e) {
res.status(500).json({ error: 'Error procesando artículo' });
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Eliminar favorito
app.delete('/api/favorites/:platform/:id', async (req, res) => {
try {
if (!redisClient) {
return res.status(500).json({ error: 'Redis no está disponible' });
}
const { platform, id } = req.params;
const key = `notified:${platform}:${id}`;
const value = await redisClient.get(key);
if (!value) {
return res.status(404).json({ error: 'Artículo no encontrado' });
}
try {
const articleData = JSON.parse(value);
articleData.is_favorite = false;
// Mantener el TTL existente
const ttl = await redisClient.ttl(key);
if (ttl > 0) {
await redisClient.setex(key, ttl, JSON.stringify(articleData));
} else {
await redisClient.set(key, JSON.stringify(articleData));
}
const favorites = await getFavorites();
broadcast({ type: 'favorites_updated', data: favorites });
res.json({ success: true, favorites });
} catch (e) {
res.status(500).json({ error: 'Error procesando artículo' });
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Limpiar toda la caché de Redis
app.delete('/api/cache', async (req, res) => {
try {
if (!redisClient) {
return res.status(500).json({ error: 'Redis no está disponible' });
}
// Obtener todas las claves que empiezan con 'notified:'
const keys = await redisClient.keys('notified:*');
if (!keys || keys.length === 0) {
return res.json({
success: true,
message: 'Cache ya está vacío',
count: 0
});
}
// Eliminar todas las claves
const count = keys.length;
for (const key of keys) {
await redisClient.del(key);
}
// Notificar a los clientes WebSocket
broadcast({
type: 'cache_cleared',
data: { count, timestamp: Date.now() }
});
// También actualizar favoritos (debería estar vacío ahora)
const favorites = await getFavorites();
broadcast({ type: 'favorites_updated', data: favorites });
res.json({
success: true,
message: `Cache limpiado: ${count} artículos eliminados`,
count
});
} catch (error) {
console.error('Error limpiando cache de Redis:', error);
res.status(500).json({ error: error.message });
}
});
// Obtener artículos notificados
app.get('/api/articles', async (req, res) => {
try {
const articles = await getNotifiedArticles();
const limit = parseInt(req.query.limit) || 100;
const offset = parseInt(req.query.offset) || 0;
const sorted = articles.sort((a, b) => b.notifiedAt - a.notifiedAt);
const paginated = sorted.slice(offset, offset + limit);
res.json({
articles: paginated,
total: articles.length,
limit,
offset,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Buscar artículos en Redis
app.get('/api/articles/search', async (req, res) => {
try {
const query = req.query.q || '';
if (!query.trim()) {
return res.json({ articles: [], total: 0 });
}
const searchTerm = query.toLowerCase().trim();
const allArticles = await getNotifiedArticles();
// Filtrar artículos que coincidan con la búsqueda
const filtered = allArticles.filter(article => {
// Buscar en título
const title = (article.title || '').toLowerCase();
if (title.includes(searchTerm)) return true;
// Buscar en descripción
const description = (article.description || '').toLowerCase();
if (description.includes(searchTerm)) return true;
// Buscar en localidad
const location = (article.location || '').toLowerCase();
if (location.includes(searchTerm)) return true;
// Buscar en precio (como número o texto)
const price = String(article.price || '').toLowerCase();
if (price.includes(searchTerm)) return true;
// Buscar en plataforma
const platform = (article.platform || '').toLowerCase();
if (platform.includes(searchTerm)) return true;
// Buscar en ID
const id = String(article.id || '').toLowerCase();
if (id.includes(searchTerm)) return true;
return false;
});
// Ordenar por fecha de notificación (más recientes primero)
const sorted = filtered.sort((a, b) => b.notifiedAt - a.notifiedAt);
res.json({
articles: sorted,
total: sorted.length,
query: query,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Obtener logs (últimas líneas o nuevas líneas desde un número de línea)
app.get('/api/logs', (req, res) => {
try {
// Intentar múltiples ubicaciones posibles
let logFile = LOG_PATH;
if (!existsSync(logFile)) {
// Intentar en el directorio de logs
const altPath = join(PROJECT_ROOT, 'logs', 'monitor.log');
if (existsSync(altPath)) {
logFile = altPath;
} else {
return res.json({ logs: [], totalLines: 0, lastLineNumber: 0 });
}
}
// Verificar que no sea un directorio
try {
const stats = statSync(logFile);
if (stats.isDirectory()) {
return res.json({ logs: ['Error: monitor.log es un directorio. Por favor, elimínalo y reinicia.'], totalLines: 0, lastLineNumber: 0 });
}
} catch (e) {
return res.json({ logs: [], totalLines: 0, lastLineNumber: 0 });
}
const logsContent = readFileSync(logFile, 'utf8');
const allLines = logsContent.split('\n').filter(l => l.trim());
const totalLines = allLines.length;
// Si se proporciona since (número de línea desde el que empezar), devolver solo las nuevas
const sinceLine = parseInt(req.query.since) || 0;
if (sinceLine > 0 && sinceLine < totalLines) {
// Devolver solo las líneas nuevas después de sinceLine
const newLines = allLines.slice(sinceLine);
return res.json({
logs: newLines,
totalLines: totalLines,
lastLineNumber: totalLines - 1 // Índice de la última línea
});
} else {
// Carga inicial: devolver las últimas líneas
const limit = parseInt(req.query.limit) || 500;
const lastLines = allLines.slice(-limit);
return res.json({
logs: lastLines,
totalLines: totalLines,
lastLineNumber: totalLines - 1 // Índice de la última línea
});
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Obtener configuración
app.get('/api/config', (req, res) => {
try {
if (!config) {
config = yaml.parse(readFileSync(CONFIG_PATH, 'utf8'));
}
// No enviar token por seguridad
const safeConfig = { ...config };
if (safeConfig.telegram_token) {
safeConfig.telegram_token = '***';
}
res.json(safeConfig);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Obtener threads/topics de Telegram
app.get('/api/telegram/threads', async (req, res) => {
try {
if (!config) {
config = yaml.parse(readFileSync(CONFIG_PATH, 'utf8'));
}
const token = config?.telegram_token;
const channel = config?.telegram_channel;
if (!token || !channel) {
return res.status(400).json({ error: 'Token o canal de Telegram no configurados' });
}
// Convertir el canal a chat_id si es necesario
let chatId = channel;
if (channel.startsWith('@')) {
// Para canales con @, necesitamos obtener el chat_id primero
const getChatUrl = `https://api.telegram.org/bot${token}/getChat?chat_id=${encodeURIComponent(channel)}`;
const chatResponse = await fetch(getChatUrl);
const chatData = await chatResponse.json();
if (!chatData.ok) {
return res.status(400).json({ error: `Error obteniendo chat: ${chatData.description || 'Chat no encontrado'}` });
}
chatId = chatData.result.id;
}
// Intentar obtener forum topics
const forumTopicsUrl = `https://api.telegram.org/bot${token}/getForumTopics?chat_id=${chatId}&limit=100`;
const topicsResponse = await fetch(forumTopicsUrl);
const topicsData = await topicsResponse.json();
if (topicsData.ok && topicsData.result?.topics) {
const threads = topicsData.result.topics.map(topic => ({
id: topic.message_thread_id,
name: topic.name || `Thread ${topic.message_thread_id}`,
icon_color: topic.icon_color,
icon_custom_emoji_id: topic.icon_custom_emoji_id,
}));
return res.json({ threads, success: true });
} else {
// Si no hay forum topics, devolver un mensaje informativo
return res.json({
threads: [],
success: false,
message: 'El chat no tiene forum topics habilitados o no se pudieron obtener. Puedes obtener el Thread ID manualmente copiando el enlace del tema.',
info: 'Para obtener el Thread ID manualmente: 1. Haz clic derecho en el tema/hilo en Telegram 2. Selecciona "Copiar enlace del tema" 3. El número al final de la URL es el Thread ID (ej: t.me/c/1234567890/8 → Thread ID = 8)'
});
}
} catch (error) {
console.error('Error obteniendo threads de Telegram:', error.message);
res.status(500).json({ error: error.message });
}
});
// Obtener suscripciones push guardadas
function getPushSubscriptions() {
return readJSON(PUSH_SUBSCRIPTIONS_PATH, []);
}
// Guardar suscripciones push
function savePushSubscriptions(subscriptions) {
return writeJSON(PUSH_SUBSCRIPTIONS_PATH, subscriptions);
}
// API Routes para Push Notifications
// Obtener clave pública VAPID
app.get('/api/push/public-key', (req, res) => {
if (!vapidKeys || !vapidKeys.publicKey) {
return res.status(500).json({ error: 'VAPID keys no están configuradas' });
}
res.json({ publicKey: vapidKeys.publicKey });
});
// Suscribirse a notificaciones push
app.post('/api/push/subscribe', async (req, res) => {
try {
const subscription = req.body;
if (!subscription || !subscription.endpoint) {
return res.status(400).json({ error: 'Suscripción inválida' });
}
const subscriptions = getPushSubscriptions();
// Verificar si ya existe esta suscripción
const existingIndex = subscriptions.findIndex(
sub => sub.endpoint === subscription.endpoint
);
if (existingIndex >= 0) {
subscriptions[existingIndex] = subscription;
} else {
subscriptions.push(subscription);
}
savePushSubscriptions(subscriptions);
console.log(`✅ Nueva suscripción push guardada. Total: ${subscriptions.length}`);
res.json({ success: true, totalSubscriptions: subscriptions.length });
} catch (error) {
console.error('Error guardando suscripción push:', error);
res.status(500).json({ error: error.message });
}
});
// Cancelar suscripción push
app.post('/api/push/unsubscribe', async (req, res) => {
try {
const subscription = req.body;
if (!subscription || !subscription.endpoint) {
return res.status(400).json({ error: 'Suscripción inválida' });
}
const subscriptions = getPushSubscriptions();
const filtered = subscriptions.filter(
sub => sub.endpoint !== subscription.endpoint
);
savePushSubscriptions(filtered);
console.log(`✅ Suscripción push cancelada. Total: ${filtered.length}`);
res.json({ success: true, totalSubscriptions: filtered.length });
} catch (error) {
console.error('Error cancelando suscripción push:', error);
res.status(500).json({ error: error.message });
}
});
// Enviar notificación push a todas las suscripciones
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);
}
// WebSocket connection
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);
});
});
// Determinar la ruta del log para el watcher
let watchLogPath = LOG_PATH;
if (!existsSync(watchLogPath)) {
const altPath = join(PROJECT_ROOT, 'logs', 'monitor.log');
if (existsSync(altPath)) {
watchLogPath = altPath;
}
}
// Watch files for changes (ya no vigilamos logs porque usa polling)
const watcher = watch([WORKERS_PATH].filter(p => existsSync(p)), {
persistent: true,
ignoreInitial: true,
});
watcher.on('change', async (path) => {
console.log(`Archivo cambiado: ${path}`);
if (path === WORKERS_PATH) {
const workers = readJSON(WORKERS_PATH);
broadcast({ type: 'workers_updated', data: workers });
}
});
// Rastrear artículos ya notificados para detectar nuevos
let notifiedArticleKeys = new Set();
let articlesCheckInterval = null;
// Función para detectar y enviar artículos nuevos
async function checkForNewArticles() {
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
async function startArticleMonitoring() {
if (redisClient) {
// Iniciar intervalo para verificar nuevos artículos cada 3 segundos
articlesCheckInterval = setInterval(checkForNewArticles, 3000);
console.log('✅ Monitoreo de artículos nuevos iniciado');
}
}
// Inicializar WebSocket
initWebSocket(server);
// Rutas API
app.use('/api', routes);
app.use('/api/workers', workersRouter);
app.use('/api/articles', articlesRouter);
app.use('/api/favorites', favoritesRouter);
app.use('/api/logs', logsRouter);
app.use('/api/config', configRouter);
app.use('/api/telegram', telegramRouter);
app.use('/api/push', pushRouter);
app.use('/api/users', usersRouter);
// Inicializar servidor
const PORT = process.env.PORT || 3001;
async function startServer() {
try {
// Inicializar Redis
await initRedis();
// Iniciar monitoreo de artículos nuevos
await startArticleMonitoring();
server.listen(PORT, () => {
console.log(`🚀 Servidor backend ejecutándose en http://localhost:${PORT}`);
console.log(`📡 WebSocket disponible en ws://localhost:${PORT}`);
// Inicializar file watcher
initFileWatcher();
// Iniciar servidor HTTP
server.listen(SERVER.PORT, () => {
console.log(`🚀 Servidor backend ejecutándose en http://localhost:${SERVER.PORT}`);
console.log(`📡 WebSocket disponible en ws://localhost:${SERVER.PORT}/ws`);
});
} catch (error) {
console.error('Error al iniciar el servidor:', error);
process.exit(1);
}
}
startServer().catch(console.error);

View 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;
}
}

View 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;
}
}

View 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();
}
}

View 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);
}

View 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;
}

View File

@@ -0,0 +1,88 @@
import { readFileSync, writeFileSync, existsSync, statSync } from 'fs';
import { join } from 'path';
import { PATHS } from '../config/constants.js';
// Función para obtener la ruta del log (en Docker puede estar en /data/logs)
export function getLogPath() {
const logsDirPath = join(PATHS.PROJECT_ROOT, 'logs', 'monitor.log');
const rootLogPath = join(PATHS.PROJECT_ROOT, 'monitor.log');
if (existsSync(logsDirPath)) {
return logsDirPath;
}
return rootLogPath;
}
// Leer archivo JSON de forma segura
export function readJSON(path, defaultValue = {}) {
try {
if (existsSync(path)) {
return JSON.parse(readFileSync(path, 'utf8'));
}
return defaultValue;
} catch (error) {
console.error(`Error leyendo ${path}:`, error.message);
return defaultValue;
}
}
// Escribir archivo JSON
export function writeJSON(path, data) {
try {
writeFileSync(path, JSON.stringify(data, null, 2), 'utf8');
return true;
} catch (error) {
console.error(`Error escribiendo ${path}:`, error.message);
return false;
}
}
// Leer logs de forma segura
export function readLogs(logPath, options = {}) {
const { sinceLine = 0, limit = 500 } = options;
try {
if (!existsSync(logPath)) {
return { logs: [], totalLines: 0, lastLineNumber: 0 };
}
// Verificar que no sea un directorio
try {
const stats = statSync(logPath);
if (stats.isDirectory()) {
return {
logs: ['Error: monitor.log es un directorio. Por favor, elimínalo y reinicia.'],
totalLines: 0,
lastLineNumber: 0
};
}
} catch (e) {
return { logs: [], totalLines: 0, lastLineNumber: 0 };
}
const logsContent = readFileSync(logPath, 'utf8');
const allLines = logsContent.split('\n').filter(l => l.trim());
const totalLines = allLines.length;
if (sinceLine > 0 && sinceLine < totalLines) {
// Devolver solo las líneas nuevas después de sinceLine
const newLines = allLines.slice(sinceLine);
return {
logs: newLines,
totalLines: totalLines,
lastLineNumber: totalLines - 1
};
} else {
// Carga inicial: devolver las últimas líneas
const lastLines = allLines.slice(-limit);
return {
logs: lastLines,
totalLines: totalLines,
lastLineNumber: totalLines - 1
};
}
} catch (error) {
throw new Error(error.message);
}
}

View File

@@ -45,6 +45,19 @@
<SunIcon v-if="isDark" class="w-5 h-5" />
<MoonIcon v-else class="w-5 h-5" />
</button>
<button
@click="isAuthenticated ? handleLogout() : showLoginModal = true"
:class="[
'p-2 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500',
isAuthenticated
? 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
: 'text-primary-600 dark:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20'
]"
:title="isAuthenticated ? 'Desconectar / Cerrar sesión' : 'Iniciar sesión'"
>
<ArrowRightOnRectangleIcon v-if="isAuthenticated" class="w-5 h-5" />
<ArrowLeftOnRectangleIcon v-else class="w-5 h-5" />
</button>
<div class="hidden sm:flex items-center space-x-2">
<div
class="w-3 h-3 rounded-full"
@@ -82,7 +95,7 @@
{{ item.name }}
</router-link>
</div>
<div class="pt-4 pb-3 border-t border-gray-200 dark:border-gray-700 px-4">
<div class="pt-4 pb-3 border-t border-gray-200 dark:border-gray-700 px-4 space-y-3">
<div class="flex items-center space-x-2">
<div
class="w-3 h-3 rounded-full"
@@ -92,6 +105,19 @@
{{ wsConnected ? 'Conectado' : 'Desconectado' }}
</span>
</div>
<button
@click="isAuthenticated ? handleLogout() : showLoginModal = true"
:class="[
'w-full flex items-center px-3 py-2 text-base font-medium rounded-md transition-colors',
isAuthenticated
? 'text-gray-900 dark:text-gray-200 hover:text-red-600 dark:hover:text-red-400 hover:bg-gray-50 dark:hover:bg-gray-700'
: 'text-gray-900 dark:text-gray-200 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-gray-50 dark:hover:bg-gray-700'
]"
>
<ArrowRightOnRectangleIcon v-if="isAuthenticated" class="w-5 h-5 mr-3" />
<ArrowLeftOnRectangleIcon v-else class="w-5 h-5 mr-3" />
{{ isAuthenticated ? 'Desconectar' : 'Iniciar Sesión' }}
</button>
</div>
</div>
</nav>
@@ -159,6 +185,99 @@
</div>
</div>
</div>
<!-- Modal de Login Global -->
<div
v-if="showLoginModal"
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-4"
@click.self="closeLoginModal"
>
<div class="card max-w-md w-full">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">🔐 Iniciar Sesión</h2>
<button
@click="closeLoginModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Cerrar"
>
</button>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Ingresa tus credenciales para acceder a las funciones de administrador.
</p>
<form @submit.prevent="handleGlobalLogin" class="space-y-4">
<div v-if="globalLoginError" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded">
{{ globalLoginError }}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Usuario
</label>
<input
v-model="globalLoginForm.username"
type="text"
class="input"
placeholder="admin"
required
autocomplete="username"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Contraseña
</label>
<input
v-model="globalLoginForm.password"
type="password"
class="input"
placeholder="••••••••"
required
autocomplete="current-password"
/>
</div>
<div class="flex items-center">
<input
v-model="globalLoginForm.remember"
type="checkbox"
id="remember-global"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label for="remember-global" class="ml-2 block text-sm text-gray-700 dark:text-gray-300">
Recordar credenciales
</label>
</div>
<div class="flex flex-col-reverse sm:flex-row justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="closeLoginModal"
class="btn btn-secondary text-sm sm:text-base"
>
Cancelar
</button>
<button
type="submit"
class="btn btn-primary text-sm sm:text-base"
:disabled="globalLoginLoading"
>
{{ globalLoginLoading ? 'Iniciando...' : 'Iniciar Sesión' }}
</button>
</div>
</form>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<p class="text-xs text-gray-500 dark:text-gray-400">
💡 Por defecto: usuario <code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">admin</code> / contraseña <code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">admin</code>
</p>
</div>
</div>
</div>
</div>
</template>
@@ -169,6 +288,7 @@ import {
DocumentTextIcon,
HeartIcon,
Cog6ToothIcon,
UserGroupIcon,
DocumentMagnifyingGlassIcon,
Bars3Icon,
XMarkIcon,
@@ -176,26 +296,42 @@ import {
MoonIcon,
BellIcon,
BellSlashIcon,
ArrowRightOnRectangleIcon,
ArrowLeftOnRectangleIcon,
} from '@heroicons/vue/24/outline';
import pushNotificationService from './services/pushNotifications';
import authService from './services/auth';
import { useRouter } from 'vue-router';
import api from './services/api';
const navItems = [
{ path: '/', name: 'Dashboard', icon: HomeIcon },
{ path: '/articles', name: 'Artículos', icon: DocumentTextIcon },
{ path: '/favorites', name: 'Favoritos', icon: HeartIcon },
{ path: '/workers', name: 'Workers', icon: Cog6ToothIcon },
{ path: '/users', name: 'Usuarios', icon: UserGroupIcon },
{ path: '/logs', name: 'Logs', icon: DocumentMagnifyingGlassIcon },
];
const router = useRouter();
const wsConnected = ref(false);
const mobileMenuOpen = ref(false);
const darkMode = ref(false);
const toasts = ref([]);
const pushEnabled = ref(false);
const showLoginModal = ref(false);
const globalLoginError = ref('');
const globalLoginLoading = ref(false);
const globalLoginForm = ref({
username: '',
password: '',
remember: true,
});
let ws = null;
let toastIdCounter = 0;
const isDark = computed(() => darkMode.value);
const isAuthenticated = computed(() => authService.hasCredentials());
function addToast(article) {
const id = ++toastIdCounter;
@@ -281,13 +417,102 @@ async function checkPushStatus() {
pushEnabled.value = hasSubscription;
}
async function handleGlobalLogin() {
globalLoginError.value = '';
globalLoginLoading.value = true;
if (!globalLoginForm.value.username || !globalLoginForm.value.password) {
globalLoginError.value = 'Usuario y contraseña son requeridos';
globalLoginLoading.value = false;
return;
}
try {
if (globalLoginForm.value.remember) {
authService.saveCredentials(
globalLoginForm.value.username,
globalLoginForm.value.password
);
}
// Intentar hacer una petición autenticada para validar credenciales
// Usamos stats que no requiere auth, pero validará las credenciales
try {
await api.getStats();
} catch (error) {
// Si hay error 401, las credenciales son inválidas
if (error.response?.status === 401) {
throw error;
}
}
// Si llegamos aquí, las credenciales son válidas
closeLoginModal();
// Recargar página para actualizar datos después del login
window.location.reload();
} catch (error) {
console.error('Error en login:', error);
if (error.response?.status === 401) {
globalLoginError.value = 'Usuario o contraseña incorrectos';
authService.clearCredentials();
} else {
globalLoginError.value = 'Error de conexión. Intenta de nuevo.';
}
} finally {
globalLoginLoading.value = false;
}
}
function closeLoginModal() {
showLoginModal.value = false;
globalLoginError.value = '';
globalLoginForm.value = {
username: '',
password: '',
remember: true,
};
}
function handleAuthRequired(event) {
showLoginModal.value = true;
if (event.detail?.message) {
globalLoginError.value = event.detail.message;
}
}
function handleLogout() {
// Limpiar credenciales
authService.clearCredentials();
// Redirigir al dashboard después del logout
router.push('/');
// Disparar evento para que los componentes se actualicen
window.dispatchEvent(new CustomEvent('auth-logout'));
// Mostrar mensaje informativo
console.log('Sesión cerrada correctamente');
}
onMounted(async () => {
initDarkMode();
connectWebSocket();
await checkPushStatus();
// Cargar credenciales guardadas si existen
if (authService.hasCredentials()) {
const creds = authService.getCredentials();
globalLoginForm.value.username = creds.username;
globalLoginForm.value.password = creds.password;
}
// Escuchar eventos de autenticación requerida
window.addEventListener('auth-required', handleAuthRequired);
});
onUnmounted(() => {
window.removeEventListener('auth-required', handleAuthRequired);
if (ws) {
ws.close();
}

View File

@@ -5,6 +5,7 @@ import Dashboard from './views/Dashboard.vue';
import Articles from './views/Articles.vue';
import Favorites from './views/Favorites.vue';
import Workers from './views/Workers.vue';
import Users from './views/Users.vue';
import Logs from './views/Logs.vue';
import './style.css';
@@ -13,6 +14,7 @@ const routes = [
{ path: '/articles', component: Articles },
{ path: '/favorites', component: Favorites },
{ path: '/workers', component: Workers },
{ path: '/users', component: Users },
{ path: '/logs', component: Logs },
];

View File

@@ -1,4 +1,5 @@
import axios from 'axios';
import authService from './auth';
const api = axios.create({
baseURL: '/api',
@@ -7,6 +8,37 @@ const api = axios.create({
},
});
// Interceptor para añadir autenticación a las peticiones
api.interceptors.request.use(
(config) => {
const authHeader = authService.getAuthHeader();
if (authHeader) {
config.headers.Authorization = authHeader;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Interceptor para manejar errores 401 (no autenticado)
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Disparar evento personalizado para mostrar diálogo de login
window.dispatchEvent(new CustomEvent('auth-required', {
detail: {
message: 'Se requiere autenticación para esta acción',
config: error.config
}
}));
}
return Promise.reject(error);
}
);
export default {
// Estadísticas
async getStats() {
@@ -83,5 +115,26 @@ export default {
const response = await api.delete('/cache');
return response.data;
},
// Usuarios
async getUsers() {
const response = await api.get('/users');
return response.data;
},
async createUser(userData) {
const response = await api.post('/users', userData);
return response.data;
},
async deleteUser(username) {
const response = await api.delete(`/users/${username}`);
return response.data;
},
async changePassword(passwordData) {
const response = await api.post('/users/change-password', passwordData);
return response.data;
},
};

View File

@@ -0,0 +1,95 @@
// Servicio de autenticación para gestionar credenciales
const AUTH_STORAGE_KEY = 'wallabicher_auth';
class AuthService {
constructor() {
this.credentials = this.loadCredentials();
}
// Cargar credenciales desde localStorage
loadCredentials() {
try {
const stored = localStorage.getItem(AUTH_STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return {
username: parsed.username || '',
password: parsed.password || '',
};
}
} catch (error) {
console.error('Error cargando credenciales:', error);
}
return { username: '', password: '' };
}
// Guardar credenciales en localStorage
saveCredentials(username, password) {
try {
this.credentials = { username, password };
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(this.credentials));
return true;
} catch (error) {
console.error('Error guardando credenciales:', error);
return false;
}
}
// Eliminar credenciales
clearCredentials() {
try {
this.credentials = { username: '', password: '' };
localStorage.removeItem(AUTH_STORAGE_KEY);
return true;
} catch (error) {
console.error('Error eliminando credenciales:', error);
return false;
}
}
// Obtener credenciales actuales
getCredentials() {
return { ...this.credentials };
}
// Verificar si hay credenciales guardadas
hasCredentials() {
return !!(this.credentials.username && this.credentials.password);
}
// Generar header de autenticación Basic
getAuthHeader() {
if (!this.hasCredentials()) {
return null;
}
const { username, password } = this.credentials;
const encoded = btoa(`${username}:${password}`);
return `Basic ${encoded}`;
}
// Validar credenciales (test básico)
async validateCredentials(username, password) {
try {
// Intentar hacer una petición simple para validar las credenciales
const encoded = btoa(`${username}:${password}`);
const response = await fetch('/api/stats', {
method: 'GET',
headers: {
'Authorization': `Basic ${encoded}`,
},
});
// Si la petición funciona, las credenciales son válidas
// Nota: stats no requiere auth, pero podemos usar cualquier endpoint
return response.ok || response.status !== 401;
} catch (error) {
return false;
}
}
}
// Exportar instancia singleton
const authService = new AuthService();
export default authService;

View File

@@ -0,0 +1,554 @@
<template>
<div>
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Gestión de Usuarios</h1>
<div class="flex flex-wrap gap-2">
<button
v-if="isAuthenticated"
@click="showChangePasswordModal = true"
class="btn btn-secondary text-xs sm:text-sm"
>
🔑 Cambiar Mi Contraseña
</button>
<button @click="showAddModal = true" class="btn btn-primary text-xs sm:text-sm whitespace-nowrap">
+ Crear Usuario
</button>
</div>
</div>
<div v-if="loading" class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
<p class="mt-2 text-gray-600 dark:text-gray-400">Cargando usuarios...</p>
</div>
<div v-else class="space-y-4">
<!-- Lista de usuarios -->
<div v-if="users.length > 0" class="grid grid-cols-1 gap-4">
<div
v-for="user in users"
:key="user.username"
class="card hover:shadow-lg transition-shadow"
>
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">{{ user.username }}</h3>
<span
v-if="user.username === currentUser"
class="px-2 py-1 text-xs font-semibold rounded bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200"
>
</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-gray-600 dark:text-gray-400">
<div v-if="user.createdAt">
<span class="font-medium">Creado:</span>
<span class="ml-2">{{ formatDate(user.createdAt) }}</span>
</div>
<div v-if="user.createdBy">
<span class="font-medium">Por:</span>
<span class="ml-2">{{ user.createdBy }}</span>
</div>
<div v-if="user.updatedAt">
<span class="font-medium">Actualizado:</span>
<span class="ml-2">{{ formatDate(user.updatedAt) }}</span>
</div>
</div>
</div>
<div class="flex gap-2">
<button
v-if="user.username === currentUser"
@click="showChangePasswordModal = true"
class="btn btn-secondary text-xs sm:text-sm"
title="Cambiar contraseña"
>
🔑 Cambiar Contraseña
</button>
<button
v-if="user.username !== currentUser"
@click="confirmDeleteUser(user.username)"
class="btn btn-danger text-xs sm:text-sm"
title="Eliminar usuario"
>
🗑 Eliminar
</button>
</div>
</div>
</div>
</div>
<div v-else class="card text-center py-12">
<p class="text-gray-600 dark:text-gray-400 mb-4">No hay usuarios configurados</p>
<button @click="showAddModal = true" class="btn btn-primary">
+ Crear primer usuario
</button>
</div>
</div>
<!-- Modal para crear/editar usuario -->
<div
v-if="showAddModal"
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-4"
@click.self="closeAddModal"
>
<div class="card max-w-md w-full max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">Crear Usuario</h2>
<button
@click="closeAddModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Cerrar"
>
</button>
</div>
<form @submit.prevent="handleCreateUser" class="space-y-4">
<div v-if="addError" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded">
{{ addError }}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Nombre de usuario <span class="text-red-500">*</span>
</label>
<input
v-model="userForm.username"
type="text"
class="input"
placeholder="nuevo_usuario"
required
minlength="3"
autocomplete="username"
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Mínimo 3 caracteres
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Contraseña <span class="text-red-500">*</span>
</label>
<input
v-model="userForm.password"
type="password"
class="input"
placeholder="••••••••"
required
minlength="6"
autocomplete="new-password"
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Mínimo 6 caracteres
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Confirmar contraseña <span class="text-red-500">*</span>
</label>
<input
v-model="userForm.passwordConfirm"
type="password"
class="input"
placeholder="••••••••"
required
minlength="6"
autocomplete="new-password"
/>
</div>
<div class="flex flex-col-reverse sm:flex-row justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="closeAddModal"
class="btn btn-secondary text-sm sm:text-base"
>
Cancelar
</button>
<button
type="submit"
class="btn btn-primary text-sm sm:text-base"
:disabled="loadingAction"
>
{{ loadingAction ? 'Creando...' : 'Crear Usuario' }}
</button>
</div>
</form>
</div>
</div>
<!-- Modal para cambiar contraseña -->
<div
v-if="showChangePasswordModal"
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-4"
@click.self="closeChangePasswordModal"
>
<div class="card max-w-md w-full">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">Cambiar Contraseña</h2>
<button
@click="closeChangePasswordModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Cerrar"
>
</button>
</div>
<form @submit.prevent="handleChangePassword" class="space-y-4">
<div v-if="passwordError" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded">
{{ passwordError }}
</div>
<div v-if="passwordSuccess" class="bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded">
{{ passwordSuccess }}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Contraseña actual <span class="text-red-500">*</span>
</label>
<input
v-model="passwordForm.currentPassword"
type="password"
class="input"
placeholder="••••••••"
required
autocomplete="current-password"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Nueva contraseña <span class="text-red-500">*</span>
</label>
<input
v-model="passwordForm.newPassword"
type="password"
class="input"
placeholder="••••••••"
required
minlength="6"
autocomplete="new-password"
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Mínimo 6 caracteres
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Confirmar nueva contraseña <span class="text-red-500">*</span>
</label>
<input
v-model="passwordForm.newPasswordConfirm"
type="password"
class="input"
placeholder="••••••••"
required
minlength="6"
autocomplete="new-password"
/>
</div>
<div class="flex flex-col-reverse sm:flex-row justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="closeChangePasswordModal"
class="btn btn-secondary text-sm sm:text-base"
>
Cancelar
</button>
<button
type="submit"
class="btn btn-primary text-sm sm:text-base"
:disabled="loadingAction"
>
{{ loadingAction ? 'Cambiando...' : 'Cambiar Contraseña' }}
</button>
</div>
</form>
</div>
</div>
<!-- Modal de confirmación para eliminar -->
<div
v-if="userToDelete"
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-4"
@click.self="userToDelete = null"
>
<div class="card max-w-md w-full">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">Confirmar Eliminación</h2>
<button
@click="userToDelete = null"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Cerrar"
>
</button>
</div>
<p class="text-gray-700 dark:text-gray-300 mb-4">
¿Estás seguro de que deseas eliminar al usuario <strong>{{ userToDelete }}</strong>?
</p>
<p class="text-sm text-red-600 dark:text-red-400 mb-4">
Esta acción no se puede deshacer.
</p>
<div class="flex flex-col-reverse sm:flex-row justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="userToDelete = null"
class="btn btn-secondary text-sm sm:text-base"
>
Cancelar
</button>
<button
@click="handleDeleteUser"
class="btn btn-danger text-sm sm:text-base"
:disabled="loadingAction"
>
{{ loadingAction ? 'Eliminando...' : 'Eliminar Usuario' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import api from '../services/api';
import authService from '../services/auth';
const users = ref([]);
const loading = ref(true);
const loadingAction = ref(false);
const showAddModal = ref(false);
const showChangePasswordModal = ref(false);
const userToDelete = ref(null);
const addError = ref('');
const passwordError = ref('');
const passwordSuccess = ref('');
const userForm = ref({
username: '',
password: '',
passwordConfirm: '',
});
const passwordForm = ref({
currentPassword: '',
newPassword: '',
newPasswordConfirm: '',
});
const isAuthenticated = computed(() => authService.hasCredentials());
const currentUser = computed(() => {
const creds = authService.getCredentials();
return creds.username || '';
});
function formatDate(dateString) {
if (!dateString) return 'N/A';
try {
const date = new Date(dateString);
return date.toLocaleString('es-ES', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch (e) {
return dateString;
}
}
async function loadUsers() {
loading.value = true;
try {
const data = await api.getUsers();
users.value = data.users || [];
} catch (error) {
console.error('Error cargando usuarios:', error);
// El modal de login se manejará automáticamente desde App.vue
} finally {
loading.value = false;
}
}
async function handleCreateUser() {
addError.value = '';
loadingAction.value = true;
if (!userForm.value.username || !userForm.value.password || !userForm.value.passwordConfirm) {
addError.value = 'Todos los campos son requeridos';
loadingAction.value = false;
return;
}
if (userForm.value.username.length < 3) {
addError.value = 'El nombre de usuario debe tener al menos 3 caracteres';
loadingAction.value = false;
return;
}
if (userForm.value.password.length < 6) {
addError.value = 'La contraseña debe tener al menos 6 caracteres';
loadingAction.value = false;
return;
}
if (userForm.value.password !== userForm.value.passwordConfirm) {
addError.value = 'Las contraseñas no coinciden';
loadingAction.value = false;
return;
}
try {
await api.createUser({
username: userForm.value.username,
password: userForm.value.password,
});
closeAddModal();
await loadUsers();
} catch (error) {
console.error('Error creando usuario:', error);
if (error.response?.data?.error) {
addError.value = error.response.data.error;
} else {
addError.value = 'Error creando usuario. Intenta de nuevo.';
}
} finally {
loadingAction.value = false;
}
}
async function handleChangePassword() {
passwordError.value = '';
passwordSuccess.value = '';
loadingAction.value = true;
if (!passwordForm.value.currentPassword || !passwordForm.value.newPassword || !passwordForm.value.newPasswordConfirm) {
passwordError.value = 'Todos los campos son requeridos';
loadingAction.value = false;
return;
}
if (passwordForm.value.newPassword.length < 6) {
passwordError.value = 'La nueva contraseña debe tener al menos 6 caracteres';
loadingAction.value = false;
return;
}
if (passwordForm.value.newPassword !== passwordForm.value.newPasswordConfirm) {
passwordError.value = 'Las contraseñas no coinciden';
loadingAction.value = false;
return;
}
try {
await api.changePassword({
currentPassword: passwordForm.value.currentPassword,
newPassword: passwordForm.value.newPassword,
});
passwordSuccess.value = 'Contraseña actualizada correctamente';
// Actualizar credenciales guardadas si la nueva contraseña es para el usuario actual
const creds = authService.getCredentials();
if (creds.username === currentUser.value) {
authService.saveCredentials(currentUser.value, passwordForm.value.newPassword);
}
// Limpiar formulario después de 2 segundos
setTimeout(() => {
closeChangePasswordModal();
}, 2000);
} catch (error) {
console.error('Error cambiando contraseña:', error);
if (error.response?.data?.error) {
passwordError.value = error.response.data.error;
} else {
passwordError.value = 'Error cambiando contraseña. Intenta de nuevo.';
}
} finally {
loadingAction.value = false;
}
}
async function handleDeleteUser() {
if (!userToDelete.value) return;
loadingAction.value = true;
try {
await api.deleteUser(userToDelete.value);
userToDelete.value = null;
await loadUsers();
} catch (error) {
console.error('Error eliminando usuario:', error);
alert(error.response?.data?.error || 'Error eliminando usuario. Intenta de nuevo.');
} finally {
loadingAction.value = false;
}
}
function confirmDeleteUser(username) {
userToDelete.value = username;
}
function closeAddModal() {
showAddModal.value = false;
addError.value = '';
userForm.value = {
username: '',
password: '',
passwordConfirm: '',
};
}
function closeChangePasswordModal() {
showChangePasswordModal.value = false;
passwordError.value = '';
passwordSuccess.value = '';
passwordForm.value = {
currentPassword: '',
newPassword: '',
newPasswordConfirm: '',
};
}
function handleAuthLogout() {
// Cuando el usuario se desconecta globalmente, limpiar datos
users.value = [];
showAddModal.value = false;
showChangePasswordModal.value = false;
userToDelete.value = null;
addError.value = '';
passwordError.value = '';
passwordSuccess.value = '';
}
onMounted(() => {
loadUsers();
window.addEventListener('auth-logout', handleAuthLogout);
// Escuchar evento de login exitoso para recargar usuarios
window.addEventListener('auth-login', () => {
loadUsers();
});
});
onUnmounted(() => {
window.removeEventListener('auth-logout', handleAuthLogout);
window.removeEventListener('auth-login', loadUsers);
});
</script>