import { NextRequest } from "next/server"; import { verifyPaypalWebhook } from "@/lib/billing/paypal"; import { handlePaypalEvent } from "@/lib/billing/webhooks/paypal"; import { alreadyProcessed, logWebhook } from "@/lib/billing/webhook-log"; import { rateLimit, LIMITS } from "@/lib/ratelimit"; export const dynamic = "force-dynamic"; /** Best-effort client IP for anonymous rate limiting. */ function clientKey(req: NextRequest): string { const fwd = req.headers.get("x-forwarded-for"); if (fwd) return fwd.split(",")[0].trim(); return req.headers.get("x-real-ip") ?? "anon"; } // PayPal webhook bodies are small; anything larger is not a real event. const MAX_BODY_BYTES = 1_000_000; const SIG_HEADERS = [ "paypal-auth-algo", "paypal-cert-url", "paypal-transmission-id", "paypal-transmission-sig", "paypal-transmission-time", ]; export async function POST(req: NextRequest) { // This endpoint is unauthenticated and verification is done by CALLING PayPal, // so every request costs us an outbound API call. Throttle per IP, and reject // obviously-bogus requests before spending anything. const rl = await rateLimit("paypal-webhook", clientKey(req), LIMITS.webhook); if (!rl.ok) { return new Response("Too many requests", { status: 429, headers: { "Retry-After": String(rl.retryAfterSec ?? 60) }, }); } const headers: Record = {}; for (const h of SIG_HEADERS) headers[h] = req.headers.get(h) ?? undefined; // A genuine PayPal delivery always carries all five signature headers. Bailing // here costs nothing, whereas verifyPaypalWebhook() would hit the network. if (SIG_HEADERS.some((h) => !headers[h])) { return new Response("Invalid signature", { status: 400 }); } // Check the declared size BEFORE reading, so an oversized body is never // materialized in memory. The post-read check still backstops a missing or // lying Content-Length. const declared = Number(req.headers.get("content-length") ?? "0"); if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) { return new Response("Payload too large", { status: 413 }); } const body = await req.text(); if (body.length > MAX_BODY_BYTES) { return new Response("Payload too large", { status: 413 }); } let parsed: unknown; try { parsed = JSON.parse(body); } catch { return new Response("Invalid payload", { status: 400 }); } const verified = await verifyPaypalWebhook(headers, body).catch(() => false); if (!verified) { console.error("[paypal webhook] verification failed"); return new Response("Invalid signature", { status: 400 }); } const event = parsed as { id?: string; event_type?: string }; const eventId = event.id ?? `paypal_${Date.now()}`; if (event.id && (await alreadyProcessed(eventId))) return new Response("ok (duplicate)"); try { await handlePaypalEvent(event as Parameters[0]); await logWebhook("paypal", eventId, event.event_type ?? "unknown", "processed"); } catch (err) { console.error("[paypal webhook] handler error", err); await logWebhook("paypal", eventId, event.event_type ?? "unknown", "failed", err instanceof Error ? err.message : String(err)); return new Response("Handler error", { status: 500 }); } return new Response("ok"); }