SPB Git

spb/phi-conseil

Public
3.3 KB · 97 lines typescript
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
Files · main
Menu

Theme