| 1 | // Web Push (VAPID) — background notifications to the owner's browser/PWA
|
|---|
| 2 | // (docs/webpush-design.md). RFC 8030 delivery + RFC 8291 payload encryption via
|
|---|
| 3 | // the `web-push` dependency (approved); the push service only ever sees
|
|---|
| 4 | // ciphertext. No cookies anywhere: only enabling/disabling is a logged-in action.
|
|---|
| 5 | import crypto from 'crypto';
|
|---|
| 6 | import fs from 'fs';
|
|---|
| 7 | import path from 'path';
|
|---|
| 8 | import { fileURLToPath } from 'url';
|
|---|
| 9 | import db from '../config/database.js';
|
|---|
| 10 |
|
|---|
| 11 | const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|---|
| 12 |
|
|---|
| 13 | // Lazy so a not-yet-installed dependency can never crash app boot; only push
|
|---|
| 14 | // fails until `npm ci` has run (same pattern as @simplewebauthn/server).
|
|---|
| 15 | let _lib = null;
|
|---|
| 16 | async function lib() {
|
|---|
| 17 | if (!_lib) { const m = await import('web-push'); _lib = m.default || m; } // CJS: API on default
|
|---|
| 18 | return _lib;
|
|---|
| 19 | }
|
|---|
| 20 |
|
|---|
| 21 | // ── VAPID keys ──────────────────────────────────────────────────────
|
|---|
| 22 | // env wins; otherwise a persisted key file next to the database, generated on
|
|---|
| 23 | // first use. NEVER regenerated while the file exists: new keys invalidate every
|
|---|
| 24 | // existing subscription. Back up storage/ as a whole (README).
|
|---|
| 25 |
|
|---|
| 26 | function keyFilePath() {
|
|---|
| 27 | const dbPath = process.env.DATABASE_PATH || path.join(__dirname, '../../storage/database.sqlite');
|
|---|
| 28 | const dir = dbPath === ':memory:' ? path.join(__dirname, '../../storage') : path.dirname(dbPath);
|
|---|
| 29 | return path.join(dir, '.vapid');
|
|---|
| 30 | }
|
|---|
| 31 |
|
|---|
| 32 | // The VAPID subject: an https URL (PUBLIC_BASE_URL) or a mailto.
|
|---|
| 33 | function subject() {
|
|---|
| 34 | if (process.env.VAPID_SUBJECT) return process.env.VAPID_SUBJECT;
|
|---|
| 35 | const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
|
|---|
| 36 | if (/^https:\/\//.test(base)) return base;
|
|---|
| 37 | const from = (process.env.SMTP_FROM || '').replace(/^.*</, '').replace(/>.*$/, '').trim();
|
|---|
| 38 | return from.includes('@') ? `mailto:${from}` : 'mailto:webpush@invalid.local';
|
|---|
| 39 | }
|
|---|
| 40 |
|
|---|
| 41 | let _keys = null;
|
|---|
| 42 | async function vapidKeys() {
|
|---|
| 43 | if (_keys) return _keys;
|
|---|
| 44 | const envPub = process.env.VAPID_PUBLIC_KEY, envPriv = process.env.VAPID_PRIVATE_KEY;
|
|---|
| 45 | if (envPub && envPriv) { _keys = { publicKey: envPub, privateKey: envPriv }; return _keys; }
|
|---|
| 46 | const file = keyFilePath();
|
|---|
| 47 | try {
|
|---|
| 48 | const j = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|---|
| 49 | if (j && j.publicKey && j.privateKey) { _keys = j; return _keys; }
|
|---|
| 50 | } catch { /* not created yet */ }
|
|---|
| 51 | const { generateVAPIDKeys } = await lib();
|
|---|
| 52 | const fresh = generateVAPIDKeys();
|
|---|
| 53 | fs.mkdirSync(path.dirname(file), { recursive: true });
|
|---|
| 54 | fs.writeFileSync(file, JSON.stringify(fresh), { mode: 0o600 });
|
|---|
| 55 | try { fs.chmodSync(file, 0o600); } catch { /* non-POSIX fs */ }
|
|---|
| 56 | _keys = fresh;
|
|---|
| 57 | return _keys;
|
|---|
| 58 | }
|
|---|
| 59 |
|
|---|
| 60 | // The public key for the client (pushManager.subscribe). Null when the
|
|---|
| 61 | // dependency is missing or the key can't be persisted → feature stays gated.
|
|---|
| 62 | export async function publicKey() {
|
|---|
| 63 | try { return (await vapidKeys()).publicKey; } catch { return null; }
|
|---|
| 64 | }
|
|---|
| 65 |
|
|---|
| 66 | export async function pushReady() { return (await publicKey()) !== null; }
|
|---|
| 67 |
|
|---|
| 68 | // ── Subscriptions ───────────────────────────────────────────────────
|
|---|
| 69 |
|
|---|
| 70 | // help (a ward's call for help) and guardian (adoption handshake) serve the
|
|---|
| 71 | // Guardian PWA and default ON: a guardian must never miss a call for help.
|
|---|
| 72 | export const DEFAULT_ALERTS = { follow: 1, reply: 1, like: 0, boost: 0, dm: 1, help: 1, guardian: 1 };
|
|---|
| 73 |
|
|---|
| 74 | export function saveSubscription({ endpoint, userId, p256dh, auth, alertTypes, uaLabel }) {
|
|---|
| 75 | if (!endpoint || !userId || !p256dh || !auth) return false;
|
|---|
| 76 | const alerts = JSON.stringify({ ...DEFAULT_ALERTS, ...(alertTypes || {}) });
|
|---|
| 77 | db.prepare(`INSERT INTO push_subscriptions (endpoint, user_id, p256dh, auth, alert_types, ua_label, created_at)
|
|---|
| 78 | VALUES (?,?,?,?,?,?,CURRENT_TIMESTAMP)
|
|---|
| 79 | ON CONFLICT(endpoint) DO UPDATE SET
|
|---|
| 80 | user_id=excluded.user_id, p256dh=excluded.p256dh, auth=excluded.auth,
|
|---|
| 81 | alert_types=excluded.alert_types, ua_label=excluded.ua_label`)
|
|---|
| 82 | .run(endpoint, userId, p256dh, auth, alerts, uaLabel || null);
|
|---|
| 83 | return true;
|
|---|
| 84 | }
|
|---|
| 85 |
|
|---|
| 86 | export function deleteSubscription(endpoint) {
|
|---|
| 87 | return db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint).changes > 0;
|
|---|
| 88 | }
|
|---|
| 89 |
|
|---|
| 90 | export function listSubscriptions(userId) {
|
|---|
| 91 | return db.prepare('SELECT endpoint, alert_types, ua_label, created_at, last_ok_at FROM push_subscriptions WHERE user_id = ? ORDER BY created_at').all(userId);
|
|---|
| 92 | }
|
|---|
| 93 |
|
|---|
| 94 | export function updateAlerts(endpoint, userId, alertTypes) {
|
|---|
| 95 | const alerts = JSON.stringify({ ...DEFAULT_ALERTS, ...(alertTypes || {}) });
|
|---|
| 96 | return db.prepare('UPDATE push_subscriptions SET alert_types = ? WHERE endpoint = ? AND user_id = ?').run(alerts, endpoint, userId).changes > 0;
|
|---|
| 97 | }
|
|---|
| 98 |
|
|---|
| 99 | // ── Sending ─────────────────────────────────────────────────────────
|
|---|
| 100 |
|
|---|
| 101 | // Send one payload to one stored subscription row. 404/410 → the device is
|
|---|
| 102 | // gone or permission was revoked → delete the row (self-pruning).
|
|---|
| 103 | async function sendTo(row, payload) {
|
|---|
| 104 | const wp = await lib();
|
|---|
| 105 | const keys = await vapidKeys();
|
|---|
| 106 | wp.setVapidDetails(subject(), keys.publicKey, keys.privateKey);
|
|---|
| 107 | try {
|
|---|
| 108 | await wp.sendNotification(
|
|---|
| 109 | { endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } },
|
|---|
| 110 | JSON.stringify(payload),
|
|---|
| 111 | { TTL: 3600 },
|
|---|
| 112 | );
|
|---|
| 113 | db.prepare('UPDATE push_subscriptions SET last_ok_at = CURRENT_TIMESTAMP WHERE endpoint = ?').run(row.endpoint);
|
|---|
| 114 | return true;
|
|---|
| 115 | } catch (e) {
|
|---|
| 116 | if (e && (e.statusCode === 404 || e.statusCode === 410)) deleteSubscription(row.endpoint);
|
|---|
| 117 | else console.warn('[push] send failed:', e && (e.statusCode || e.message));
|
|---|
| 118 | return false;
|
|---|
| 119 | }
|
|---|
| 120 | }
|
|---|
| 121 |
|
|---|
| 122 | // Burst throttle: a wave of likes or a mass-follow must not become a wave of
|
|---|
| 123 | // pushes. Per (user, type) at most one push per window; extras drop silently
|
|---|
| 124 | // (the events themselves are still in Berichten — only the ping is deduped).
|
|---|
| 125 | // In-memory is fine: one process, and a restart just means one extra ping.
|
|---|
| 126 | const THROTTLE_SECONDS = { follow: 60, reply: 30, dm: 30, like: 300, boost: 300, test: 0, help: 0, guardian: 30 };
|
|---|
| 127 | const _lastPush = new Map();
|
|---|
| 128 | export function throttled(userId, type, nowSeconds = Math.floor(Date.now() / 1000)) {
|
|---|
| 129 | const windowS = THROTTLE_SECONDS[type] ?? 60;
|
|---|
| 130 | if (!windowS) return false;
|
|---|
| 131 | const key = `${userId}:${type}`;
|
|---|
| 132 | const prev = _lastPush.get(key) || 0;
|
|---|
| 133 | if (nowSeconds - prev < windowS) return true;
|
|---|
| 134 | _lastPush.set(key, nowSeconds);
|
|---|
| 135 | return false;
|
|---|
| 136 | }
|
|---|
| 137 |
|
|---|
| 138 | // Notify one user on all their devices, honouring per-type preferences.
|
|---|
| 139 | // type ∈ {follow, reply, like, boost, dm, help, guardian, test}. Fire-and-forget at call sites.
|
|---|
| 140 | export async function notifyUser(userId, { type, title, body, url }) {
|
|---|
| 141 | if (!(await pushReady())) return 0;
|
|---|
| 142 | if (throttled(userId, type)) return 0;
|
|---|
| 143 | const rows = db.prepare('SELECT * FROM push_subscriptions WHERE user_id = ?').all(userId);
|
|---|
| 144 | let sent = 0;
|
|---|
| 145 | for (const row of rows) {
|
|---|
| 146 | if (type !== 'test') {
|
|---|
| 147 | let alerts = DEFAULT_ALERTS;
|
|---|
| 148 | try { alerts = { ...DEFAULT_ALERTS, ...JSON.parse(row.alert_types || '{}') }; } catch { /* keep defaults */ }
|
|---|
| 149 | if (!alerts[type]) continue;
|
|---|
| 150 | }
|
|---|
| 151 | if (await sendTo(row, { type, title: String(title || '').slice(0, 120), body: String(body || '').slice(0, 240), url: url || '/' })) sent++;
|
|---|
| 152 | }
|
|---|
| 153 | return sent;
|
|---|
| 154 | }
|
|---|
| 155 |
|
|---|
| 156 | // Notify the owner of a site (the usual entry point from the S2S inbox).
|
|---|
| 157 | export async function notifySite(slug, event) {
|
|---|
| 158 | const row = db.prepare('SELECT owner_id FROM sites WHERE slug = ?').get(slug);
|
|---|
| 159 | if (!row || !row.owner_id) return 0;
|
|---|
| 160 | return notifyUser(row.owner_id, event);
|
|---|
| 161 | }
|
|---|
| 162 |
|
|---|
| 163 | export default {
|
|---|
| 164 | publicKey, pushReady, DEFAULT_ALERTS, throttled,
|
|---|
| 165 | saveSubscription, deleteSubscription, listSubscriptions, updateAlerts,
|
|---|
| 166 | notifyUser, notifySite,
|
|---|
| 167 | };
|
|---|