Files
Lauri Ojansivu 0a5ae3e205 Browser push notifications, per device.
Member Settings / Notification Settings / "Browser notifications on this
device": the browser asks for permission and gives a push subscription,
and from then on every notification the member gets in WeKan - the same
recipients, watching and muted kinds as the tray and e-mail - also pops
up from that browser even with WeKan closed; a click opens the card. Turn
it on per browser and device, up to ten per account.

web-push 3.6.7 (pinned) sends it with this WeKan's own VAPID key pair,
made once and kept in a server-only collection; only the public key
leaves the server. A subscription's endpoint is chosen by the browser and
POSTed to by the server, so it is accepted only over https and only at
the push services browsers use - an internal address or another site is
refused when saved and again before every send. Gone subscriptions are
forgotten. The service worker opens only paths of this WeKan. Not
offered on Sandstorm.

models/lib/webPush.js decides; tests/webPush.test.cjs covers the push
services, internal and look-alike hosts, foreign click targets and the
bound; server/lib/tests/webPush.tests.js runs in Meteor (passing): one
key pair, only the public key out, an internal endpoint refused. With
the webhook activity picker (c3321926ab) and the all-boards activity
feed (656af74b2c), this completes both issues. The new strings are
English placeholders until the next translation pass.

Thanks to saurabharch, hingerl and xet7 !

Fixes #2026,
Fixes #1377.
2026-10-14 15:44:01 +03:00

58 lines
2.5 KiB
JavaScript

'use strict';
// #2026 / #1377: browser push notifications. A member turns them on per
// device in Member Settings / Notification Settings; the browser gives a
// push subscription, and every notification they get in WeKan is also
// pushed there, so it arrives even with WeKan closed.
//
// A subscription's endpoint is an address the BROWSER chose, which the server
// then POSTs to - so it is accepted only over https and only at the push
// services browsers use. Anything else (an internal address, another
// site) is refused when it is saved, so a member cannot make the server
// send requests where they like. Pure: tests/webPush.test.cjs.
const PUSH_SERVICE_HOSTS = Object.freeze([
'fcm.googleapis.com', // Chrome, Edge, Opera, Brave, Samsung
'updates.push.services.mozilla.com', // Firefox
'web.push.apple.com', // Safari
'notify.windows.com', // legacy Edge (*.notify.windows.com)
'push.services.mozilla.com',
]);
function isPushServiceEndpoint(endpoint) {
let u;
try { u = new URL(String(endpoint || '')); } catch (e) { return false; }
if (u.protocol !== 'https:' || u.username || u.password || (u.port && u.port !== '443')) return false;
const host = u.hostname.toLowerCase();
return PUSH_SERVICE_HOSTS.some(h => host === h || host.endsWith(`.${h}`));
}
const B64URL = /^[A-Za-z0-9_-]{8,200}$/;
// A subscription as the browser's PushSubscription.toJSON() gives it, or null.
function cleanSubscription(input) {
const s = input && typeof input === 'object' ? input : {};
const keys = s.keys && typeof s.keys === 'object' ? s.keys : {};
if (!isPushServiceEndpoint(s.endpoint) || String(s.endpoint).length > 1000) return null;
if (!B64URL.test(String(keys.p256dh || '')) || !B64URL.test(String(keys.auth || ''))) return null;
return { endpoint: String(s.endpoint), keys: { p256dh: String(keys.p256dh), auth: String(keys.auth) } };
}
const MAX_SUBSCRIPTIONS = 10;
// The member's subscriptions after adding one: once each, newest kept.
function subscriptionsAfterAdd(current, sub, now = new Date()) {
const list = (Array.isArray(current) ? current : []).filter(s => s && s.endpoint !== sub.endpoint);
return [...list, { ...sub, createdAt: now }].slice(-MAX_SUBSCRIPTIONS);
}
function pushPayload({ title, body, url }) {
return JSON.stringify({
title: String(title || 'WeKan').slice(0, 200),
body: String(body || '').slice(0, 1000),
url: /^\/[^/]|^\/$/.test(String(url || '')) ? String(url) : '/',
});
}
module.exports = { PUSH_SERVICE_HOSTS, isPushServiceEndpoint, cleanSubscription, subscriptionsAfterAdd, pushPayload, MAX_SUBSCRIPTIONS };