Push reminders: personal to the notes, generic without them

The daily reminder said one generic line to everyone, because the server
never sees a deck. Now the notification text is written in the browser
(lib/reminder.ts) and travels with the subscription:

- Personal: "3 cards are due in Sky deck", in the app's language, tapping
  through to /review. Without notes (or with nothing due) it is the plain
  nudge, translated in both languages — push.title / push.due.* / push.generic.
- POST /api/push accepts an optional message {title, body, url}; url must be
  a path on this app, so a stolen subscription cannot make the notification
  link elsewhere. The store keeps endpoint, keys, the message and createdAt
  (a refresh no longer resets the timestamp).
- components/PushReminderSync.tsx refreshes the stored text when the app
  opens and whenever the tab becomes visible (throttled to twice a day), so a
  count is only ever sent while it is true.
- scripts/send-reminders.mjs sends each device its own text and falls back to
  PUSH_REMINDER_TITLE/_BODY for a subscription that has none
  (scripts/reminder-payload.mjs, unit-checked).
- /api/push/test previews the real reminder with a "Test:" prefix.
- deploy/freezmo-reminders.{service,timer}: the daily send as a systemd user
  timer (18:00 local, Persistent, randomised), documented in the README.
- Settings copy, privacy page (en+cs) and .env.example say exactly what the
  server now holds.
- phase2-test grew the whole path with a faked browser subscription: personal
  text in en and cs, the generic fallback in both, the URL guard, and the
  sender's preference order (81 checks).

Version 0.8.2, SW v104.
This commit is contained in:
Martin Dvořák 2026-10-18 14:34:15 +00:00
commit 77819b5961
18 changed files with 498 additions and 49 deletions

View file

@ -2,14 +2,22 @@ import { promises as fs } from "node:fs";
import path from "node:path";
/**
* Server-side push subscriptions: one JSON file with endpoint + keys and a
* creation timestamp. No personal data, no history, no user ids — and the
* whole feature stays hidden unless VAPID keys are configured.
* Server-side push subscriptions: one JSON file with endpoint + keys, the
* reminder text this browser asked for, and a creation timestamp. No user ids,
* no history, no note copy — the reminder text is what the user's own browser
* composed for the notification (it may name one of their decks), and it is
* sent back to them unchanged. The whole feature stays hidden unless VAPID
* keys are configured.
*/
/** What the notification will say; composed in the browser (lib/reminder.ts). */
export type PushReminderMessage = { title: string; body: string; url?: string };
export type PushSubscriptionRecord = {
endpoint: string;
keys: { p256dh: string; auth: string };
/** Absent for a subscription stored before reminders carried a message. */
message?: PushReminderMessage;
createdAt: number;
};
@ -33,7 +41,6 @@ export async function readSubscriptions(dir: string): Promise<PushSubscriptionRe
return [];
}
}
async function writeSubscriptions(dir: string, next: PushSubscriptionRecord[]): Promise<void> {
await fs.mkdir(dir, { recursive: true });
const file = fileFor(dir);
@ -45,17 +52,25 @@ async function writeSubscriptions(dir: string, next: PushSubscriptionRecord[]):
/** Adds or refreshes a subscription (deduped by endpoint). */
export async function addSubscription(
dir: string,
subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
subscription: { endpoint: string; keys: { p256dh: string; auth: string }; message?: PushReminderMessage },
/** Cap on stored subscriptions — the configured, sane one. */
max = 50,
): Promise<{ count: number; accepted: boolean }> {
const current = await readSubscriptions(dir);
const exists = current.some((entry) => entry.endpoint === subscription.endpoint);
const previous = current.find((entry) => entry.endpoint === subscription.endpoint);
const next = current.filter((entry) => entry.endpoint !== subscription.endpoint);
// Refreshing a device that is already subscribed always works; a brand new
// device over the cap is refused.
if (!exists && next.length >= max) return { count: current.length, accepted: false };
next.push({ ...subscription, createdAt: Date.now() });
if (previous === undefined && next.length >= max) return { count: current.length, accepted: false };
/* A refresh may carry a newer message; without one the device keeps the
reminder it already had, and it keeps its original timestamp. */
const message = subscription.message ?? previous?.message;
next.push({
endpoint: subscription.endpoint,
keys: subscription.keys,
...(message ? { message } : {}),
createdAt: previous?.createdAt ?? Date.now(),
});
const capped = next.slice(-max);
await writeSubscriptions(dir, capped);
return { count: capped.length, accepted: true };