19ed377214
POST /api/webhooks/lead con header x-webhook-secret. Un endpoint solo per il
form del sito e per qualunque bridge: il contratto e' un POST, e chi lo manda
non cambia la route.
La normalizzazione dei campi sta in lead-intake.ts, non nella route, perche' e'
la parte che cambia quando si aggiunge una sorgente. Regge tre forme senza
doverle distinguere: payload piatto, `fields` annidati con {value} (Elementor
Pro), e urlencoded per i form che non mandano JSON. Riconosce i nomi italiani
(nome, telefono, azienda, messaggio), che e' come li chiama un form Elementor
scritto in italiano.
Chi compila due volte non diventa due lead. Il riconoscimento e' sull'email: il
secondo invio aggiorna last_contact_date e lascia un'attivita' con quello che
ha scritto, cosi' il messaggio non si perde ma la scheda resta una. Senza email
non si puo' dedurre nulla e si crea.
Due scelte di sicurezza, entrambe diverse dalle route /api/internal:
- Segreto assente in ambiente = 403, non "passa". Le internal possono
permetterselo perche' sono raggiungibili solo da localhost; questa e' esposta
a internet, e un deploy con la variabile dimenticata deve smettere di
accettare lead, non accettarli da chiunque.
- Il rate limit viene PRIMA del confronto sul segreto, altrimenti tentare
segreti a raffica costerebbe zero. Confronto a tempo costante con safeEqual,
lo stesso del gate admin.
src/proxy.ts non intercetta /api/*, quindi da monte non arriva nessuna
protezione: sta tutto dentro la route.
Provato contro il DB di produzione via tunnel SSH, poi ripulito (2 lead e 2
attivita' prima, 2 e 2 dopo): senza segreto 403, segreto sbagliato 403, nome
mancante 422, payload piatto 201, ripetuto 200 "updated" senza duplicare,
forma Elementor 201 con nome/telefono/messaggio mappati, urlencoded 201, e con
starts_at valorizzato il lead nasce con la data della call e un'attivita'
"meeting".
Resta da confermare con un invio VERO da Elementor la forma esatta del suo
payload: qui e' gestita in modo difensivo, non verificata sul campo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
93 lines
3.4 KiB
TypeScript
93 lines
3.4 KiB
TypeScript
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 });
|
|
}
|
|
}
|