mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
650451bdf7
A fourth built-in channel next to email, webhook and ntfy. Browsers register per device, the server encrypts per RFC 8291 and signs per RFC 8292 with node:crypto and sends only to known push services through the SSRF guard. The event matrix decides per category what arrives by push; admins switch the channel on under Notifications. VAPID keys come from the environment or are generated once, only while neither stored row exists, and kept encrypted. A pair that does not decrypt or match is never overwritten and never swapped for another one: push stays off with an error in the log and every device stays registered until the key is fixed. The subject is VAPID_SUBJECT, then an https APP_URL, then the project URL. Only chat messages collapse into one notification per trip, and a replacement still alerts. A 403 removes a device only after three failures in a row; 404 and 410 remove it at once. Password change, reset and an admin setting a password drop the user's devices with the other credentials. The request log redacts subscriptions and endpoints, boot errors print *** for every secret variable, and logout forgets the device with a plain fetch so an expired session cannot redirect midway. Addresses discussion #894.
149 lines
5.7 KiB
JavaScript
149 lines
5.7 KiB
JavaScript
/*
|
|
* Web Push handlers for the generated service worker.
|
|
*
|
|
* vite-plugin-pwa builds sw.js with Workbox (generateSW), and Workbox pulls
|
|
* this file in through `workbox.importScripts` in vite.config.js, ahead of its
|
|
* own precaching. A CLASSIC, dependency-free script on purpose, in the style of
|
|
* theme-boot.js: importScripts cannot load a module, and switching the whole
|
|
* worker to injectManifest to get TypeScript here would mean rewriting every
|
|
* runtime caching rule by hand. The behaviour is pinned by
|
|
* tests/unit/pwa/swPush.test.ts, which runs this file against a fake worker.
|
|
*
|
|
* What arrives is the shared pushPayloadSchema: { title, body, url?, tag? },
|
|
* rendered on the server in the recipient's language, because a worker has no
|
|
* translations of its own. Only chat messages carry a tag, so one trip's chat
|
|
* collapses into one notification; everything else stands on its own.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
var FALLBACK_TITLE = 'TREK';
|
|
var ICON = '/icons/icon-192x192.png';
|
|
var SUBSCRIPTIONS_URL = '/api/notifications/push/subscriptions';
|
|
|
|
/** The payload as an object; a push that is not JSON still shows, with its text as the body. */
|
|
function readPayload(data) {
|
|
if (!data) return {};
|
|
try {
|
|
var parsed = data.json();
|
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
} catch (e) {
|
|
try {
|
|
return { body: data.text() };
|
|
} catch (e2) {
|
|
return {};
|
|
}
|
|
}
|
|
}
|
|
|
|
function text(value) {
|
|
return typeof value === 'string' ? value : '';
|
|
}
|
|
|
|
/**
|
|
* A path on this origin, or '/'. The url comes from the server, but a click
|
|
* must never be able to open another site in the installed app's window.
|
|
*/
|
|
function sameOriginPath(value) {
|
|
if (typeof value !== 'string' || !value) return '/';
|
|
try {
|
|
var resolved = new URL(value, self.location.origin);
|
|
if (resolved.origin !== self.location.origin) return '/';
|
|
return resolved.pathname + resolved.search + resolved.hash;
|
|
} catch (e) {
|
|
return '/';
|
|
}
|
|
}
|
|
|
|
self.addEventListener('push', function (event) {
|
|
var payload = readPayload(event.data);
|
|
var options = {
|
|
body: text(payload.body),
|
|
icon: ICON,
|
|
data: { url: sameOriginPath(payload.url) },
|
|
};
|
|
// A tag makes this notification replace an earlier one with the same tag
|
|
// (the server only sets one for chat, per trip). renotify goes with it, or
|
|
// the replacement would arrive without sound or vibration.
|
|
if (text(payload.tag)) {
|
|
options.tag = payload.tag;
|
|
options.renotify = true;
|
|
}
|
|
// Always show something. Chrome requires a visible notification for every
|
|
// push (userVisibleOnly), and WebKit revokes the subscription after a few
|
|
// silent ones, even while a TREK window has the focus.
|
|
event.waitUntil(self.registration.showNotification(text(payload.title) || FALLBACK_TITLE, options));
|
|
});
|
|
|
|
/** Focus a TREK window and take it to `path`, or open one there when none is left. */
|
|
function openPath(path) {
|
|
var target = self.location.origin + path;
|
|
return self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (windows) {
|
|
var client = windows[0];
|
|
for (var i = 0; i < windows.length; i++) {
|
|
if (windows[i].focused) client = windows[i];
|
|
}
|
|
if (!client) return self.clients.openWindow(target);
|
|
return client
|
|
.focus()
|
|
.then(function (focused) {
|
|
// navigate() is only allowed on a window this worker controls.
|
|
var win = focused || client;
|
|
return typeof win.navigate === 'function' ? win.navigate(target) : win;
|
|
})
|
|
.catch(function () {
|
|
return self.clients.openWindow(target);
|
|
});
|
|
});
|
|
}
|
|
|
|
self.addEventListener('notificationclick', function (event) {
|
|
event.notification.close();
|
|
var data = event.notification.data || {};
|
|
event.waitUntil(openPath(sameOriginPath(data.url)));
|
|
});
|
|
|
|
/**
|
|
* The browser replaced or dropped the subscription (Firefox does this now and
|
|
* then). Subscribe again with the same server key and tell the server. The
|
|
* same-origin fetch carries the session cookie; when the session is gone this
|
|
* fails, and the re-sync after the next sign-in is the backstop.
|
|
*/
|
|
function resubscribe(event) {
|
|
var old = event.oldSubscription;
|
|
var key = old && old.options ? old.options.applicationServerKey : null;
|
|
var next = event.newSubscription
|
|
? Promise.resolve(event.newSubscription)
|
|
: key
|
|
? self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key })
|
|
: Promise.resolve(null);
|
|
return next
|
|
.then(function (subscription) {
|
|
if (!subscription) return undefined;
|
|
return fetch(SUBSCRIPTIONS_URL, {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ subscription: subscription.toJSON() }),
|
|
}).then(function (response) {
|
|
// The old endpoint is dead either way; forgetting it only tidies the
|
|
// server's device list, and only once the new one is safely stored.
|
|
if (!response.ok || !old || old.endpoint === subscription.endpoint) return undefined;
|
|
return fetch(SUBSCRIPTIONS_URL, {
|
|
method: 'DELETE',
|
|
credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ endpoint: old.endpoint }),
|
|
});
|
|
});
|
|
})
|
|
.catch(function (err) {
|
|
console.warn('[push] could not renew the subscription', err);
|
|
});
|
|
}
|
|
|
|
self.addEventListener('pushsubscriptionchange', function (event) {
|
|
event.waitUntil(resubscribe(event));
|
|
});
|
|
})();
|