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" } }); }