freezmo/app/api/push/test/route.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

93 lines
3.4 KiB
TypeScript

import webpush from "web-push";
import { z } from "zod";
import { authErrorKind, authErrorMessage, getServerConfig, resolveAuth } from "@/lib/config";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
import { translate } from "@/lib/i18n-dict";
import { readSubscriptions, removeSubscription } from "@/lib/push-store";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const bodySchema = z.object({ endpoint: z.string().url().max(2000) });
/** Sends one test notification to this browser's subscription. */
export async function POST(request: Request) {
const locale = requestLocale(request);
const crossOrigin = rejectCrossOrigin(request);
if (crossOrigin) return crossOrigin;
const config = getServerConfig();
const oversized = rejectOversizedBody(request, 16 * 1024);
if (oversized) return oversized;
const limit = rateLimit(`push:${clientKey(request)}`, config.rateLimits.push, config.rateLimits.windowMs);
if (!limit.allowed) return rateLimitResponse(limit, locale);
const auth = resolveAuth(config, request);
if ("error" in auth) {
return Response.json(
{ error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) },
{ status: 401 },
);
}
if (!config.push.publicKey || !config.push.privateKey) {
return Response.json(
{ error: translate(locale, "server.pushDisabled"), kind: "push_disabled" },
{ status: 404 },
);
}
let payload: unknown;
try {
payload = await request.json();
} catch {
return Response.json({ error: translate(locale, "server.bodyNotJson") }, { status: 400 });
}
const parsed = bodySchema.safeParse(payload);
if (!parsed.success) {
return Response.json({ error: translate(locale, "server.invalidRequest", { detail: "endpoint" }) }, { status: 400 });
}
const subscriptions = await readSubscriptions(config.push.dir);
const target = subscriptions.find((entry) => entry.endpoint === parsed.data.endpoint);
if (!target) {
return Response.json(
{ error: translate(locale, "server.pushNotSubscribed"), kind: "not_subscribed" },
{ status: 404 },
);
}
webpush.setVapidDetails(config.push.subject, config.push.publicKey, config.push.privateKey);
/* The test shows the reminder this device would actually get — the browser's
own text when it has one, the generic nudge otherwise — with a marker so a
test is never mistaken for the real thing. */
const message = target.message;
try {
await webpush.sendNotification(
{ endpoint: target.endpoint, keys: target.keys },
JSON.stringify({
title: `${translate(locale, "server.pushTestPrefix")} ${message?.title ?? translate(locale, "push.title")}`,
body: message?.body ?? translate(locale, "push.generic"),
url: message?.url ?? "/",
}),
);
} catch (error) {
const status = (error as { statusCode?: number }).statusCode;
if (status === 404 || status === 410) {
await removeSubscription(config.push.dir, target.endpoint);
return Response.json(
{ error: translate(locale, "server.pushGone"), kind: "gone" },
{ status: 410 },
);
}
return Response.json(
{ error: translate(locale, "server.pushSendFailed"), kind: "push_failed" },
{ status: 502 },
);
}
return Response.json({ ok: true }, { headers: { "Cache-Control": "no-store" } });
}