Changeset 96c714f in Klonkt


Ignore:
Timestamp:
07/22/2026 07:12:11 AM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
9a00f28
Parents:
8240e80
git-author:
Robin <roboburr@…> (07/22/2026 07:11:54 AM)
git-committer:
Robin <roboburr@…> (07/22/2026 07:12:11 AM)
Message:

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@…>

Files:
4 edited

Legend:

Unmodified
Added
Removed
  • .env.example

    r8240e80 r96c714f  
    1515# so a database dump alone stays useless). Or set your own: openssl rand -base64 32
    1616PAID_SECRET=
     17# Web-push (notificaties) VAPID keys. Leave EMPTY to auto-generate on first use
     18# (saved to storage/.vapid). Do NOT rotate: new keys break every subscription.
     19VAPID_PUBLIC_KEY=
     20VAPID_PRIVATE_KEY=
     21VAPID_SUBJECT=
    1722DATABASE_PATH=./storage/database.sqlite
    1823MEDIA_PATH=./storage/media
  • README.md

    r8240e80 r96c714f  
    2222  sites, Mastodon, PeerTube — any fediverse server) and their public posts appear in your
    2323  Circle. Decentralized, no central platform — built on ActivityPub.
     24- **Push notifications** — a browser/PWA notification for new followers, replies,
     25  mentions, likes, boosts and private messages, even with the site closed.
     26  Self-hosted Web Push (VAPID): payloads are encrypted end-to-end to your
     27  browser, and private messages never carry their text in the push.
    2428- **Themes & languages** — multiple palettes (light + dark), interface in EN/NL/DE.
    2529- **Installable (PWA)**, **privacy-first** (self-hosted fonts, no tracking).
     
    168172| `PUBLIC_BASE_URL` | ✅ | Canonical URL (e.g. `https://yourdomain.com`) |
    169173| `PAID_SECRET` | auto | Encrypts the stored Patreon secrets for **paid posts**. Leave empty to auto-generate on first use (`storage/.paid-secret`), or set your own ≥16-char string. |
     174| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT` | auto | Identify this server to browser push services (**push notifications**). Leave empty to auto-generate on first use (`storage/.vapid`). |
    170175| `SMTP_HOST` / `_PORT` / `_USER` / `_PASS` / `_FROM` | — | Email for password reset + newsletter |
    171176| `KLONKT_DEFAULT_LANG` | — | Default language for visitors (`en`/`nl`/`de`) |
     
    182187- `PAID_SECRET` → `storage/.paid-secret` (encrypts the stored Patreon secrets for
    183188  paid posts)
     189- `VAPID_*` → `storage/.vapid` (identifies this server to browser push services;
     190  regenerating it would silently break every existing push subscription)
    184191
    185192The paid-posts key lives **outside** the database on purpose: encrypting the
     
    189196**Back up the whole `storage/` directory** (database, media *and* these key
    190197files). Restoring the database without `storage/.paid-secret` leaves the stored
    191 Patreon secrets unreadable — you'd have to reconnect Patreon.
     198Patreon secrets unreadable — you'd have to reconnect Patreon. Restoring without
     199`storage/.vapid` breaks push subscriptions — every device would have to re-enable
     200notifications.
    192201
    193202## Stack
  • src/services/PushService.js

    r8240e80 r96c714f  
    118118}
    119119
     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
    120136// Notify one user on all their devices, honouring per-type preferences.
    121137// type ∈ {follow, reply, like, boost, dm, test}. Fire-and-forget at call sites.
    122138export async function notifyUser(userId, { type, title, body, url }) {
    123139  if (!(await pushReady())) return 0;
     140  if (throttled(userId, type)) return 0;
    124141  const rows = db.prepare('SELECT * FROM push_subscriptions WHERE user_id = ?').all(userId);
    125142  let sent = 0;
     
    143160
    144161export default {
    145   publicKey, pushReady, DEFAULT_ALERTS,
     162  publicKey, pushReady, DEFAULT_ALERTS, throttled,
    146163  saveSubscription, deleteSubscription, listSubscriptions, updateAlerts,
    147164  notifyUser, notifySite,
  • test/push.test.js

    r8240e80 r96c714f  
    6565});
    6666
     67test('burst throttle: one ping per window per (user,type); test type never throttles', () => {
     68  assert.equal(Push.throttled('tu1', 'like', 1000), false);   // first passes
     69  assert.equal(Push.throttled('tu1', 'like', 1100), true);    // within 300s window
     70  assert.equal(Push.throttled('tu1', 'like', 1301), false);   // window elapsed
     71  assert.equal(Push.throttled('tu1', 'boost', 1000), false);  // other type independent
     72  assert.equal(Push.throttled('tu2', 'like', 1000), false);   // other user independent
     73  assert.equal(Push.throttled('tu1', 'test', 1000), false);   // test bypasses
     74  assert.equal(Push.throttled('tu1', 'test', 1001), false);
     75});
     76
    6777test('incomplete subscription payloads are refused', () => {
    6878  assert.equal(Push.saveSubscription({ endpoint: '', userId: 'u1', p256dh: 'x', auth: 'y' }), false);
Note: See TracChangeset for help on using the changeset viewer.