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:
parent
6036238bf0
commit
77819b5961
18 changed files with 498 additions and 49 deletions
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue