spb/phi-conseil
Public
1import { NextResponse } from "next/server";2import { contactSchema } from "@/lib/validation";3import { getEnv } from "@/lib/env";4import { getMailer } from "@/lib/mail";5import { clientIp, createDedupe, createMemoryRateLimiter, fingerprint } from "@/lib/security";67const MAX_BODY_BYTES = 32 * 1024;8const MIN_FILL_MS = 2500;910let limiter: ReturnType<typeof createMemoryRateLimiter> | null = null;11const dedupe = createDedupe(600);1213function getLimiter() {14 if (!limiter) {15 const env = getEnv();16 limiter = createMemoryRateLimiter(env.CONTACT_RATE_LIMIT, env.CONTACT_RATE_WINDOW_S);17 }18 return limiter;19}2021export async function POST(request: Request) {22 const ip = clientIp(request.headers);23 const ipHash = fingerprint(ip);2425 const len = Number(request.headers.get("content-length") ?? "0");26 if (len > MAX_BODY_BYTES) {27 return NextResponse.json({ ok: false, error: "Message trop volumineux." }, { status: 413 });28 }29 if (!(request.headers.get("content-type") ?? "").includes("application/json")) {30 return NextResponse.json({ ok: false, error: "Format non pris en charge." }, { status: 415 });31 }3233 const rl = getLimiter().check(ipHash);34 if (!rl.allowed) {35 return NextResponse.json(36 { ok: false, error: "Trop de tentatives." },37 { status: 429, headers: { "Retry-After": String(rl.retryAfterS) } },38 );39 }4041 let raw: unknown;42 try {43 const text = await request.text();44 if (text.length > MAX_BODY_BYTES) {45 return NextResponse.json({ ok: false, error: "Message trop volumineux." }, { status: 413 });46 }47 raw = JSON.parse(text);48 } catch {49 return NextResponse.json({ ok: false, error: "Requête invalide." }, { status: 400 });50 }5152 const parsed = contactSchema.safeParse(raw);53 if (!parsed.success) {54 // Honeypot rempli → réponse « succès » silencieuse pour ne pas renseigner les robots.55 const honeypot = parsed.error.issues.some((i) => i.path[0] === "website");56 if (honeypot) {57 console.info(`[contact] honeypot déclenché ip=${ipHash}`);58 return NextResponse.json({ ok: true, delivered: false });59 }60 const fields = Object.fromEntries(61 parsed.error.issues.map((i) => [i.path.join("."), i.message]),62 );63 return NextResponse.json(64 { ok: false, error: "Certains champs sont invalides.", fields },65 { status: 400 },66 );67 }6869 const data = parsed.data;70 if (data.startedAt !== undefined && Date.now() - data.startedAt < MIN_FILL_MS) {71 console.info(`[contact] soumission trop rapide ip=${ipHash}`);72 return NextResponse.json({ ok: true, delivered: false });73 }7475 const key = fingerprint(`${data.email}|${data.subject}|${data.message}`);76 if (dedupe.isDuplicate(key)) {77 return NextResponse.json({ ok: true, delivered: false, duplicate: true });78 }7980 try {81 const result = await getMailer().send(data, { ipHash });82 console.info(83 `[contact] reçu ip=${ipHash} delivered=${result.delivered} id=${result.id ?? "-"}`,84 );85 return NextResponse.json({ ok: true, delivered: result.delivered });86 } catch (e) {87 console.error(88 `[contact] échec d'envoi ip=${ipHash}`,89 e instanceof Error ? e.message : "erreur",90 );91 return NextResponse.json(92 { ok: false, error: "L'envoi a échoué. Veuillez réessayer plus tard." },93 { status: 502 },94 );95 }96}97