freezmo/lib/push-store.ts
Martin Dvorak 77819b5961 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.
2026-10-18 14:34:15 +00:00

84 lines
3.4 KiB
TypeScript

import { promises as fs } from "node:fs";
import path from "node:path";
/**
* 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;
};
function fileFor(dir: string): string {
return path.join(dir, "subscriptions.json");
}
export async function readSubscriptions(dir: string): Promise<PushSubscriptionRecord[]> {
try {
const raw = await fs.readFile(fileFor(dir), "utf8");
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(entry): entry is PushSubscriptionRecord =>
Boolean(entry) &&
typeof (entry as PushSubscriptionRecord).endpoint === "string" &&
typeof (entry as PushSubscriptionRecord).keys?.p256dh === "string" &&
typeof (entry as PushSubscriptionRecord).keys?.auth === "string",
);
} catch {
return [];
}
}
async function writeSubscriptions(dir: string, next: PushSubscriptionRecord[]): Promise<void> {
await fs.mkdir(dir, { recursive: true });
const file = fileFor(dir);
const temporary = `${file}.${crypto.randomUUID()}.tmp`;
await fs.writeFile(temporary, JSON.stringify(next, null, 2), "utf8");
await fs.rename(temporary, file);
}
/** Adds or refreshes a subscription (deduped by endpoint). */
export async function addSubscription(
dir: 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 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 (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 };
}
export async function removeSubscription(dir: string, endpoint: string): Promise<number> {
const current = await readSubscriptions(dir);
const next = current.filter((entry) => entry.endpoint !== endpoint);
await writeSubscriptions(dir, next);
return next.length;
}