| 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 | export const DEFAULT_ALERTS = { follow: 1, reply: 1, like: 0, boost: 0, dm: 1 };
|
|---|
| 71 |
|
|---|
| 72 | export 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 |
|
|---|
| 84 | export function deleteSubscription(endpoint) {
|
|---|
| 85 | return db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint).changes > 0;
|
|---|
| 86 | }
|
|---|
| 87 |
|
|---|
| 88 | export 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 |
|
|---|
| 92 | export 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).
|
|---|
| 101 | async 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.
|
|---|
| 122 | export 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).
|
|---|
| 138 | export 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 |
|
|---|
| 144 | export default {
|
|---|
| 145 | publicKey, pushReady, DEFAULT_ALERTS,
|
|---|
| 146 | saveSubscription, deleteSubscription, listSubscriptions, updateAlerts,
|
|---|
| 147 | notifyUser, notifySite,
|
|---|
| 148 | };
|
|---|