source: Klonkt/src/services/PushService.js

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

Guardian-queues als C2S-routes + push-typen help/guardian

De drie shaer:queues-collecties uit het actor-doc zijn nu echte owner-only
routes (Bearer, zelfde patroon als /blocked): offers, follows (leeg tot
gated follows bestaan) en wards. Contract gelijk aan de Shaer test-daemon,
dus de iOS/Android-dashboards lezen ze ongewijzigd.

Web-push kent twee nieuwe alert-typen: 'help' (hulpvraag van een ward,
niet gethrottled, standaard aan) en 'guardian' (adoptieverkeer). Teksten in
nl/en/de.

Changed files:
src/routes/activitypub.js

  • GET /ap/users/:slug/queues/{offers,follows,wards}, owner-only

src/services/PushService.js

  • alert-typen help + guardian (defaults aan, throttle 0/30s)

src/services/i18n.js

  • push.n_help_*, push.n_guard_* in nl/en/de

New file:
test/guardianship.test.js

  • pint het module-oppervlak: actor-props, handshake C2S+S2S beide kanten, queue-shapes, ward-mag-niet-guarden, helpRequest-props

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

  • Property mode set to 100644
File size: 7.8 KB
Line 
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
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.
72export const DEFAULT_ALERTS = { follow: 1, reply: 1, like: 0, boost: 0, dm: 1, help: 1, guardian: 1 };
73
74export 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
86export function deleteSubscription(endpoint) {
87 return db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint).changes > 0;
88}
89
90export 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
94export 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).
103async 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.
126const THROTTLE_SECONDS = { follow: 60, reply: 30, dm: 30, like: 300, boost: 300, test: 0, help: 0, guardian: 30 };
127const _lastPush = new Map();
128export 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.
140export 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).
157export 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
163export default {
164 publicKey, pushReady, DEFAULT_ALERTS, throttled,
165 saveSubscription, deleteSubscription, listSubscriptions, updateAlerts,
166 notifyUser, notifySite,
167};
Note: See TracBrowser for help on using the repository browser.