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

173 lines
5.9 KiB
TypeScript

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 { addSubscription, removeSubscription } from "@/lib/push-store";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Push reminder subscriptions. GET reports whether reminders are available
* (VAPID configured) and hands out the public key; POST/DELETE manage this
* browser's subscription. The feature is hidden client-side when `enabled`
* is false — no half-configured UI ever shows.
*/
/**
* The server POSTs to the stored endpoint with VAPID headers, so it must be a
* real push service: an arbitrary URL here would be an SSRF primitive. The
* allowed hosts come from the config (PUSH_ALLOWED_HOSTS), defaulting to the
* real services.
*/
function isPushEndpoint(value: string, allowedHosts: string[]): boolean {
try {
const url = new URL(value);
if (url.protocol !== "https:") return false;
const host = url.hostname.toLowerCase();
return allowedHosts.some((entry) => (entry.startsWith(".") ? host.endsWith(entry) : host === entry));
} catch {
return false;
}
}
const subscriptionSchema = z.object({
endpoint: z.string().url().max(2000),
keys: z.object({
p256dh: z.string().min(16).max(400),
auth: z.string().min(8).max(200),
}),
/**
* The reminder text this browser wants to receive (composed client-side, see
* lib/reminder.ts). Only ever shown back to the user in their notification.
* `url` must be a path on this app, so a stolen subscription cannot turn the
* notification into a link somewhere else.
*/
message: z
.object({
title: z.string().min(1).max(150),
body: z.string().min(1).max(300),
url: z.string().max(300).regex(/^\//).optional(),
})
.optional(),
});
export async function GET(request: Request) {
const locale = requestLocale(request);
const crossOrigin = rejectCrossOrigin(request);
if (crossOrigin) return crossOrigin;
const config = getServerConfig();
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 },
);
}
const enabled = Boolean(config.push.publicKey && config.push.privateKey);
return Response.json(
{ enabled, publicKey: enabled ? config.push.publicKey : undefined },
{ headers: { "Cache-Control": "no-store" } },
);
}
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, 64 * 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 = subscriptionSchema.safeParse(payload);
if (!parsed.success) {
return Response.json(
{
error: translate(locale, "server.invalidRequest", {
detail: parsed.error.issues
.slice(0, 3)
.map((issue) => `${issue.path.join(".") || "body"} ${issue.message}`)
.join("; "),
}),
},
{ status: 400 },
);
}
if (!isPushEndpoint(parsed.data.endpoint, config.push.allowedHosts)) {
return Response.json(
{ error: translate(locale, "server.pushEndpoint"), kind: "bad_endpoint" },
{ status: 400 },
);
}
const count = await addSubscription(config.push.dir, parsed.data, config.push.maxSubscriptions);
if (!count.accepted) {
return Response.json(
{ error: translate(locale, "server.pushTooMany", { max: config.push.maxSubscriptions }), kind: "too_many" },
{ status: 429 },
);
}
return Response.json({ ok: true, count: count.count }, { headers: { "Cache-Control": "no-store" } });
}
export async function DELETE(request: Request) {
const locale = requestLocale(request);
const crossOrigin = rejectCrossOrigin(request);
if (crossOrigin) return crossOrigin;
const config = getServerConfig();
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 },
);
}
const endpoint = new URL(request.url).searchParams.get("endpoint") ?? "";
if (!endpoint) {
return Response.json(
{ error: translate(locale, "server.invalidRequest", { detail: "endpoint" }) },
{ status: 400 },
);
}
const count = await removeSubscription(config.push.dir, endpoint);
return Response.json({ ok: true, count }, { headers: { "Cache-Control": "no-store" } });
}