source: Klonkt/src/services/PushService.js@ 96c714f

main
Last change on this file since 96c714f was 96c714f, checked in by Robin <roboburr@…>, 7 weeks ago

Feature: web push slice 4, burst throttle + docs

  • Burst throttle: a wave of likes or a mass-follow becomes one ping, not a wave of pushes. Per (user, type) at most one push per window (like/boost 300s, follow 60s, reply/dm 30s, test never throttled); extras drop silently — the events themselves still land in Berichten, only the ping is deduped. In-memory (one process; a restart costs at most one extra ping). Pure throttled() exported and pinned by test.
  • README: push notifications feature bullet, VAPID_* in the config table, storage/.vapid in the auto-generated-secrets + backup section (restoring without it silently breaks every subscription).
  • .env.example: VAPID block in the SESSION_SECRET/PAID_SECRET style.

Pruning (404/410 → row deleted) and the iOS install hint already landed in
slices 1-2; this closes the plan from docs/webpush-design.md.

Changed files:
src/services/PushService.js

  • throttled() + window table; notifyUser checks it first

test/push.test.js

  • throttle windows, per-type/per-user independence, test bypass

README.md

  • feature bullet, VAPID config row, backup warning

.env.example

  • VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 7.5 KB
RevLine 
[ad10715]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.
5import crypto from 'crypto';
6import fs from 'fs';
7import path from 'path';
8import { fileURLToPath } from 'url';
9import db from '../config/database.js';
10
11const __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).
15let _lib = null;
16async 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
26function 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.
33function 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
41let _keys = null;
42async 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.
62export async function publicKey() {
63 try { return (await vapidKeys()).publicKey; } catch { return null; }
64}
65
66export async function pushReady() { return (await publicKey()) !== null; }
67
68// ── Subscriptions ───────────────────────────────────────────────────
69
70export const DEFAULT_ALERTS = { follow: 1, reply: 1, like: 0, boost: 0, dm: 1 };
71
72export function saveSubscription({ endpoint, userId, p256dh, auth, alertTypes, uaLabel }) {
73 if (!endpoint || !userId || !p256dh || !auth) return false;
74 const alerts = JSON.stringify({ ...DEFAULT_ALERTS, ...(alertTypes || {}) });
75 db.prepare(`INSERT INTO push_subscriptions (endpoint, user_id, p256dh, auth, alert_types, ua_label, created_at)
76 VALUES (?,?,?,?,?,?,CURRENT_TIMESTAMP)
77 ON CONFLICT(endpoint) DO UPDATE SET
78 user_id=excluded.user_id, p256dh=excluded.p256dh, auth=excluded.auth,
79 alert_types=excluded.alert_types, ua_label=excluded.ua_label`)
80 .run(endpoint, userId, p256dh, auth, alerts, uaLabel || null);
81 return true;
82}
83
84export function deleteSubscription(endpoint) {
85 return db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint).changes > 0;
86}
87
88export function listSubscriptions(userId) {
89 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);
90}
91
92export function updateAlerts(endpoint, userId, alertTypes) {
93 const alerts = JSON.stringify({ ...DEFAULT_ALERTS, ...(alertTypes || {}) });
94 return db.prepare('UPDATE push_subscriptions SET alert_types = ? WHERE endpoint = ? AND user_id = ?').run(alerts, endpoint, userId).changes > 0;
95}
96
97// ── Sending ─────────────────────────────────────────────────────────
98
99// Send one payload to one stored subscription row. 404/410 → the device is
100// gone or permission was revoked → delete the row (self-pruning).
101async function sendTo(row, payload) {
102 const wp = await lib();
103 const keys = await vapidKeys();
104 wp.setVapidDetails(subject(), keys.publicKey, keys.privateKey);
105 try {
106 await wp.sendNotification(
107 { endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } },
108 JSON.stringify(payload),
109 { TTL: 3600 },
110 );
111 db.prepare('UPDATE push_subscriptions SET last_ok_at = CURRENT_TIMESTAMP WHERE endpoint = ?').run(row.endpoint);
112 return true;
113 } catch (e) {
114 if (e && (e.statusCode === 404 || e.statusCode === 410)) deleteSubscription(row.endpoint);
115 else console.warn('[push] send failed:', e && (e.statusCode || e.message));
116 return false;
117 }
118}
119
[96c714f]120// Burst throttle: a wave of likes or a mass-follow must not become a wave of
121// pushes. Per (user, type) at most one push per window; extras drop silently
122// (the events themselves are still in Berichten — only the ping is deduped).
123// In-memory is fine: one process, and a restart just means one extra ping.
124const THROTTLE_SECONDS = { follow: 60, reply: 30, dm: 30, like: 300, boost: 300, test: 0 };
125const _lastPush = new Map();
126export function throttled(userId, type, nowSeconds = Math.floor(Date.now() / 1000)) {
127 const windowS = THROTTLE_SECONDS[type] ?? 60;
128 if (!windowS) return false;
129 const key = `${userId}:${type}`;
130 const prev = _lastPush.get(key) || 0;
131 if (nowSeconds - prev < windowS) return true;
132 _lastPush.set(key, nowSeconds);
133 return false;
134}
135
[ad10715]136// Notify one user on all their devices, honouring per-type preferences.
137// type ∈ {follow, reply, like, boost, dm, test}. Fire-and-forget at call sites.
138export async function notifyUser(userId, { type, title, body, url }) {
139 if (!(await pushReady())) return 0;
[96c714f]140 if (throttled(userId, type)) return 0;
[ad10715]141 const rows = db.prepare('SELECT * FROM push_subscriptions WHERE user_id = ?').all(userId);
142 let sent = 0;
143 for (const row of rows) {
144 if (type !== 'test') {
145 let alerts = DEFAULT_ALERTS;
146 try { alerts = { ...DEFAULT_ALERTS, ...JSON.parse(row.alert_types || '{}') }; } catch { /* keep defaults */ }
147 if (!alerts[type]) continue;
148 }
149 if (await sendTo(row, { type, title: String(title || '').slice(0, 120), body: String(body || '').slice(0, 240), url: url || '/' })) sent++;
150 }
151 return sent;
152}
153
154// Notify the owner of a site (the usual entry point from the S2S inbox).
155export async function notifySite(slug, event) {
156 const row = db.prepare('SELECT owner_id FROM sites WHERE slug = ?').get(slug);
157 if (!row || !row.owner_id) return 0;
158 return notifyUser(row.owner_id, event);
159}
160
161export default {
[96c714f]162 publicKey, pushReady, DEFAULT_ALERTS, throttled,
[ad10715]163 saveSubscription, deleteSubscription, listSubscriptions, updateAlerts,
164 notifyUser, notifySite,
165};
Note: See TracBrowser for help on using the repository browser.