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.
84 lines
3.4 KiB
TypeScript
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;
|
|
}
|