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.
173 lines
5.9 KiB
TypeScript
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" } });
|
|
}
|