diff --git a/.eslintrc b/.eslintrc index dec82d92..061064e8 100644 --- a/.eslintrc +++ b/.eslintrc @@ -2,7 +2,8 @@ "env": { "browser": true, "es2021": true, - "node": true + "node": true, + "serviceworker": true }, "root": true, "parser": "vue-eslint-parser", diff --git a/app/client/public/service-worker.js b/app/client/public/service-worker.js new file mode 100644 index 00000000..47185459 --- /dev/null +++ b/app/client/public/service-worker.js @@ -0,0 +1,14 @@ +self.addEventListener('push', function (event) { + let data = {}; + if (event.data) { + data = event.data.json(); + } + + const title = data.title ?? 'SkyChat Notification'; + const options = { + body: data.body ?? 'New notification from SkyChat', + icon: '/favicon.png', + }; + + event.waitUntil(self.registration.showNotification(title, options)); +}); diff --git a/app/client/src/lib/WebPush.js b/app/client/src/lib/WebPush.js new file mode 100644 index 00000000..a8bca2e7 --- /dev/null +++ b/app/client/src/lib/WebPush.js @@ -0,0 +1,39 @@ +export class WebPush { + static SERVICE_WORKER_URL = 'service-worker.js'; + + static urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; + } + + static async register(vapidPublicKey) { + if (!('serviceWorker' in navigator) || !('PushManager' in window)) { + throw new Error('WebPush is not supported'); + } + + const registration = await navigator.serviceWorker.register(WebPush.SERVICE_WORKER_URL); + const permission = await Notification.requestPermission(); + + const subscription = await registration.pushManager.getSubscription(); + if (subscription) { + return null; + } + + if (permission !== 'granted') { + throw new Error('Permission denied'); + } + + const convertedVapidKey = WebPush.urlBase64ToUint8Array(vapidPublicKey); + + return registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: convertedVapidKey, + }); + } +} diff --git a/app/client/src/stores/client.js b/app/client/src/stores/client.js index b79b239f..d2d98e49 100644 --- a/app/client/src/stores/client.js +++ b/app/client/src/stores/client.js @@ -1,6 +1,7 @@ import { defineStore } from 'pinia'; import { useToast } from 'vue-toastification'; import { SkyChatClient } from '../../../api/index.ts'; +import { WebPush } from '../lib/WebPush.js'; // Connect to SkyChatClient const protocol = document.location.protocol === 'http:' ? 'ws' : 'wss'; @@ -84,6 +85,23 @@ export const useClientStore = defineStore('client', { this.messages[messageIndex] = message; }); + // Ask for push notification permission on user login + client.on('set-user', async (user) => { + if (!user.id) { + return; + } + + try { + const subscription = await WebPush.register(import.meta.env.VAPID_PUBLIC_KEY); + if (subscription) { + client.sendMessage(`/push ${JSON.stringify(subscription)}`); + } + } catch (error) { + console.error(error); + return; + } + }); + client.on('info', (info) => { const toast = useToast(); toast.info(info); diff --git a/app/server/plugins/core/CorePluginGroup.ts b/app/server/plugins/core/CorePluginGroup.ts index bc25c49e..3fad2844 100644 --- a/app/server/plugins/core/CorePluginGroup.ts +++ b/app/server/plugins/core/CorePluginGroup.ts @@ -20,6 +20,7 @@ import { ReactionPlugin } from './global/ReactionPlugin.js'; import { SetRightPlugin } from './global/SetRightPlugin.js'; import { StickerPlugin } from './global/StickerPlugin.js'; import { VoidPlugin } from './global/VoidPlugin.js'; +import { WebPushPlugin } from './global/WebPushPlugin.js'; import { WelcomePlugin } from './global/WelcomePlugin.js'; import { XpTickerPlugin } from './global/XpTickerPlugin.js'; import { HelpPlugin } from './room/HelpPlugin.js'; @@ -65,6 +66,7 @@ export class CorePluginGroup extends PluginGroup { SetRightPlugin, StickerPlugin, VoidPlugin, + WebPushPlugin, WelcomePlugin, XpTickerPlugin, ]; diff --git a/app/server/plugins/core/global/WebPushPlugin.ts b/app/server/plugins/core/global/WebPushPlugin.ts new file mode 100644 index 00000000..ba0c0cb4 --- /dev/null +++ b/app/server/plugins/core/global/WebPushPlugin.ts @@ -0,0 +1,136 @@ +import { Connection } from '../../../skychat/Connection.js'; +import { Logging } from '../../../skychat/Logging.js'; +import { RoomManager } from '../../../skychat/RoomManager.js'; +import { User } from '../../../skychat/User.js'; +import { UserController } from '../../../skychat/UserController.js'; +import { GlobalPlugin } from '../../GlobalPlugin.js'; + +import webpush from 'web-push'; + +type Subscription = { + endpoint: string; + expirationTime: number | null; + keys: { + auth: string; + p256dh: string; + }; +}; + +export class WebPushPlugin extends GlobalPlugin { + static readonly ENDPOINTS_WHITELIST = ['fcm.googleapis.com', '.push.services.mozilla.com', '.windows.com']; + + static readonly REPO_URL = 'https://github.com/skychatorg/skychat'; + + static readonly MAX_SUBSCRIPTIONS = 10; + + static readonly commandName = 'push'; + + static readonly commandAliases = ['pushclear']; + + readonly minRight = 0; + + readonly rules = { + push: { + minCount: 1, + coolDown: 2000, + }, + }; + + constructor(manager: RoomManager) { + super(manager); + + webpush.setVapidDetails(WebPushPlugin.REPO_URL, process.env.VAPID_PUBLIC_KEY as string, process.env.VAPID_PRIVATE_KEY as string); + } + + async run(alias: string, param: string, connection: Connection): Promise { + if (alias === 'push') { + return this.handlePush(param, connection); + } else if (alias === 'pushclear') { + return this.handlePushClear(connection); + } + } + + async handlePush(param: string, connection: Connection): Promise { + const data = JSON.parse(param); + if (!data || typeof data !== 'object') { + throw new Error('Invalid data'); + } + + // Sanitize data + if (typeof data.endpoint !== 'string') { + throw new Error('Invalid endpoint'); + } + const url = new URL(data.endpoint); + if (!url || !url.hostname || !WebPushPlugin.ENDPOINTS_WHITELIST.some((allowed) => url.hostname.endsWith(allowed))) { + throw new Error(`Endpoint ${url.hostname} not allowed for push notifications`); + } + if (typeof data.expirationTime !== 'number' && data.expirationTime !== null) { + throw new Error('Invalid expirationTime'); + } + if (typeof data.keys !== 'object') { + throw new Error('Invalid keys'); + } + if (typeof data.keys.auth !== 'string') { + throw new Error('Invalid keys.auth'); + } + if (typeof data.keys.p256dh !== 'string') { + throw new Error('Invalid keys.p256dh'); + } + const subscription: Subscription = { + endpoint: data.endpoint, + expirationTime: data.expirationTime, + keys: { + auth: data.keys.auth, + p256dh: data.keys.p256dh, + }, + }; + + // Prepare to store the subscription + const user = connection.session.user; + if (!Array.isArray(user.storage[WebPushPlugin.commandName])) { + user.storage[WebPushPlugin.commandName] = []; + } + + // Does the subscription already exist? + if (user.storage[WebPushPlugin.commandName].some((s: Subscription) => s.endpoint === subscription.endpoint)) { + return; + } + + // Store the subscription & clean up old ones + user.storage[WebPushPlugin.commandName].push(subscription); + while (user.storage[WebPushPlugin.commandName].length > WebPushPlugin.MAX_SUBSCRIPTIONS) { + user.storage[WebPushPlugin.commandName].shift(); + } + UserController.sync(user); + } + + async handlePushClear(connection: Connection): Promise { + const user = connection.session.user; + user.storage[WebPushPlugin.commandName] = []; + await UserController.sync(user); + } + + send(user: User, data: { title: string; body: string; tag: string }): void { + const subscriptions = user.storage[WebPushPlugin.commandName] as Subscription[]; + if (!subscriptions) { + return; + } + + // We do not `await` not to block the main logic + Logging.info(`Sending push notification to ${user.username}`); + for (const subscription of subscriptions) { + webpush + .sendNotification(subscription, JSON.stringify(data)) + .then(() => { + Logging.info(`Push notification sent to ${user.username}`); + }) + .catch(async (error) => { + // We delete the subscription if it is not valid anymore (we assume it is not if we can't send a notification once) + Logging.error(`Error sending push notification to ${user.username}`, error); + // drop `subscription` from `subscriptions` + user.storage[WebPushPlugin.commandName] = subscriptions.filter((s) => s !== subscription); + await UserController.sync(user); + }); + } + } +} diff --git a/app/server/plugins/core/room/MentionPlugin.ts b/app/server/plugins/core/room/MentionPlugin.ts index 87f67a31..d8125ce4 100644 --- a/app/server/plugins/core/room/MentionPlugin.ts +++ b/app/server/plugins/core/room/MentionPlugin.ts @@ -4,6 +4,7 @@ import { Message } from '../../../skychat/Message.js'; import { Session } from '../../../skychat/Session.js'; import { RoomPlugin } from '../../RoomPlugin.js'; import { BlacklistPlugin } from '../global/BlacklistPlugin.js'; +import { WebPushPlugin } from '../global/WebPushPlugin.js'; export class MentionPlugin extends RoomPlugin { static readonly commandName = 'mention'; @@ -25,7 +26,7 @@ export class MentionPlugin extends RoomPlugin { const mentions = message.content.match(/@[a-zA-Z0-9-_]+/g); - // Note quote detected + // No quote detected if (mentions === null) { return message; } @@ -60,9 +61,18 @@ export class MentionPlugin extends RoomPlugin { identifier: connection.session.identifier, messageId: message.id, }); + + // Send push notification + const webPushPlugin = this.room.manager.getPlugin(WebPushPlugin.commandName) as WebPushPlugin; + if (webPushPlugin && session.user.id) { + webPushPlugin.send(session.user, { + title: `Mention in #${this.room.name}`, + body: `${connection.session.user.username} mentioned you in #${this.room.name}`, + tag: 'mention', + }); + } } - // For each quote return message; } } diff --git a/app/template/.env.template b/app/template/.env.template index 0c40c38b..96980622 100644 --- a/app/template/.env.template +++ b/app/template/.env.template @@ -57,6 +57,10 @@ DOCKER_GID="$DOCKER_GID" # Container timezone. DOCKER_TZ="Europe/Paris" +# VAPID keys for web push notifications. +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= + # Mailgun API key. If unset, you will not be able to send emails. MAILGUN_API_KEY= diff --git a/docker-compose.yml b/docker-compose.yml index 99b1e27d..c4520ed8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,6 +46,8 @@ services: MAILGUN_API_KEY: "${MAILGUN_API_KEY}" MAILGUN_DOMAIN: "${MAILGUN_DOMAIN}" MAILGUN_FROM: "${MAILGUN_FROM}" + VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} + VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY} volumes: - ./config:/workdir/config - ./backups:/workdir/backups diff --git a/package-lock.json b/package-lock.json index cc77fe0d..84f604c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "mailgun.js": "^10.2.1", "p-queue": "^8.0.1", "pino": "^9.2.0", - "rate-limiter-flexible": "^5.0.3" + "rate-limiter-flexible": "^5.0.3", + "web-push": "^3.6.7" }, "devDependencies": { "@babel/cli": "^7.24.7", @@ -39,6 +40,7 @@ "@types/sha256": "^0.2.2", "@types/striptags": "^3.1.1", "@types/ua-parser-js": "^0.7.39", + "@types/web-push": "^3.6.4", "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^7.14.1", "@typescript-eslint/parser": "^7.14.1", @@ -3398,6 +3400,16 @@ "integrity": "sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==", "dev": true }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ws": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", @@ -3896,7 +3908,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", - "dev": true, "dependencies": { "debug": "^4.3.4" }, @@ -3908,7 +3919,6 @@ "version": "4.3.5", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -3924,8 +3934,7 @@ "node_modules/agent-base/node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/ajv": { "version": "6.12.6", @@ -4032,6 +4041,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/async": { "version": "2.6.4", "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", @@ -4221,6 +4242,12 @@ "node": ">=8" } }, + "node_modules/bn.js": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz", + "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", @@ -4340,8 +4367,7 @@ "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==", - "dev": true + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, "node_modules/buffer-from": { "version": "1.1.2", @@ -4846,7 +4872,6 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "dev": true, "dependencies": { "safe-buffer": "^5.0.1" } @@ -6150,6 +6175,15 @@ "node": ">=0.10.0" } }, + "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.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -6170,7 +6204,6 @@ "version": "7.0.5", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", - "dev": true, "dependencies": { "agent-base": "^7.0.2", "debug": "4" @@ -6183,7 +6216,6 @@ "version": "4.3.5", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -6199,8 +6231,7 @@ "node_modules/https-proxy-agent/node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/iconv-lite": { "version": "0.4.24", @@ -6286,8 +6317,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/internal-slot": { "version": "1.0.3", @@ -6736,7 +6766,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", - "dev": true, "dependencies": { "buffer-equal-constant-time": "1.0.1", "ecdsa-sig-formatter": "1.0.11", @@ -6747,7 +6776,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", - "dev": true, "dependencies": { "jwa": "^2.0.0", "safe-buffer": "^5.0.1" @@ -6951,6 +6979,12 @@ "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/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -6967,7 +7001,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -8414,8 +8447,7 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/secure-json-parse": { "version": "2.7.0", @@ -9504,6 +9536,25 @@ "vue": "^3.0.1" } }, + "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/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -11961,6 +12012,15 @@ "integrity": "sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==", "dev": true }, + "@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/ws": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", @@ -12304,7 +12364,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", - "dev": true, "requires": { "debug": "^4.3.4" }, @@ -12313,7 +12372,6 @@ "version": "4.3.5", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", - "dev": true, "requires": { "ms": "2.1.2" } @@ -12321,8 +12379,7 @@ "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" } } }, @@ -12406,6 +12463,17 @@ "is-string": "^1.0.7" } }, + "asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "requires": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "async": { "version": "2.6.4", "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", @@ -12541,6 +12609,11 @@ "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", "dev": true }, + "bn.js": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz", + "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==" + }, "body-parser": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", @@ -12616,8 +12689,7 @@ "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==", - "dev": true + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, "buffer-from": { "version": "1.1.2", @@ -12987,7 +13059,6 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "dev": true, "requires": { "safe-buffer": "^5.0.1" } @@ -13940,6 +14011,11 @@ "parse-passwd": "^1.0.0" } }, + "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==" + }, "http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -13957,7 +14033,6 @@ "version": "7.0.5", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", - "dev": true, "requires": { "agent-base": "^7.0.2", "debug": "4" @@ -13967,7 +14042,6 @@ "version": "4.3.5", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", - "dev": true, "requires": { "ms": "2.1.2" } @@ -13975,8 +14049,7 @@ "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" } } }, @@ -14035,8 +14108,7 @@ "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "internal-slot": { "version": "1.0.3", @@ -14342,7 +14414,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", - "dev": true, "requires": { "buffer-equal-constant-time": "1.0.1", "ecdsa-sig-formatter": "1.0.11", @@ -14353,7 +14424,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", - "dev": true, "requires": { "jwa": "^2.0.0", "safe-buffer": "^5.0.1" @@ -14514,6 +14584,11 @@ "mime-db": "1.52.0" } }, + "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==" + }, "minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -14526,8 +14601,7 @@ "minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" }, "minipass": { "version": "7.1.2", @@ -15522,8 +15596,7 @@ "safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "secure-json-parse": { "version": "2.7.0", @@ -16284,6 +16357,18 @@ "sortablejs": "1.14.0" } }, + "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==", + "requires": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + } + }, "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index 672d4824..970e9d7e 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "mailgun.js": "^10.2.1", "p-queue": "^8.0.1", "pino": "^9.2.0", - "rate-limiter-flexible": "^5.0.3" + "rate-limiter-flexible": "^5.0.3", + "web-push": "^3.6.7" }, "devDependencies": { "@babel/cli": "^7.24.7", @@ -56,6 +57,7 @@ "@types/sha256": "^0.2.2", "@types/striptags": "^3.1.1", "@types/ua-parser-js": "^0.7.39", + "@types/web-push": "^3.6.4", "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^7.14.1", "@typescript-eslint/parser": "^7.14.1", diff --git a/vite.config.js b/vite.config.js index 8a6ca7fb..05f3a816 100644 --- a/vite.config.js +++ b/vite.config.js @@ -14,6 +14,7 @@ export default defineConfig({ '@': path.resolve(__dirname, './app/client/src'), }, }, + envPrefix: ['VAPID_PUBLIC_', 'VITE_'], server: { open: true, proxy: {