diff --git a/web/backend/package-lock.json b/web/backend/package-lock.json index 6e5d8b9..e816326 100644 --- a/web/backend/package-lock.json +++ b/web/backend/package-lock.json @@ -1,5 +1,5 @@ { - "name": "wallabicher-backend", + "name": "wallabicher-backend", "version": "1.0.0", "lockfileVersion": 3, "requires": true, @@ -13,6 +13,7 @@ "cors": "^2.8.5", "express": "^4.18.2", "redis": "^4.6.10", + "web-push": "^3.6.7", "ws": "^8.14.2", "yaml": "^2.3.4" } @@ -89,6 +90,15 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -108,6 +118,18 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -120,6 +142,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -156,6 +184,12 @@ "node": ">=8" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -318,6 +352,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -589,6 +632,15 @@ "node": ">= 0.4" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -609,6 +661,42 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/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/https-proxy-agent/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/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -678,6 +766,27 @@ "node": ">=0.12.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -747,6 +856,21 @@ "node": ">= 0.6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -1131,6 +1255,25 @@ "node": ">= 0.8" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "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 9199db1..0c2123e 100644 --- a/web/backend/package.json +++ b/web/backend/package.json @@ -8,16 +8,20 @@ "start": "node server.js", "dev": "node --watch server.js" }, - "keywords": ["wallabicher", "api", "express"], + "keywords": [ + "wallabicher", + "api", + "express" + ], "author": "", "license": "MIT", "dependencies": { - "express": "^4.18.2", + "chokidar": "^3.5.3", "cors": "^2.8.5", - "ws": "^8.14.2", + "express": "^4.18.2", "redis": "^4.6.10", - "yaml": "^2.3.4", - "chokidar": "^3.5.3" + "web-push": "^3.6.7", + "ws": "^8.14.2", + "yaml": "^2.3.4" } } - diff --git a/web/backend/server.js b/web/backend/server.js index 539c836..d723b60 100644 --- a/web/backend/server.js +++ b/web/backend/server.js @@ -8,6 +8,7 @@ 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); @@ -24,6 +25,37 @@ 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'); + +// 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() { @@ -585,6 +617,112 @@ app.get('/api/telegram/threads', async (req, res) => { } }); +// 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'); @@ -684,6 +822,21 @@ async function checkForNewArticles() { 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 diff --git a/web/frontend/public/manifest.json b/web/frontend/public/manifest.json index afca5ef..3e3f4d9 100644 --- a/web/frontend/public/manifest.json +++ b/web/frontend/public/manifest.json @@ -17,6 +17,8 @@ "theme_color": "#0284c7", "background_color": "#ffffff", "display": "standalone", - "start_url": "/" + "start_url": "/", + "scope": "/", + "gcm_sender_id": "103953800507" } diff --git a/web/frontend/public/sw.js b/web/frontend/public/sw.js new file mode 100644 index 0000000..7e62403 --- /dev/null +++ b/web/frontend/public/sw.js @@ -0,0 +1,100 @@ +// Service Worker para notificaciones push +const CACHE_NAME = 'wallabicher-v1'; + +// Instalar Service Worker +self.addEventListener('install', (event) => { + console.log('Service Worker instalado'); + self.skipWaiting(); +}); + +// Activar Service Worker +self.addEventListener('activate', (event) => { + console.log('Service Worker activado'); + event.waitUntil(self.clients.claim()); +}); + +// Manejar mensajes push +self.addEventListener('push', (event) => { + console.log('Push recibido:', event); + + let notificationData = { + title: 'Wallabicher', + body: 'Tienes nuevas notificaciones', + icon: '/android-chrome-192x192.png', + badge: '/android-chrome-192x192.png', + tag: 'wallabicher-notification', + requireInteraction: false, + data: {} + }; + + // Si hay datos en el push, usarlos + if (event.data) { + try { + const data = event.data.json(); + if (data.title) notificationData.title = data.title; + if (data.body) notificationData.body = data.body; + if (data.icon) notificationData.icon = data.icon; + if (data.image) notificationData.image = data.image; + if (data.url) notificationData.data.url = data.url; + if (data.platform) notificationData.data.platform = data.platform; + if (data.price) notificationData.data.price = data.price; + if (data.currency) notificationData.data.currency = data.currency; + + // Usar tag único para agrupar notificaciones por artículo + if (data.id) notificationData.tag = `article-${data.id}`; + } catch (e) { + // Si no es JSON, usar como texto + notificationData.body = event.data.text(); + } + } + + event.waitUntil( + self.registration.showNotification(notificationData.title, { + body: notificationData.body, + icon: notificationData.icon, + badge: notificationData.badge, + image: notificationData.image, + tag: notificationData.tag, + requireInteraction: notificationData.requireInteraction, + data: notificationData.data, + actions: notificationData.data.url ? [ + { + action: 'open', + title: 'Ver artículo' + }, + { + action: 'close', + title: 'Cerrar' + } + ] : [] + }) + ); +}); + +// Manejar clics en notificaciones +self.addEventListener('notificationclick', (event) => { + console.log('Notificación clickeada:', event); + + event.notification.close(); + + if (event.action === 'close') { + return; + } + + // Si hay una URL, abrirla + if (event.notification.data.url) { + event.waitUntil( + clients.openWindow(event.notification.data.url) + ); + } else { + // Si no hay URL, abrir la app + event.waitUntil( + clients.openWindow('/') + ); + } +}); + +// Manejar notificaciones cerradas +self.addEventListener('notificationclose', (event) => { + console.log('Notificación cerrada:', event); +}); diff --git a/web/frontend/src/App.vue b/web/frontend/src/App.vue index ee1e5aa..a4a2b04 100644 --- a/web/frontend/src/App.vue +++ b/web/frontend/src/App.vue @@ -21,6 +21,22 @@
+ +