source: Klonkt/src/services/PushService.js@ ad10715

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

Feature: web push slice 1, VAPID keys + subscription store

The foundation for background notifications (docs/webpush-design.md).

  • Dependency (approved): web-push for RFC 8292 VAPID JWTs and RFC 8291 payload encryption. Lazy import so a canary that autofollows before npm ci never crashes on boot (same pattern as @simplewebauthn/server).
  • VAPID keys: env (VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY/VAPID_SUBJECT) wins, else auto-generated once into storage/.vapid (0600), never regenerated while the file exists: new keys would invalidate every subscription. Subject: PUBLIC_BASE_URL, else mailto from SMTP_FROM.
  • push_subscriptions table: one row per device, client keys for encrypted payloads, per-type alert preferences (follow/reply on, like/boost off, dm on by default), self-pruning on 404/410 in the send path.
  • notifyUser/notifySite: honour alert prefs, cap title/body length, fire-and-forget at call sites (slice 3 wires the triggers).

Changed files:
package.json, package-lock.json

  • web-push@3.6.7

src/config/database.js

  • push_subscriptions table (additive)

src/routes/posts.js

  • RESERVED_SLUGS: add 'push' (and the missing 'paid') so a post can't shadow the mounted routes

New file:
src/services/PushService.js

  • VAPID key resolve/persist, subscription CRUD, encrypted send with pruning, notifyUser/notifySite

test/push.test.js

  • key autogen (0600, persists, served=stored), subscription CRUD, upsert-not-duplicate, refuse incomplete payloads

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

  • Property mode set to 100644
File size: 6.7 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
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
120// Notify one user on all their devices, honouring per-type preferences.
121// type ∈ {follow, reply, like, boost, dm, test}. Fire-and-forget at call sites.
122export async function notifyUser(userId, { type, title, body, url }) {
123 if (!(await pushReady())) return 0;
124 const rows = db.prepare('SELECT * FROM push_subscriptions WHERE user_id = ?').all(userId);
125 let sent = 0;
126 for (const row of rows) {
127 if (type !== 'test') {
128 let alerts = DEFAULT_ALERTS;
129 try { alerts = { ...DEFAULT_ALERTS, ...JSON.parse(row.alert_types || '{}') }; } catch { /* keep defaults */ }
130 if (!alerts[type]) continue;
131 }
132 if (await sendTo(row, { type, title: String(title || '').slice(0, 120), body: String(body || '').slice(0, 240), url: url || '/' })) sent++;
133 }
134 return sent;
135}
136
137// Notify the owner of a site (the usual entry point from the S2S inbox).
138export async function notifySite(slug, event) {
139 const row = db.prepare('SELECT owner_id FROM sites WHERE slug = ?').get(slug);
140 if (!row || !row.owner_id) return 0;
141 return notifyUser(row.owner_id, event);
142}
143
144export default {
145 publicKey, pushReady, DEFAULT_ALERTS,
146 saveSubscription, deleteSubscription, listSubscriptions, updateAlerts,
147 notifyUser, notifySite,
148};
Note: See TracBrowser for help on using the repository browser.