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