Repository Public
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