SPB Git

Repository Public

route.ts 9.1 KB 248 lines typescript

1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// KA Communication Hub — réception des soumissions publiques (/contact/*).3// POST multipart/form-data : { category, _hp (honeypot), _ts (horodatage),4// …champs du registre, fichiers }. Toute la validation est SERVEUR (le5// registre src/lib/comms/categories.ts fait foi) ; le frontend n'est jamais6// cru — ni pour la priorité, ni pour l'identité KA ID, ni pour les fichiers.7import { NextRequest, NextResponse } from "next/server";8import fs from "fs/promises";9import path from "path";10import crypto from "crypto";11import { getSessionUser } from "@/lib/auth";12import {13  categoryBySlug,14  computePriority,15  CORE_FIELDS,16  type FieldDef,17} from "@/lib/comms/categories";18import {19  insertSubmission,20  insertAttachment,21  logEvent,22  nextReference,23} from "@/lib/comms/db";24import { allowSubmission, ipFingerprint, looksLikeSpam } from "@/lib/comms/rate-limit";25import { sendReceipt } from "@/lib/comms/receipt-email";2627const UPLOAD_DIR = path.join(process.cwd(), "data", "contact-uploads");28const MAX_FILE_BYTES = 10 * 1024 * 1024;29const MAX_FILES = 3;3031// Liste blanche stricte des types de fichiers (extension ET MIME).32const ALLOWED_FILES: Record<string, string[]> = {33  ".pdf": ["application/pdf"],34  ".doc": ["application/msword"],35  ".docx": ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"],36  ".png": ["image/png"],37  ".jpg": ["image/jpeg"],38  ".jpeg": ["image/jpeg"],39  ".webp": ["image/webp"],40  ".txt": ["text/plain"],41};4243function clean(v: FormDataEntryValue | null, maxLen = 500): string | null {44  if (typeof v !== "string") return null;45  // sanitation : caractères de contrôle retirés (le \\n des textarea survit),46  // longueur bornée47  const s = v.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "").trim();48  return s ? s.slice(0, maxLen) : null;49}5051function isEmail(s: string): boolean {52  return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(s) && s.length <= 200;53}5455function isUrl(s: string): boolean {56  try {57    const u = new URL(s.startsWith("http") ? s : `https://${s}`);58    return u.protocol === "https:" || u.protocol === "http:";59  } catch {60    return false;61  }62}6364/** Le champ est-il actif compte tenu des conditions showIf/showIfUnchecked ? */65function fieldActive(f: FieldDef, values: Record<string, string | null>): boolean {66  if (f.showIf && values[f.showIf.field] !== f.showIf.equals) return false;67  if (f.showIfUnchecked && values[f.showIfUnchecked] === "on") return false;68  return true;69}7071export async function POST(req: NextRequest) {72  const fd = await req.formData().catch(() => null);73  if (!fd)74    return NextResponse.json({ error: "Requête illisible." }, { status: 400 });7576  const cat = categoryBySlug(clean(fd.get("category"), 40) ?? "");77  if (!cat)78    return NextResponse.json({ error: "Catégorie inconnue." }, { status: 400 });7980  // Anti-abus : honeypot + temps de remplissage + fenêtre glissante par IP81  // (empreinte salée, en mémoire seulement — jamais d'IP en base).82  const spammy = looksLikeSpam(83    clean(fd.get("_hp"), 200),84    typeof fd.get("_ts") === "string" ? (fd.get("_ts") as string) : null,85  );86  if (!allowSubmission(ipFingerprint(req)))87    return NextResponse.json(88      { error: "Trop de soumissions rapprochées — réessayez dans quelques minutes." },89      { status: 429 },90    );9192  /* ---------- validation champ par champ selon le registre ---------- */93  const values: Record<string, string | null> = {};94  for (const f of cat.fields) {95    if (f.type === "file") continue;96    if (f.type === "checkbox") {97      values[f.name] = fd.get(f.name) === "on" ? "on" : null;98      continue;99    }100    values[f.name] = clean(fd.get(f.name), f.maxLen ?? 500);101  }102103  const errors: Record<string, string> = {};104  for (const f of cat.fields) {105    if (f.type === "file") continue;106    if (!fieldActive(f, values)) {107      values[f.name] = null; // champ inactif : on ne garde rien108      continue;109    }110    const v = values[f.name];111    if (f.required && !v) {112      errors[f.name] = "Ce champ est requis.";113      continue;114    }115    if (!v) continue;116    if (f.type === "email" && !isEmail(v)) errors[f.name] = "Courriel invalide.";117    if (f.type === "url" && !isUrl(v)) errors[f.name] = "Adresse web invalide.";118    if (f.type === "select" && f.options && !f.options.includes(v))119      errors[f.name] = "Valeur non reconnue.";120    if (f.type === "date" && !/^\d{4}-\d{2}-\d{2}$/.test(v))121      errors[f.name] = "Date invalide.";122  }123  if (Object.keys(errors).length)124    return NextResponse.json({ error: "Formulaire incomplet.", fields: errors }, { status: 422 });125126  /* ---------- fichiers ---------- */127  type Staged = { field: string; name: string; mime: string; buf: Buffer };128  const staged: Staged[] = [];129  for (const f of cat.fields) {130    if (f.type !== "file" || !fieldActive(f, values)) continue;131    const file = fd.get(f.name);132    const present = file instanceof File && file.size > 0;133    if (f.required && !present)134      return NextResponse.json(135        { error: "Formulaire incomplet.", fields: { [f.name]: "Fichier requis." } },136        { status: 422 },137      );138    if (!present) continue;139    if (staged.length >= MAX_FILES)140      return NextResponse.json({ error: "Trop de fichiers (3 max)." }, { status: 422 });141    if (file.size > MAX_FILE_BYTES)142      return NextResponse.json(143        { error: "Fichier trop lourd — 10 Mo maximum.", fields: { [f.name]: "10 Mo maximum." } },144        { status: 413 },145      );146    const ext = path.extname(file.name || "").toLowerCase();147    const mimes = ALLOWED_FILES[ext];148    if (!mimes || !mimes.includes(file.type))149      return NextResponse.json(150        { error: "Format refusé.", fields: { [f.name]: "Formats acceptés : PDF, Word, image." } },151        { status: 415 },152      );153    staged.push({154      field: f.name,155      name: (file.name || `fichier${ext}`).slice(0, 200),156      mime: file.type,157      buf: Buffer.from(await file.arrayBuffer()),158    });159  }160161  /* ---------- identité : session KA ID côté serveur uniquement ---------- */162  const user = await getSessionUser().catch(() => null);163  const isAnonymous = values.is_anonymous === "on";164165  /* ---------- colonnes cœur vs metadata ---------- */166  const core: Record<string, string | null> = {};167  const metadata: Record<string, unknown> = {};168  for (const f of cat.fields) {169    if (f.type === "file") continue;170    const v = values[f.name];171    if (f.name === "is_anonymous") continue;172    if (CORE_FIELDS.has(f.name)) core[f.name] = v;173    else if (v != null) metadata[f.name] = f.type === "checkbox" ? true : v;174  }175  // fournisseurs/carrières : « Nom » simple saisi dans first_name — OK tel quel.176177  const subCategory = cat.subCategoryFrom178    ? (values[cat.subCategoryFrom] ?? null)179    : null;180  const priority = spammy181    ? "basse"182    : computePriority(cat.slug, subCategory, metadata);183184  const reference = nextReference(cat.prefix);185  const id = insertSubmission({186    reference,187    category: cat.slug,188    sub_category: subCategory,189    first_name: core.first_name ?? null,190    last_name: core.last_name ?? null,191    email: isAnonymous ? null : (core.email ?? null),192    phone: core.phone ?? null,193    organization: core.organization ?? null,194    job_title: core.job_title ?? null,195    website: core.website ?? null,196    linkedin_url: core.linkedin_url ?? null,197    github_url: core.github_url ?? null,198    site_concerned: core.site_concerned ?? null,199    subject: core.subject ?? null,200    message: core.message ?? null,201    metadata: Object.keys(metadata).length ? metadata : null,202    priority,203    confidentiality: cat.confidentiality,204    user_id: isAnonymous ? null : (user?.id ?? null),205    ka_id: isAnonymous ? null : (user?.kaId ?? null),206    is_anonymous: isAnonymous,207  });208  if (spammy) {209    // On enregistre quand même (faux positifs possibles) mais marqué spam.210    const { commsDb } = await import("@/lib/comms/db");211    commsDb212      .prepare("UPDATE contact_submissions SET is_spam = 1 WHERE id = ?")213      .run(id);214  }215  logEvent(id, null, "creation", `Soumission ${cat.slug} reçue (web)`);216217  /* ---------- pièces jointes : nom aléatoire, hors du dossier public ---------- */218  if (staged.length) {219    const year = String(new Date().getFullYear());220    const dir = path.join(UPLOAD_DIR, year);221    await fs.mkdir(dir, { recursive: true });222    for (const s of staged) {223      const stored = `${reference}-${s.field}-${crypto.randomBytes(8).toString("hex")}${path.extname(s.name).toLowerCase()}`;224      await fs.writeFile(path.join(dir, stored), s.buf);225      insertAttachment({226        submission_id: id,227        field: s.field,228        original_name: s.name,229        stored_name: `${year}/${stored}`,230        mime: s.mime,231        size: s.buf.length,232      });233    }234  }235236  /* ---------- accusé de réception (non bloquant) ---------- */237  const to = isAnonymous ? null : (core.email ?? null);238  if (to && !spammy) {239    const name =240      [core.first_name, core.last_name].filter(Boolean).join(" ") || "bonjour";241    sendReceipt({ to, name, reference, categoryTitle: cat.title }).catch((e) =>242      console.error("[contact] accusé de réception :", (e as Error).message),243    );244  }245246  return NextResponse.json({ ok: true, reference });247}248
Files main
Menu

Theme