feat: implement user authentication and login modal, refactor backend
This commit is contained in:
104
web/backend/README.md
Normal file
104
web/backend/README.md
Normal 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
|
||||
|
||||
34
web/backend/config/constants.js
Normal file
34
web/backend/config/constants.js
Normal 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';
|
||||
|
||||
65
web/backend/middlewares/auth.js
Normal file
65
web/backend/middlewares/auth.js
Normal 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' });
|
||||
}
|
||||
}
|
||||
|
||||
30
web/backend/middlewares/rateLimit.js
Normal file
30
web/backend/middlewares/rateLimit.js
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
594
web/backend/package-lock.json
generated
594
web/backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
81
web/backend/routes/articles.js
Normal file
81
web/backend/routes/articles.js
Normal 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;
|
||||
|
||||
29
web/backend/routes/config.js
Normal file
29
web/backend/routes/config.js
Normal 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;
|
||||
|
||||
99
web/backend/routes/favorites.js
Normal file
99
web/backend/routes/favorites.js
Normal 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;
|
||||
|
||||
81
web/backend/routes/index.js
Normal file
81
web/backend/routes/index.js
Normal 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;
|
||||
|
||||
22
web/backend/routes/logs.js
Normal file
22
web/backend/routes/logs.js
Normal 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;
|
||||
|
||||
73
web/backend/routes/push.js
Normal file
73
web/backend/routes/push.js
Normal 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;
|
||||
|
||||
70
web/backend/routes/telegram.js
Normal file
70
web/backend/routes/telegram.js
Normal 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
177
web/backend/routes/users.js
Normal 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;
|
||||
|
||||
35
web/backend/routes/workers.js
Normal file
35
web/backend/routes/workers.js
Normal 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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
// Inicializar WebSocket
|
||||
initWebSocket(server);
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
// 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() {
|
||||
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}`);
|
||||
});
|
||||
try {
|
||||
// Inicializar Redis
|
||||
await initRedis();
|
||||
|
||||
// Iniciar monitoreo de artículos nuevos
|
||||
await startArticleMonitoring();
|
||||
|
||||
// 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);
|
||||
|
||||
|
||||
115
web/backend/services/articleMonitor.js
Normal file
115
web/backend/services/articleMonitor.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import { getRedisClient, initNotifiedArticleKeys } from './redis.js';
|
||||
import { broadcast } from './websocket.js';
|
||||
import { sendPushNotifications } from './webPush.js';
|
||||
import { ARTICLE_MONITORING } from '../config/constants.js';
|
||||
|
||||
let notifiedArticleKeys = new Set();
|
||||
let articlesCheckInterval = null;
|
||||
|
||||
// Función para detectar y enviar artículos nuevos
|
||||
async function checkForNewArticles() {
|
||||
const redisClient = getRedisClient();
|
||||
if (!redisClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentKeys = await redisClient.keys('notified:*');
|
||||
const currentKeysSet = new Set(currentKeys);
|
||||
|
||||
// Encontrar claves nuevas
|
||||
const newKeys = currentKeys.filter(key => !notifiedArticleKeys.has(key));
|
||||
|
||||
if (newKeys.length > 0) {
|
||||
// Obtener los artículos nuevos
|
||||
const newArticles = [];
|
||||
for (const key of newKeys) {
|
||||
try {
|
||||
const value = await redisClient.get(key);
|
||||
if (value) {
|
||||
// Intentar parsear como JSON
|
||||
let articleData = {};
|
||||
try {
|
||||
articleData = JSON.parse(value);
|
||||
} catch (e) {
|
||||
// Si no es JSON válido, extraer información de la key
|
||||
const parts = key.split(':');
|
||||
if (parts.length >= 3) {
|
||||
articleData = {
|
||||
platform: parts[1],
|
||||
id: parts.slice(2).join(':'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Añadir información adicional si está disponible
|
||||
if (articleData.platform && articleData.id) {
|
||||
newArticles.push({
|
||||
platform: articleData.platform || 'unknown',
|
||||
id: articleData.id || 'unknown',
|
||||
title: articleData.title || null,
|
||||
price: articleData.price || null,
|
||||
currency: articleData.currency || '€',
|
||||
url: articleData.url || null,
|
||||
images: articleData.images || [],
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error obteniendo artículo de Redis (${key}):`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Enviar artículos nuevos por WebSocket
|
||||
if (newArticles.length > 0) {
|
||||
broadcast({
|
||||
type: 'new_articles',
|
||||
data: newArticles
|
||||
});
|
||||
|
||||
// Enviar notificaciones push para cada artículo nuevo
|
||||
for (const article of newArticles) {
|
||||
await sendPushNotifications({
|
||||
title: `Nuevo artículo en ${article.platform?.toUpperCase() || 'Wallabicher'}`,
|
||||
body: article.title || 'Artículo nuevo disponible',
|
||||
icon: '/android-chrome-192x192.png',
|
||||
image: article.images?.[0] || null,
|
||||
url: article.url || '/',
|
||||
platform: article.platform,
|
||||
price: article.price,
|
||||
currency: article.currency || '€',
|
||||
id: article.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Actualizar el set de claves notificadas
|
||||
notifiedArticleKeys = currentKeysSet;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error verificando artículos nuevos:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Inicializar el check de artículos cuando Redis esté listo
|
||||
export async function startArticleMonitoring() {
|
||||
const redisClient = getRedisClient();
|
||||
if (redisClient) {
|
||||
// Inicializar claves conocidas
|
||||
notifiedArticleKeys = await initNotifiedArticleKeys();
|
||||
|
||||
// Iniciar intervalo para verificar nuevos artículos
|
||||
articlesCheckInterval = setInterval(checkForNewArticles, ARTICLE_MONITORING.CHECK_INTERVAL);
|
||||
console.log('✅ Monitoreo de artículos nuevos iniciado');
|
||||
}
|
||||
}
|
||||
|
||||
// Detener el monitoreo
|
||||
export function stopArticleMonitoring() {
|
||||
if (articlesCheckInterval) {
|
||||
clearInterval(articlesCheckInterval);
|
||||
articlesCheckInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
41
web/backend/services/fileWatcher.js
Normal file
41
web/backend/services/fileWatcher.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { watch } from 'chokidar';
|
||||
import { existsSync } from 'fs';
|
||||
import { PATHS } from '../config/constants.js';
|
||||
import { readJSON } from '../utils/fileUtils.js';
|
||||
import { broadcast } from './websocket.js';
|
||||
|
||||
let watcher = null;
|
||||
|
||||
// Inicializar file watcher
|
||||
export function initFileWatcher() {
|
||||
// Watch files for changes
|
||||
const filesToWatch = [PATHS.WORKERS].filter(p => existsSync(p));
|
||||
|
||||
if (filesToWatch.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
watcher = watch(filesToWatch, {
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
});
|
||||
|
||||
watcher.on('change', async (path) => {
|
||||
console.log(`Archivo cambiado: ${path}`);
|
||||
if (path === PATHS.WORKERS) {
|
||||
const workers = readJSON(PATHS.WORKERS);
|
||||
broadcast({ type: 'workers_updated', data: workers });
|
||||
}
|
||||
});
|
||||
|
||||
console.log('✅ File watcher inicializado');
|
||||
}
|
||||
|
||||
// Detener file watcher
|
||||
export function stopFileWatcher() {
|
||||
if (watcher) {
|
||||
watcher.close();
|
||||
watcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
219
web/backend/services/redis.js
Normal file
219
web/backend/services/redis.js
Normal file
@@ -0,0 +1,219 @@
|
||||
import redis from 'redis';
|
||||
import yaml from 'yaml';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { RateLimiterRedis } from 'rate-limiter-flexible';
|
||||
import { PATHS } from '../config/constants.js';
|
||||
import { RATE_LIMIT } from '../config/constants.js';
|
||||
|
||||
let redisClient = null;
|
||||
let rateLimiter = null;
|
||||
let config = null;
|
||||
|
||||
// Inicializar Redis si está configurado
|
||||
export async function initRedis() {
|
||||
try {
|
||||
config = yaml.parse(readFileSync(PATHS.CONFIG, 'utf8'));
|
||||
const cacheConfig = config?.cache;
|
||||
|
||||
if (cacheConfig?.type === 'redis') {
|
||||
const redisConfig = cacheConfig.redis;
|
||||
// En Docker, usar el nombre del servicio si no se especifica host
|
||||
const redisHost = process.env.REDIS_HOST || redisConfig.host || 'localhost';
|
||||
redisClient = redis.createClient({
|
||||
socket: {
|
||||
host: redisHost,
|
||||
port: redisConfig.port || 6379,
|
||||
},
|
||||
password: redisConfig.password || undefined,
|
||||
database: redisConfig.db || 0,
|
||||
});
|
||||
|
||||
redisClient.on('error', (err) => console.error('Redis Client Error', err));
|
||||
await redisClient.connect();
|
||||
console.log('✅ Conectado a Redis');
|
||||
|
||||
// Inicializar rate limiter con Redis
|
||||
try {
|
||||
// Crear un cliente Redis adicional para el rate limiter
|
||||
const rateLimiterClient = redis.createClient({
|
||||
socket: {
|
||||
host: redisHost,
|
||||
port: redisConfig.port || 6379,
|
||||
},
|
||||
password: redisConfig.password || undefined,
|
||||
database: redisConfig.db || 0,
|
||||
});
|
||||
await rateLimiterClient.connect();
|
||||
|
||||
rateLimiter = new RateLimiterRedis({
|
||||
storeClient: rateLimiterClient,
|
||||
keyPrefix: 'rl:',
|
||||
points: RATE_LIMIT.POINTS,
|
||||
duration: RATE_LIMIT.DURATION,
|
||||
blockDuration: RATE_LIMIT.BLOCK_DURATION,
|
||||
});
|
||||
console.log('✅ Rate limiter inicializado con Redis');
|
||||
} catch (error) {
|
||||
console.error('Error inicializando rate limiter:', error.message);
|
||||
}
|
||||
|
||||
// Inicializar usuario admin por defecto si no existe
|
||||
await initDefaultAdmin();
|
||||
|
||||
} else {
|
||||
console.log('ℹ️ Redis no configurado, usando modo memoria');
|
||||
console.log('⚠️ Rate limiting y autenticación requieren Redis');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error inicializando Redis:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Inicializar usuario admin por defecto
|
||||
async function initDefaultAdmin() {
|
||||
if (!redisClient) return;
|
||||
|
||||
try {
|
||||
const adminExists = await redisClient.exists('user:admin');
|
||||
if (!adminExists) {
|
||||
// Crear usuario admin por defecto con contraseña "admin"
|
||||
// En producción, esto debería cambiarse
|
||||
const defaultPassword = 'admin';
|
||||
const hashedPassword = await bcrypt.hash(defaultPassword, 10);
|
||||
await redisClient.hSet('user:admin', {
|
||||
username: 'admin',
|
||||
passwordHash: hashedPassword,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
console.log('✅ Usuario admin creado por defecto (usuario: admin, contraseña: admin)');
|
||||
console.log('⚠️ IMPORTANTE: Cambia la contraseña por defecto en producción');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error inicializando usuario admin:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Getters
|
||||
export function getRedisClient() {
|
||||
return redisClient;
|
||||
}
|
||||
|
||||
export function getRateLimiter() {
|
||||
return rateLimiter;
|
||||
}
|
||||
|
||||
export function getConfig() {
|
||||
return config;
|
||||
}
|
||||
|
||||
export function reloadConfig() {
|
||||
try {
|
||||
config = yaml.parse(readFileSync(PATHS.CONFIG, 'utf8'));
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.error('Error recargando configuración:', error.message);
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
// Funciones de utilidad para artículos
|
||||
export async function getNotifiedArticles() {
|
||||
if (!redisClient) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const keys = await redisClient.keys('notified:*');
|
||||
const articles = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const parts = key.split(':');
|
||||
if (parts.length >= 3) {
|
||||
const platform = parts[1];
|
||||
const id = parts.slice(2).join(':');
|
||||
const ttl = await redisClient.ttl(key);
|
||||
const value = await redisClient.get(key);
|
||||
|
||||
// Intentar parsear como JSON (nuevo formato con toda la info)
|
||||
let articleData = {};
|
||||
try {
|
||||
if (value && value !== '1') {
|
||||
articleData = JSON.parse(value);
|
||||
}
|
||||
} catch (e) {
|
||||
// Si no es JSON válido, usar valor por defecto
|
||||
}
|
||||
|
||||
articles.push({
|
||||
platform: articleData.platform || platform,
|
||||
id: articleData.id || id,
|
||||
title: articleData.title || null,
|
||||
description: articleData.description || null,
|
||||
price: articleData.price || null,
|
||||
currency: articleData.currency || null,
|
||||
location: articleData.location || null,
|
||||
allows_shipping: articleData.allows_shipping !== undefined ? articleData.allows_shipping : null,
|
||||
url: articleData.url || null,
|
||||
images: articleData.images || [],
|
||||
modified_at: articleData.modified_at || null,
|
||||
notifiedAt: Date.now() - (7 * 24 * 60 * 60 - ttl) * 1000,
|
||||
expiresAt: Date.now() + ttl * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return articles;
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo artículos de Redis:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFavorites() {
|
||||
if (!redisClient) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const keys = await redisClient.keys('notified:*');
|
||||
const favorites = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const value = await redisClient.get(key);
|
||||
if (value) {
|
||||
try {
|
||||
const articleData = JSON.parse(value);
|
||||
if (articleData.is_favorite === true) {
|
||||
favorites.push(articleData);
|
||||
}
|
||||
} catch (e) {
|
||||
// Si no es JSON válido, ignorar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return favorites;
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo favoritos de Redis:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Inicializar claves conocidas para evitar notificar artículos existentes
|
||||
export async function initNotifiedArticleKeys() {
|
||||
if (!redisClient) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
try {
|
||||
const initialKeys = await redisClient.keys('notified:*');
|
||||
const keysSet = new Set(initialKeys);
|
||||
console.log(`📋 ${keysSet.size} artículos ya notificados detectados`);
|
||||
return keysSet;
|
||||
} catch (error) {
|
||||
console.error('Error inicializando claves de artículos:', error.message);
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
79
web/backend/services/webPush.js
Normal file
79
web/backend/services/webPush.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import webpush from 'web-push';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { PATHS, VAPID_CONTACT } from '../config/constants.js';
|
||||
import { readJSON, writeJSON } from '../utils/fileUtils.js';
|
||||
|
||||
let vapidKeys = null;
|
||||
|
||||
// Inicializar VAPID keys para Web Push
|
||||
export function initVAPIDKeys() {
|
||||
try {
|
||||
if (existsSync(PATHS.VAPID_KEYS)) {
|
||||
vapidKeys = JSON.parse(readFileSync(PATHS.VAPID_KEYS, 'utf8'));
|
||||
console.log('✅ VAPID keys cargadas desde archivo');
|
||||
} else {
|
||||
// Generar nuevas VAPID keys
|
||||
vapidKeys = webpush.generateVAPIDKeys();
|
||||
writeFileSync(PATHS.VAPID_KEYS, JSON.stringify(vapidKeys, null, 2), 'utf8');
|
||||
console.log('✅ Nuevas VAPID keys generadas y guardadas');
|
||||
}
|
||||
|
||||
// Configurar web-push con las VAPID keys
|
||||
webpush.setVapidDetails(
|
||||
VAPID_CONTACT,
|
||||
vapidKeys.publicKey,
|
||||
vapidKeys.privateKey
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error inicializando VAPID keys:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener clave pública VAPID
|
||||
export function getPublicKey() {
|
||||
if (!vapidKeys || !vapidKeys.publicKey) {
|
||||
throw new Error('VAPID keys no están configuradas');
|
||||
}
|
||||
return vapidKeys.publicKey;
|
||||
}
|
||||
|
||||
// Obtener suscripciones push guardadas
|
||||
export function getPushSubscriptions() {
|
||||
return readJSON(PATHS.PUSH_SUBSCRIPTIONS, []);
|
||||
}
|
||||
|
||||
// Guardar suscripciones push
|
||||
export function savePushSubscriptions(subscriptions) {
|
||||
return writeJSON(PATHS.PUSH_SUBSCRIPTIONS, subscriptions);
|
||||
}
|
||||
|
||||
// Enviar notificación push a todas las suscripciones
|
||||
export async function sendPushNotifications(notificationData) {
|
||||
const subscriptions = getPushSubscriptions();
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify(notificationData);
|
||||
const promises = subscriptions.map(async (subscription) => {
|
||||
try {
|
||||
await webpush.sendNotification(subscription, payload);
|
||||
console.log('✅ Notificación push enviada');
|
||||
} catch (error) {
|
||||
console.error('Error enviando notificación push:', error);
|
||||
|
||||
// Si la suscripción es inválida (404, 410), eliminarla
|
||||
if (error.statusCode === 404 || error.statusCode === 410) {
|
||||
const updatedSubscriptions = getPushSubscriptions().filter(
|
||||
sub => sub.endpoint !== subscription.endpoint
|
||||
);
|
||||
savePushSubscriptions(updatedSubscriptions);
|
||||
console.log(`Suscripción inválida eliminada. Total: ${updatedSubscriptions.length}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
39
web/backend/services/websocket.js
Normal file
39
web/backend/services/websocket.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
let wss = null;
|
||||
|
||||
// Inicializar WebSocket Server
|
||||
export function initWebSocket(server) {
|
||||
wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
console.log('Cliente WebSocket conectado');
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('Cliente WebSocket desconectado');
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('Error WebSocket:', error);
|
||||
});
|
||||
});
|
||||
|
||||
return wss;
|
||||
}
|
||||
|
||||
// Broadcast a todos los clientes WebSocket
|
||||
export function broadcast(data) {
|
||||
if (!wss) return;
|
||||
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === 1) { // WebSocket.OPEN
|
||||
client.send(JSON.stringify(data));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Obtener instancia del WebSocket Server
|
||||
export function getWebSocketServer() {
|
||||
return wss;
|
||||
}
|
||||
|
||||
88
web/backend/utils/fileUtils.js
Normal file
88
web/backend/utils/fileUtils.js
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
95
web/frontend/src/services/auth.js
Normal file
95
web/frontend/src/services/auth.js
Normal 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;
|
||||
|
||||
554
web/frontend/src/views/Users.vue
Normal file
554
web/frontend/src/views/Users.vue
Normal 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"
|
||||
>
|
||||
Tú
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user