Webpush notifications (#6421)

Co-authored-by: Frank Elsinga <frank@elsinga.de>
This commit is contained in:
cmorg789 2025-11-26 22:55:46 -05:00 committed by GitHub
parent 892bd42dae
commit 23c4916c74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 3354 additions and 37 deletions

View File

@ -2,6 +2,7 @@ import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import visualizer from "rollup-plugin-visualizer"; import visualizer from "rollup-plugin-visualizer";
import viteCompression from "vite-plugin-compression"; import viteCompression from "vite-plugin-compression";
import { VitePWA } from "vite-plugin-pwa";
const postCssScss = require("postcss-scss"); const postCssScss = require("postcss-scss");
const postcssRTLCSS = require("postcss-rtlcss"); const postcssRTLCSS = require("postcss-rtlcss");
@ -30,6 +31,12 @@ export default defineConfig({
algorithm: "brotliCompress", algorithm: "brotliCompress",
filter: viteCompressionFilter, filter: viteCompressionFilter,
}), }),
VitePWA({
registerType: null,
srcDir: "src",
filename: "serviceWorker.ts",
strategies: "injectManifest",
}),
], ],
css: { css: {
postcss: { postcss: {

3170
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -147,6 +147,7 @@
"tcp-ping": "~0.1.1", "tcp-ping": "~0.1.1",
"thirty-two": "~1.0.2", "thirty-two": "~1.0.2",
"tough-cookie": "~4.1.3", "tough-cookie": "~4.1.3",
"web-push": "^3.6.7",
"ws": "^8.13.0" "ws": "^8.13.0"
}, },
"devDependencies": { "devDependencies": {
@ -161,6 +162,7 @@
"@testcontainers/rabbitmq": "^10.13.2", "@testcontainers/rabbitmq": "^10.13.2",
"@types/bootstrap": "~5.1.9", "@types/bootstrap": "~5.1.9",
"@types/node": "^20.8.6", "@types/node": "^20.8.6",
"@types/web-push": "^3.6.4",
"@typescript-eslint/eslint-plugin": "^6.7.5", "@typescript-eslint/eslint-plugin": "^6.7.5",
"@typescript-eslint/parser": "^6.7.5", "@typescript-eslint/parser": "^6.7.5",
"@vitejs/plugin-vue": "~5.0.1", "@vitejs/plugin-vue": "~5.0.1",
@ -199,6 +201,7 @@
"v-pagination-3": "~0.1.7", "v-pagination-3": "~0.1.7",
"vite": "~5.4.15", "vite": "~5.4.15",
"vite-plugin-compression": "^0.5.1", "vite-plugin-compression": "^0.5.1",
"vite-plugin-pwa": "^1.1.0",
"vue": "~3.4.2", "vue": "~3.4.2",
"vue-chartjs": "~5.2.0", "vue-chartjs": "~5.2.0",
"vue-confirm-dialog": "~1.0.2", "vue-confirm-dialog": "~1.0.2",

View File

@ -0,0 +1,47 @@
const NotificationProvider = require("./notification-provider");
const { UP } = require("../../src/util");
const webpush = require("web-push");
const { setting } = require("../util-server");
class Webpush extends NotificationProvider {
name = "Webpush";
/**
* @inheritDoc
*/
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
const okMsg = "Sent Successfully.";
try {
const publicVapidKey = await setting("webpushPublicVapidKey");
const privateVapidKey = await setting("webpushPrivateVapidKey");
webpush.setVapidDetails("https://github.com/louislam/uptime-kuma", publicVapidKey, privateVapidKey);
if (heartbeatJSON === null && monitorJSON === null) {
// Test message
const data = JSON.stringify({
title: "TEST",
body: `Test Alert - ${msg}`
});
await webpush.sendNotification(notification.subscription, data);
return okMsg;
}
const data = JSON.stringify({
title: heartbeatJSON["status"] === UP ? "Monitor Up" : "Monitor DOWN",
body: heartbeatJSON["status"] === UP ? `${heartbeatJSON["name"]} is DOWN` : `${heartbeatJSON["name"]} is UP`
});
await webpush.sendNotification(notification.subscription, data);
return okMsg;
} catch (error) {
this.throwGeneralAxiosError(error);
}
}
}
module.exports = Webpush;

View File

@ -83,6 +83,7 @@ const SMSPlanet = require("./notification-providers/sms-planet");
const SpugPush = require("./notification-providers/spugpush"); const SpugPush = require("./notification-providers/spugpush");
const SMSIR = require("./notification-providers/smsir"); const SMSIR = require("./notification-providers/smsir");
const { commandExists } = require("./util-server"); const { commandExists } = require("./util-server");
const Webpush = require("./notification-providers/Webpush");
class Notification { class Notification {
providerList = {}; providerList = {};
@ -174,13 +175,14 @@ class Notification {
new GtxMessaging(), new GtxMessaging(),
new Cellsynt(), new Cellsynt(),
new Wpush(), new Wpush(),
new SendGrid(),
new Brevo(), new Brevo(),
new YZJ(), new YZJ(),
new SMSPlanet(), new SMSPlanet(),
new SpugPush(), new SpugPush(),
new Notifery(), new Notifery(),
new SMSIR(), new SMSIR(),
new SendGrid(),
new Webpush(),
]; ];
for (let item of list) { for (let item of list) {
if (!item.name) { if (!item.name) {

View File

@ -96,6 +96,8 @@ const { getSettings, setSettings, setting, initJWTSecret, checkLogin, doubleChec
log.debug("server", "Importing Notification"); log.debug("server", "Importing Notification");
const { Notification } = require("./notification"); const { Notification } = require("./notification");
Notification.init(); Notification.init();
log.debug("server", "Importing Web-Push");
const webpush = require("web-push");
log.debug("server", "Importing Database"); log.debug("server", "Importing Database");
const Database = require("./database"); const Database = require("./database");
@ -1563,6 +1565,32 @@ let needSetup = false;
} }
}); });
socket.on("getWebpushVapidPublicKey", async (callback) => {
try {
let publicVapidKey = await Settings.get("webpushPublicVapidKey");
if (!publicVapidKey) {
log.debug("webpush", "Generating new VAPID keys");
const vapidKeys = webpush.generateVAPIDKeys();
await Settings.set("webpushPublicVapidKey", vapidKeys.publicKey);
await Settings.set("webpushPrivateVapidKey", vapidKeys.privateKey);
publicVapidKey = vapidKeys.publicKey;
}
callback({
ok: true,
msg: publicVapidKey,
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("clearEvents", async (monitorID, callback) => { socket.on("clearEvents", async (monitorID, callback) => {
try { try {
checkLogin(socket); checkLogin(socket);

View File

@ -173,7 +173,8 @@ export default {
"Cellsynt": "Cellsynt", "Cellsynt": "Cellsynt",
"SendGrid": "SendGrid", "SendGrid": "SendGrid",
"Brevo": "Brevo", "Brevo": "Brevo",
"notifery": "Notifery" "notifery": "Notifery",
"Webpush": "Webpush",
}; };
// Put notifications here if it's not supported in most regions or its documentation is not in English // Put notifications here if it's not supported in most regions or its documentation is not in English

View File

@ -0,0 +1,97 @@
<template>
<button
class="mb-3"
type="button" :class="[
'btn',
browserSupportsServiceWorkers ? 'btn-primary' : 'btn-danger'
]"
:disabled="!btnEnabled"
@click="registerWebpush"
>
<div v-if="processing" class="spinner-border spinner-border-sm me-1"></div>
<span v-else-if="$parent.notification.subscription" class="me-1"></span>
{{ btnText }}
</button>
<div class="form-text">
{{ $t("Webpush Helptext") }}
</div>
</template>
<script>
export default {
data() {
return {
btnEnabled: false,
btnText: "",
processing: false,
browserSupportsServiceWorkers: false,
publicVapidKey: null,
};
},
mounted() {
if (this.$parent.notification.subscription) {
this.btnEnabled = false;
this.browserSupportsServiceWorkers = true;
this.btnText = this.$t("Notifications Enabled");
} else {
if (("serviceWorker" in navigator)) {
this.btnText = this.$t("Allow Notifications");
this.browserSupportsServiceWorkers = true;
this.btnEnabled = true;
} else {
this.btnText = this.$t("Browser not supported");
this.browserSupportsServiceWorkers = false;
this.btnEnabled = false;
}
}
},
methods: {
async registerWebpush() {
this.processing = true;
try {
const publicKey = await new Promise((resolve, reject) => {
this.$root.getSocket().emit("getWebpushVapidPublicKey", (resp) => {
if (!resp.ok) {
reject(new Error(resp.msg));
}
console.log(resp.msg);
resolve(resp.msg);
});
});
const permission = await Notification.requestPermission();
if (permission !== "granted") {
this.$root.toastRes({
ok: false,
msg: this.$t("Unable to get permission to notify"),
});
this.processing = false;
return;
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: publicKey,
});
this.$parent.notification.subscription = subscription;
this.btnEnabled = false;
this.browserSupportsServiceWorkers = true;
this.btnText = this.$t("Notifications Enabled");
} catch (error) {
console.error("Subscription failed:", error);
this.$root.toastRes({
ok: false,
msg: error
});
} finally {
this.processing = false;
}
}
},
};
</script>

View File

@ -80,6 +80,7 @@ import Brevo from "./Brevo.vue";
import YZJ from "./YZJ.vue"; import YZJ from "./YZJ.vue";
import SMSPlanet from "./SMSPlanet.vue"; import SMSPlanet from "./SMSPlanet.vue";
import SMSIR from "./SMSIR.vue"; import SMSIR from "./SMSIR.vue";
import Webpush from "./Webpush.vue";
/** /**
* Manage all notification form. * Manage all notification form.
@ -168,6 +169,7 @@ const NotificationFormList = {
"Brevo": Brevo, "Brevo": Brevo,
"YZJ": YZJ, "YZJ": YZJ,
"SMSPlanet": SMSPlanet, "SMSPlanet": SMSPlanet,
"Webpush": Webpush,
}; };
export default NotificationFormList; export default NotificationFormList;

View File

@ -1191,5 +1191,10 @@
"Maximum Retries": "Maximum Retries", "Maximum Retries": "Maximum Retries",
"Template ID": "Template ID", "Template ID": "Template ID",
"wayToGetClickSMSIRTemplateID": "Your template must contain an {uptkumaalert} field. You can create a new template {here}.", "wayToGetClickSMSIRTemplateID": "Your template must contain an {uptkumaalert} field. You can create a new template {here}.",
"Recipient Numbers": "Recipient Numbers" "Recipient Numbers": "Recipient Numbers",
"Notifications Enabled": "Notifications Enabled",
"Allow Notifications": "Allow Notifications",
"Browser not supported": "Browser not supported",
"Unable to get permission to notify": "Unable to get permission to notify (request either denied or ignored).",
"Webpush Helptext": "Web push only works with SSL (HTTPS) connections. For iOS devices, webpage must be added to homescreen beforehand."
} }

23
src/serviceWorker.ts Normal file
View File

@ -0,0 +1,23 @@
// Needed per Vite PWA docs
import { precacheAndRoute } from 'workbox-precaching'
declare let self: ServiceWorkerGlobalScope
precacheAndRoute(self.__WB_MANIFEST)
// Receive push notifications
self.addEventListener('push', function (event) {
if (self.Notification?.permission !== 'granted') {
console.error("Notifications aren't supported or permission not granted!");
return;
}
if (event.data) {
let message = event.data.json();
try {
self.registration.showNotification(message.title, {
body: message.body,
});
} catch (error) {
console.error('Failed to show notification:', error);
}
}
});