import { NextResponse } from "next/server"; import { contactSchema } from "@/lib/validation"; import { getEnv } from "@/lib/env"; import { getMailer } from "@/lib/mail"; import { clientIp, createDedupe, createMemoryRateLimiter, fingerprint } from "@/lib/security"; const MAX_BODY_BYTES = 32 * 1024; const MIN_FILL_MS = 2500; let limiter: ReturnType | null = null; const dedupe = createDedupe(600); function getLimiter() { if (!limiter) { const env = getEnv(); limiter = createMemoryRateLimiter(env.CONTACT_RATE_LIMIT, env.CONTACT_RATE_WINDOW_S); } return limiter; } export async function POST(request: Request) { const ip = clientIp(request.headers); const ipHash = fingerprint(ip); const len = Number(request.headers.get("content-length") ?? "0"); if (len > MAX_BODY_BYTES) { return NextResponse.json({ ok: false, error: "Message trop volumineux." }, { status: 413 }); } if (!(request.headers.get("content-type") ?? "").includes("application/json")) { return NextResponse.json({ ok: false, error: "Format non pris en charge." }, { status: 415 }); } const rl = getLimiter().check(ipHash); if (!rl.allowed) { return NextResponse.json( { ok: false, error: "Trop de tentatives." }, { status: 429, headers: { "Retry-After": String(rl.retryAfterS) } }, ); } let raw: unknown; try { const text = await request.text(); if (text.length > MAX_BODY_BYTES) { return NextResponse.json({ ok: false, error: "Message trop volumineux." }, { status: 413 }); } raw = JSON.parse(text); } catch { return NextResponse.json({ ok: false, error: "Requête invalide." }, { status: 400 }); } const parsed = contactSchema.safeParse(raw); if (!parsed.success) { // Honeypot rempli → réponse « succès » silencieuse pour ne pas renseigner les robots. const honeypot = parsed.error.issues.some((i) => i.path[0] === "website"); if (honeypot) { console.info(`[contact] honeypot déclenché ip=${ipHash}`); return NextResponse.json({ ok: true, delivered: false }); } const fields = Object.fromEntries( parsed.error.issues.map((i) => [i.path.join("."), i.message]), ); return NextResponse.json( { ok: false, error: "Certains champs sont invalides.", fields }, { status: 400 }, ); } const data = parsed.data; if (data.startedAt !== undefined && Date.now() - data.startedAt < MIN_FILL_MS) { console.info(`[contact] soumission trop rapide ip=${ipHash}`); return NextResponse.json({ ok: true, delivered: false }); } const key = fingerprint(`${data.email}|${data.subject}|${data.message}`); if (dedupe.isDuplicate(key)) { return NextResponse.json({ ok: true, delivered: false, duplicate: true }); } try { const result = await getMailer().send(data, { ipHash }); console.info( `[contact] reçu ip=${ipHash} delivered=${result.delivered} id=${result.id ?? "-"}`, ); return NextResponse.json({ ok: true, delivered: result.delivered }); } catch (e) { console.error( `[contact] échec d'envoi ip=${ipHash}`, e instanceof Error ? e.message : "erreur", ); return NextResponse.json( { ok: false, error: "L'envoi a échoué. Veuillez réessayer plus tard." }, { status: 502 }, ); } }