import { NextRequest, NextResponse } from "next/server"; import { rateLimit } from "@/lib/rate-limit"; import { safeEqual } from "@/lib/admin-gate"; import { ingestLead, leadIntakeSchema, normalizeLeadPayload } from "@/lib/lead-intake"; /** * Ingresso lead dall'esterno — form del sito, bridge Zapier/Make, qualunque * cosa sappia fare un POST JSON. * * curl -X POST https://…/api/webhooks/lead \ * -H 'content-type: application/json' \ * -H "x-webhook-secret: $LEAD_WEBHOOK_SECRET" \ * -d '{"name":"Mario Rossi","email":"mario@example.com","source":"form-home"}' * * Due differenze rispetto alle route in /api/internal, e sono volute: * * 1. `src/proxy.ts` NON intercetta /api/* (vedi il matcher in fondo a quel * file), quindi qui non arriva nessuna protezione da monte: rate limit e * controllo del segreto stanno tutti dentro la route. * 2. Segreto assente in ambiente significa 403, non "passa". Le internal * possono permetterselo perché sono raggiungibili solo da localhost; questa * è esposta a internet, e un deploy con la variabile dimenticata deve * smettere di accettare lead, non accettarli da chiunque. */ const MAX_BODY_BYTES = 16 * 1024; function clientIp(request: NextRequest): string { return ( request.headers.get("x-forwarded-for")?.split(",")[0].trim() || request.headers.get("x-real-ip") || "unknown" ); } export async function POST(request: NextRequest) { const ip = clientIp(request); // Il rate limit viene PRIMA del confronto sul segreto: altrimenti tentare // segreti a raffica costerebbe zero. if (!rateLimit(`webhook-lead:${ip}`, 20, 60 * 1000)) { return NextResponse.json({ error: "Troppe richieste" }, { status: 429 }); } const secret = process.env.LEAD_WEBHOOK_SECRET; if (!secret) { console.error("[webhook/lead] LEAD_WEBHOOK_SECRET non configurato — richiesta rifiutata"); return NextResponse.json({ error: "Non autorizzato" }, { status: 403 }); } if (!safeEqual(request.headers.get("x-webhook-secret"), secret)) { return NextResponse.json({ error: "Non autorizzato" }, { status: 403 }); } const raw = await request.text(); if (raw.length > MAX_BODY_BYTES) { return NextResponse.json({ error: "Payload troppo grande" }, { status: 413 }); } let body: unknown; try { body = JSON.parse(raw); } catch { // I form che mandano application/x-www-form-urlencoded sono la norma: // vale la pena leggerli invece di rispondere 400 e lasciare perdere il lead. try { body = Object.fromEntries(new URLSearchParams(raw)); } catch { return NextResponse.json({ error: "Corpo non leggibile" }, { status: 400 }); } } const parsed = leadIntakeSchema.safeParse(normalizeLeadPayload(body)); if (!parsed.success) { return NextResponse.json( { error: "Dati non validi", detail: parsed.error.issues[0].message }, { status: 422 } ); } try { const result = await ingestLead(parsed.data); return NextResponse.json( { ok: true, outcome: result.outcome, leadId: result.leadId }, { status: result.outcome === "created" ? 201 : 200 } ); } catch (error) { // Il dettaglio resta nei log del server: al chiamante non si dice mai // perché il database si è lamentato. console.error("[webhook/lead] ingest fallito:", error); return NextResponse.json({ error: "Errore interno" }, { status: 500 }); } }