diff --git a/logo.jpg b/logo.jpg
deleted file mode 100644
index 6a41a34..0000000
Binary files a/logo.jpg and /dev/null differ
diff --git a/vapid-keys.json b/vapid-keys.json
new file mode 100644
index 0000000..6f6f742
--- /dev/null
+++ b/vapid-keys.json
@@ -0,0 +1,4 @@
+{
+ "publicKey": "BL7OVKXYntCaPe1ZW8rd2VyKnks7QV8UjekJItGqsJ1S8dfXggPeqIbZ8aOopkIpFMwwhqh1WA9xZp_gDiDYPAU",
+ "privateKey": "gZTPDru8a6WetWfkBtscyeUx7A9TNGPOq5zDPTyFkNQ"
+}
\ No newline at end of file
diff --git a/web/backend/README.md b/web/backend/README.md
new file mode 100644
index 0000000..2cb0a90
--- /dev/null
+++ b/web/backend/README.md
@@ -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
+
diff --git a/web/backend/config/constants.js b/web/backend/config/constants.js
new file mode 100644
index 0000000..c2201bb
--- /dev/null
+++ b/web/backend/config/constants.js
@@ -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';
+
diff --git a/web/backend/middlewares/auth.js b/web/backend/middlewares/auth.js
new file mode 100644
index 0000000..86dffe6
--- /dev/null
+++ b/web/backend/middlewares/auth.js
@@ -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' });
+ }
+}
+
diff --git a/web/backend/middlewares/rateLimit.js b/web/backend/middlewares/rateLimit.js
new file mode 100644
index 0000000..9034e45
--- /dev/null
+++ b/web/backend/middlewares/rateLimit.js
@@ -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
+ });
+ }
+}
+
diff --git a/web/backend/package-lock.json b/web/backend/package-lock.json
index e816326..1a4c637 100644
--- a/web/backend/package-lock.json
+++ b/web/backend/package-lock.json
@@ -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",
diff --git a/web/backend/package.json b/web/backend/package.json
index 0c2123e..c29031d 100644
--- a/web/backend/package.json
+++ b/web/backend/package.json
@@ -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",
diff --git a/web/backend/routes/articles.js b/web/backend/routes/articles.js
new file mode 100644
index 0000000..771f534
--- /dev/null
+++ b/web/backend/routes/articles.js
@@ -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;
+
diff --git a/web/backend/routes/config.js b/web/backend/routes/config.js
new file mode 100644
index 0000000..3934927
--- /dev/null
+++ b/web/backend/routes/config.js
@@ -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;
+
diff --git a/web/backend/routes/favorites.js b/web/backend/routes/favorites.js
new file mode 100644
index 0000000..6af0442
--- /dev/null
+++ b/web/backend/routes/favorites.js
@@ -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;
+
diff --git a/web/backend/routes/index.js b/web/backend/routes/index.js
new file mode 100644
index 0000000..139e64f
--- /dev/null
+++ b/web/backend/routes/index.js
@@ -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;
+
diff --git a/web/backend/routes/logs.js b/web/backend/routes/logs.js
new file mode 100644
index 0000000..af30939
--- /dev/null
+++ b/web/backend/routes/logs.js
@@ -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;
+
diff --git a/web/backend/routes/push.js b/web/backend/routes/push.js
new file mode 100644
index 0000000..9c9d475
--- /dev/null
+++ b/web/backend/routes/push.js
@@ -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;
+
diff --git a/web/backend/routes/telegram.js b/web/backend/routes/telegram.js
new file mode 100644
index 0000000..9fa1b36
--- /dev/null
+++ b/web/backend/routes/telegram.js
@@ -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;
+
diff --git a/web/backend/routes/users.js b/web/backend/routes/users.js
new file mode 100644
index 0000000..93d2ff9
--- /dev/null
+++ b/web/backend/routes/users.js
@@ -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;
+
diff --git a/web/backend/routes/workers.js b/web/backend/routes/workers.js
new file mode 100644
index 0000000..0c40838
--- /dev/null
+++ b/web/backend/routes/workers.js
@@ -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;
+
diff --git a/web/backend/server.js b/web/backend/server.js
index d723b60..cc43756 100644
--- a/web/backend/server.js
+++ b/web/backend/server.js
@@ -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);
-
diff --git a/web/backend/services/articleMonitor.js b/web/backend/services/articleMonitor.js
new file mode 100644
index 0000000..cae7626
--- /dev/null
+++ b/web/backend/services/articleMonitor.js
@@ -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;
+ }
+}
+
+
diff --git a/web/backend/services/fileWatcher.js b/web/backend/services/fileWatcher.js
new file mode 100644
index 0000000..7e91157
--- /dev/null
+++ b/web/backend/services/fileWatcher.js
@@ -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;
+ }
+}
+
diff --git a/web/backend/services/redis.js b/web/backend/services/redis.js
new file mode 100644
index 0000000..141864c
--- /dev/null
+++ b/web/backend/services/redis.js
@@ -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();
+ }
+}
+
diff --git a/web/backend/services/webPush.js b/web/backend/services/webPush.js
new file mode 100644
index 0000000..e7ca187
--- /dev/null
+++ b/web/backend/services/webPush.js
@@ -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);
+}
+
diff --git a/web/backend/services/websocket.js b/web/backend/services/websocket.js
new file mode 100644
index 0000000..15fa871
--- /dev/null
+++ b/web/backend/services/websocket.js
@@ -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;
+}
+
diff --git a/web/backend/utils/fileUtils.js b/web/backend/utils/fileUtils.js
new file mode 100644
index 0000000..f56a1a9
--- /dev/null
+++ b/web/backend/utils/fileUtils.js
@@ -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);
+ }
+}
+
diff --git a/web/frontend/src/App.vue b/web/frontend/src/App.vue
index a4a2b04..9bc68b4 100644
--- a/web/frontend/src/App.vue
+++ b/web/frontend/src/App.vue
@@ -45,6 +45,19 @@
+ Ingresa tus credenciales para acceder a las funciones de administrador. +
+ + + +
+ 💡 Por defecto: usuario admin / contraseña admin
+
Cargando usuarios...
+No hay usuarios configurados
+ ++ ¿Estás seguro de que deseas eliminar al usuario {{ userToDelete }}? +
++ Esta acción no se puede deshacer. +
+ +