mirror of
https://github.com/Ladebeze66/devsite.git
synced 2026-05-11 16:56:26 +02:00
mail_ok
This commit is contained in:
parent
1267d60cf2
commit
916ae8dfef
@ -16,6 +16,7 @@ my-next-site/
|
|||||||
├── cmsbackend/ # Backend Strapi
|
├── cmsbackend/ # Backend Strapi
|
||||||
├── llm-api/ # API FastAPI pour IA
|
├── llm-api/ # API FastAPI pour IA
|
||||||
├── start-my-site.ps1 # Script de démarrage
|
├── start-my-site.ps1 # Script de démarrage
|
||||||
|
├── stop-my-site.ps1 # Script d'arrêt propre
|
||||||
└── package.json # Dépendances frontend
|
└── package.json # Dépendances frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -29,6 +30,23 @@ cd J:\my-next-site
|
|||||||
|
|
||||||
Ce script lance automatiquement les 3 services dans des fenêtres PowerShell séparées.
|
Ce script lance automatiquement les 3 services dans des fenêtres PowerShell séparées.
|
||||||
|
|
||||||
|
**Améliorations :**
|
||||||
|
- Configuration centralisée via un tableau `$services` (plus de duplication entre les 3 blocs).
|
||||||
|
- **Détection du port déjà occupé** : si un service tourne déjà, il n'est pas relancé (évite `EADDRINUSE`).
|
||||||
|
- **Portabilité** : chemins résolus via `$PSScriptRoot`, pas de `J:\my-next-site` codé en dur.
|
||||||
|
- **`-NoExit`** sur chaque fenêtre : le message d'erreur reste visible si un service crashe au démarrage.
|
||||||
|
- **Bilan final** : nombre de services lancés / déjà actifs / échecs.
|
||||||
|
|
||||||
|
### Arrêt des services
|
||||||
|
```powershell
|
||||||
|
cd J:\my-next-site
|
||||||
|
.\stop-my-site.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Termine les processus qui écoutent les ports **1337** (Strapi), **3000** (Next.js) et **8000** (FastAPI). Ne touche pas aux autres processus Node de ta machine. Les fenêtres PowerShell lancées par `start-my-site.ps1` restent ouvertes (à fermer manuellement). En cas d'échec sur certains PIDs → relancer dans un PowerShell admin.
|
||||||
|
|
||||||
|
> Note encodage : les deux scripts PowerShell sont encodés en **UTF-8 avec BOM**. C'est nécessaire pour que Windows PowerShell 5.1 (version par défaut) les lise correctement avec les emojis et accents. PowerShell 7 n'a pas ce souci mais reste compatible avec le BOM.
|
||||||
|
|
||||||
## 🔧 Commandes Manuelles
|
## 🔧 Commandes Manuelles
|
||||||
|
|
||||||
### 1. Frontend Next.js
|
### 1. Frontend Next.js
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
import { getApiUrl } from "../../utils/getApiUrl";
|
|
||||||
export default async function MessagesPage() {
|
|
||||||
const apiUrl = getApiUrl();
|
|
||||||
const res = await fetch(`${apiUrl}/api/messages`);
|
|
||||||
const { data } = await res.json();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white/70 rounded-md max-w-3xl mx-auto p-6">
|
|
||||||
{/* Titre de la page */}
|
|
||||||
<h1 className="text-3xl font-bold text-center mb-6">📬 Messages reçus</h1>
|
|
||||||
|
|
||||||
{data.length === 0 ? (
|
|
||||||
<p className="text-center text-gray-600">Aucun message reçu.</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-4">
|
|
||||||
{data.map((msg: any) => (
|
|
||||||
<li key={msg.id} className="p-4 border rounded shadow">
|
|
||||||
<p><strong>👤 {msg.name}</strong> ({msg.email})</p>
|
|
||||||
<p>📅 {new Date(msg.createdAt).toLocaleString("fr-FR")}</p>
|
|
||||||
<p className="mt-2">{msg.message}</p>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
291
app/api/contact/route.ts
Normal file
291
app/api/contact/route.ts
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
/**
|
||||||
|
* Endpoint POST /api/contact — reçoit les messages du formulaire de contact
|
||||||
|
* et envoie une notification par email via l'API HTTP Brevo.
|
||||||
|
*
|
||||||
|
* Flux :
|
||||||
|
* Navigateur ─▶ POST /api/contact (cette route, serveur)
|
||||||
|
* ↓ validation + honeypot + rate-limit
|
||||||
|
* POST https://api.brevo.com/v3/smtp/email
|
||||||
|
* ↓
|
||||||
|
* Gmail : CONTACT_TO_EMAIL
|
||||||
|
*
|
||||||
|
* Aucun stockage côté Strapi (voir docs-site-interne/contact-flow.md pour la
|
||||||
|
* décision d'architecture). Si l'envoi Brevo échoue, on renvoie une erreur 502
|
||||||
|
* au client pour qu'il puisse ré-essayer.
|
||||||
|
*
|
||||||
|
* Variables d'environnement requises (voir .env.example) :
|
||||||
|
* - BREVO_API_KEY
|
||||||
|
* - CONTACT_FROM_EMAIL (doit être un expéditeur vérifié dans Brevo)
|
||||||
|
* - CONTACT_FROM_NAME
|
||||||
|
* - CONTACT_TO_EMAIL
|
||||||
|
* - CONTACT_TO_NAME
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
|
||||||
|
// Force Node runtime : `fetch` est dispo sur l'edge aussi, mais on veut la
|
||||||
|
// stdlib Node pour les logs serveur et parce que Brevo peut mettre >1s à
|
||||||
|
// répondre (edge a une limite de timeout plus stricte sur Vercel free).
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
// Map en mémoire pour le rate-limit. `Map<ip, number[]>` où number[] est la
|
||||||
|
// liste des timestamps d'envois récents. On nettoie à la volée. Suffisant pour
|
||||||
|
// un portfolio — pas besoin de Redis tant qu'on reste sur une instance.
|
||||||
|
// Note : en mode serverless (plusieurs lambdas), cette map n'est pas partagée,
|
||||||
|
// donc le rate-limit est "best effort". Pour du ship prod sous charge, passer
|
||||||
|
// sur Redis ou Upstash.
|
||||||
|
const rateLimitStore = new Map<string, number[]>();
|
||||||
|
const RATE_LIMIT_MAX = 3; // 3 envois max
|
||||||
|
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000; // par tranche de 10 minutes
|
||||||
|
|
||||||
|
const MAX_NAME_LENGTH = 120;
|
||||||
|
const MAX_EMAIL_LENGTH = 160;
|
||||||
|
const MAX_MESSAGE_LENGTH = 5000;
|
||||||
|
|
||||||
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
type ContactPayload = {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
message?: string;
|
||||||
|
// Champ honeypot : rempli = bot. Ne jamais loguer son contenu.
|
||||||
|
website?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getClientIp(req: NextRequest): string {
|
||||||
|
// Ordre de priorité classique derrière un reverse-proxy (Vercel, Nginx).
|
||||||
|
const xff = req.headers.get("x-forwarded-for");
|
||||||
|
if (xff) return xff.split(",")[0].trim();
|
||||||
|
const xrip = req.headers.get("x-real-ip");
|
||||||
|
if (xrip) return xrip.trim();
|
||||||
|
// Fallback local : pas d'IP extractible (ex. `localhost`), on bucketise tout
|
||||||
|
// le trafic dans un même compartiment. Acceptable en dev.
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRateLimit(ip: string): { ok: boolean; retryAfterMs: number } {
|
||||||
|
const now = Date.now();
|
||||||
|
const recent = (rateLimitStore.get(ip) ?? []).filter(
|
||||||
|
(ts) => now - ts < RATE_LIMIT_WINDOW_MS
|
||||||
|
);
|
||||||
|
if (recent.length >= RATE_LIMIT_MAX) {
|
||||||
|
const oldest = recent[0];
|
||||||
|
return { ok: false, retryAfterMs: RATE_LIMIT_WINDOW_MS - (now - oldest) };
|
||||||
|
}
|
||||||
|
recent.push(now);
|
||||||
|
rateLimitStore.set(ip, recent);
|
||||||
|
return { ok: true, retryAfterMs: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmailContents(input: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
message: string;
|
||||||
|
ip: string;
|
||||||
|
}) {
|
||||||
|
const dateTime = new Date().toLocaleString("fr-FR", {
|
||||||
|
timeZone: "Europe/Paris",
|
||||||
|
});
|
||||||
|
const safeName = escapeHtml(input.name);
|
||||||
|
const safeEmail = escapeHtml(input.email);
|
||||||
|
const safeMessage = escapeHtml(input.message).replace(/\n/g, "<br>");
|
||||||
|
|
||||||
|
const subject = `Nouveau message portfolio — ${input.name}`;
|
||||||
|
|
||||||
|
const textContent = [
|
||||||
|
`Nouveau message reçu sur le formulaire de contact.`,
|
||||||
|
``,
|
||||||
|
`Nom : ${input.name}`,
|
||||||
|
`Email : ${input.email}`,
|
||||||
|
`Date : ${dateTime}`,
|
||||||
|
`IP : ${input.ip}`,
|
||||||
|
``,
|
||||||
|
`Message :`,
|
||||||
|
input.message,
|
||||||
|
``,
|
||||||
|
`---`,
|
||||||
|
`Répondre directement à ce mail renverra vers ${input.email}.`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const htmlContent = `
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<body style="font-family: system-ui, -apple-system, Segoe UI, sans-serif; color: #191c1d; background: #f8fafa; margin: 0; padding: 24px;">
|
||||||
|
<div style="max-width: 560px; margin: 0 auto; background: #ffffff; border-radius: 16px; padding: 24px; box-shadow: 0 4px 12px rgba(25,28,29,0.06);">
|
||||||
|
<h1 style="font-size: 18px; font-weight: 700; color: #26445d; margin: 0 0 16px;">
|
||||||
|
Nouveau message portfolio
|
||||||
|
</h1>
|
||||||
|
<table role="presentation" style="width: 100%; font-size: 14px; line-height: 1.5;">
|
||||||
|
<tr>
|
||||||
|
<td style="color: #516169; padding: 4px 0; width: 80px;">Nom</td>
|
||||||
|
<td style="font-weight: 600;">${safeName}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="color: #516169; padding: 4px 0;">Email</td>
|
||||||
|
<td><a href="mailto:${safeEmail}" style="color: #26445d;">${safeEmail}</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="color: #516169; padding: 4px 0;">Date</td>
|
||||||
|
<td>${dateTime}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="color: #516169; padding: 4px 0;">IP</td>
|
||||||
|
<td style="font-family: monospace; font-size: 12px; color: #42474d;">${escapeHtml(input.ip)}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div style="margin-top: 20px; padding: 16px; background: #f2f4f4; border-radius: 12px;">
|
||||||
|
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 0.15em; color: #516169; margin-bottom: 8px;">Message</div>
|
||||||
|
<div style="font-size: 14px; line-height: 1.6; white-space: pre-wrap;">${safeMessage}</div>
|
||||||
|
</div>
|
||||||
|
<p style="margin-top: 20px; font-size: 12px; color: #73777d;">
|
||||||
|
Répondre directement à ce mail renverra vers <strong>${safeEmail}</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
return { subject, textContent, htmlContent };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const apiKey = process.env.BREVO_API_KEY;
|
||||||
|
const fromEmail = process.env.CONTACT_FROM_EMAIL;
|
||||||
|
const fromName = process.env.CONTACT_FROM_NAME ?? "Portfolio — nouveau message";
|
||||||
|
const toEmail = process.env.CONTACT_TO_EMAIL;
|
||||||
|
const toName = process.env.CONTACT_TO_NAME ?? "Portfolio";
|
||||||
|
|
||||||
|
if (!apiKey || !fromEmail || !toEmail) {
|
||||||
|
console.error("[/api/contact] Configuration Brevo incomplète", {
|
||||||
|
hasKey: Boolean(apiKey),
|
||||||
|
hasFrom: Boolean(fromEmail),
|
||||||
|
hasTo: Boolean(toEmail),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: "SERVER_MISCONFIGURED" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: ContactPayload;
|
||||||
|
try {
|
||||||
|
payload = (await req.json()) as ContactPayload;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: "INVALID_JSON" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = (payload.name ?? "").trim();
|
||||||
|
const email = (payload.email ?? "").trim();
|
||||||
|
const message = (payload.message ?? "").trim();
|
||||||
|
const honeypot = (payload.website ?? "").trim();
|
||||||
|
|
||||||
|
// Honeypot : si un bot a rempli le champ caché, on simule un succès silencieux.
|
||||||
|
// Ne jamais renvoyer une erreur → ça donnerait aux bots un signal pour retry.
|
||||||
|
if (honeypot.length > 0) {
|
||||||
|
console.warn("[/api/contact] Honeypot déclenché, IP :", getClientIp(req));
|
||||||
|
return NextResponse.json({ ok: true }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name || !email || !message) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: "MISSING_FIELDS" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
name.length > MAX_NAME_LENGTH ||
|
||||||
|
email.length > MAX_EMAIL_LENGTH ||
|
||||||
|
message.length > MAX_MESSAGE_LENGTH
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: "TOO_LONG" },
|
||||||
|
{ status: 413 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!EMAIL_REGEX.test(email)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: "INVALID_EMAIL" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = getClientIp(req);
|
||||||
|
const rate = checkRateLimit(ip);
|
||||||
|
if (!rate.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: "RATE_LIMITED" },
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
"Retry-After": String(Math.ceil(rate.retryAfterMs / 1000)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { subject, textContent, htmlContent } = buildEmailContents({
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
message,
|
||||||
|
ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const brevoRes = await fetch("https://api.brevo.com/v3/smtp/email", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"api-key": apiKey,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
sender: { name: fromName, email: fromEmail },
|
||||||
|
to: [{ email: toEmail, name: toName }],
|
||||||
|
// replyTo : clé de confort UX. Cliquer "Répondre" dans Gmail écrit
|
||||||
|
// directement au visiteur, sans aller-retour manuel pour recopier
|
||||||
|
// l'email depuis le corps du mail.
|
||||||
|
replyTo: { email, name },
|
||||||
|
subject,
|
||||||
|
htmlContent,
|
||||||
|
textContent,
|
||||||
|
tags: ["portfolio", "contact-form"],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!brevoRes.ok) {
|
||||||
|
const text = await brevoRes.text();
|
||||||
|
console.error(
|
||||||
|
"[/api/contact] Brevo error",
|
||||||
|
brevoRes.status,
|
||||||
|
brevoRes.statusText,
|
||||||
|
text
|
||||||
|
);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: "BREVO_ERROR" },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[/api/contact] Brevo fetch failed:", err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: "BREVO_UNREACHABLE" },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true }, { status: 200 });
|
||||||
|
}
|
||||||
@ -1,23 +1,33 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { sendMessage } from "../utils/sendMessage";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formulaire de contact — refonte "Digital Atelier" (étape 8).
|
* Formulaire de contact — refonte "Digital Atelier" (étape 8) + envoi via Brevo (étape 9).
|
||||||
*
|
*
|
||||||
* - Plus de `bg-white shadow-lg rounded-lg` sur le form : il est désormais
|
* Architecture :
|
||||||
* monté dans la carte vellum de `app/contact/page.js`.
|
* Form → POST /api/contact (Next.js server route, voir app/api/contact/route.ts)
|
||||||
* - Champs : `bg-surface-container-low`, radius `rounded-tile`, `focus-visible:ring-2 focus-visible:ring-primary`.
|
* ↓
|
||||||
* - CTA jewel : `bg-primary text-on-primary shadow-jewel` avec Material Symbol
|
* Brevo API HTTP → Gmail
|
||||||
* `send` + effet `-translate-y-0.5` au hover, état disabled en `bg-outline-variant/60`.
|
*
|
||||||
* - Bandeau status Stitch : succès en `primary-fixed`, erreur en `error-container`,
|
* Plus de passage par Strapi pour les messages : la route serveur valide,
|
||||||
* chargement en `surface-container`. Chaque état porte une Material Symbol.
|
* filtre (honeypot + rate-limit), puis envoie une notification email. Voir
|
||||||
|
* `docs-site-interne/contact-flow.md` pour l'architecture détaillée.
|
||||||
|
*
|
||||||
|
* Anti-spam :
|
||||||
|
* - Champ honeypot `website` caché (sr-only + tabindex=-1). Les bots le
|
||||||
|
* remplissent systématiquement, les humains non. Côté serveur, un champ
|
||||||
|
* rempli → succès silencieux (aucun email envoyé).
|
||||||
|
* - Rate-limit côté serveur : 3 envois / 10 min / IP (voir route.ts).
|
||||||
|
* - Validation longueur + format email côté serveur en plus du client.
|
||||||
*/
|
*/
|
||||||
export default function ContactForm() {
|
export default function ContactForm() {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
|
// Honeypot : doit rester vide. S'il est rempli, on soumet quand même pour ne
|
||||||
|
// rien changer côté UX (le serveur ignore silencieusement ces payloads).
|
||||||
|
const [website, setWebsite] = useState("");
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [statusKind, setStatusKind] = useState<
|
const [statusKind, setStatusKind] = useState<
|
||||||
"idle" | "loading" | "success" | "error"
|
"idle" | "loading" | "success" | "error"
|
||||||
@ -44,24 +54,55 @@ export default function ContactForm() {
|
|||||||
setStatusKind("loading");
|
setStatusKind("loading");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendMessage(name, email, message);
|
const res = await fetch("/api/contact", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name, email, message, website }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 429) {
|
||||||
|
setStatus(
|
||||||
|
"Trop d'envois depuis votre IP. Réessayez dans quelques minutes."
|
||||||
|
);
|
||||||
|
setStatusKind("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = (await res.json().catch(() => ({}))) as {
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
const errorCode = data.error ?? "UNKNOWN";
|
||||||
|
setStatus(
|
||||||
|
errorCode === "INVALID_EMAIL"
|
||||||
|
? "Email invalide."
|
||||||
|
: errorCode === "MISSING_FIELDS"
|
||||||
|
? "Tous les champs sont obligatoires."
|
||||||
|
: errorCode === "TOO_LONG"
|
||||||
|
? "Message trop long."
|
||||||
|
: "Erreur lors de l'envoi du message."
|
||||||
|
);
|
||||||
|
setStatusKind("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setStatus("Message envoyé. Merci, je reviens vers vous rapidement.");
|
setStatus("Message envoyé. Merci, je reviens vers vous rapidement.");
|
||||||
setStatusKind("success");
|
setStatusKind("success");
|
||||||
setName("");
|
setName("");
|
||||||
setEmail("");
|
setEmail("");
|
||||||
setMessage("");
|
setMessage("");
|
||||||
|
setWebsite("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus("Erreur lors de l'envoi du message.");
|
console.error("[ContactForm] submit failed:", error);
|
||||||
|
setStatus("Erreur réseau. Vérifiez votre connexion et réessayez.");
|
||||||
setStatusKind("error");
|
setStatusKind("error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusStyles: Record<typeof statusKind, string> = {
|
const statusStyles: Record<typeof statusKind, string> = {
|
||||||
idle: "",
|
idle: "",
|
||||||
loading:
|
loading: "bg-surface-container text-on-surface-variant",
|
||||||
"bg-surface-container text-on-surface-variant",
|
success: "bg-primary-fixed/70 text-on-primary-fixed",
|
||||||
success:
|
|
||||||
"bg-primary-fixed/70 text-on-primary-fixed",
|
|
||||||
error: "bg-error-container text-on-error-container",
|
error: "bg-error-container text-on-error-container",
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -77,6 +118,32 @@ export default function ContactForm() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3" noValidate>
|
<form onSubmit={handleSubmit} className="flex flex-col gap-3" noValidate>
|
||||||
|
{/* Honeypot : caché visuellement ET aux lecteurs d'écran. tabindex=-1 pour
|
||||||
|
qu'un utilisateur clavier ne tombe jamais dessus. Les bots qui
|
||||||
|
parsent le DOM le remplissent quasi-systématiquement. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "-10000px",
|
||||||
|
width: "1px",
|
||||||
|
height: "1px",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Ne pas remplir ce champ
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="website"
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
value={website}
|
||||||
|
onChange={(e) => setWebsite(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
<label className="flex flex-col gap-1">
|
||||||
<span className="font-headline text-[11px] font-bold uppercase tracking-[0.3em] text-secondary">
|
<span className="font-headline text-[11px] font-bold uppercase tracking-[0.3em] text-secondary">
|
||||||
Votre nom
|
Votre nom
|
||||||
@ -88,6 +155,7 @@ export default function ContactForm() {
|
|||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
className={fieldClass}
|
className={fieldClass}
|
||||||
required
|
required
|
||||||
|
maxLength={120}
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@ -103,6 +171,7 @@ export default function ContactForm() {
|
|||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
className={fieldClass}
|
className={fieldClass}
|
||||||
required
|
required
|
||||||
|
maxLength={160}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@ -116,6 +185,7 @@ export default function ContactForm() {
|
|||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
rows={5}
|
rows={5}
|
||||||
|
maxLength={5000}
|
||||||
className={`${fieldClass} min-h-[9rem] resize-y`}
|
className={`${fieldClass} min-h-[9rem] resize-y`}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,35 +0,0 @@
|
|||||||
import { getApiUrl } from "./getApiUrl";
|
|
||||||
|
|
||||||
export async function sendMessage(name: string, email: string, message: string) {
|
|
||||||
const dateTime = new Date().toLocaleString("fr-FR", { timeZone: "Europe/Paris" });
|
|
||||||
|
|
||||||
const messageWithDate = `${message}\n\n📅 Envoyé le : ${dateTime}`;
|
|
||||||
|
|
||||||
console.log("📨 Envoi du message...", { name, email, messageWithDate });
|
|
||||||
|
|
||||||
const apiUrl = getApiUrl();
|
|
||||||
|
|
||||||
const res = await fetch(`${apiUrl}/api/messages`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
data: {
|
|
||||||
name: name,
|
|
||||||
email: email,
|
|
||||||
message: messageWithDate,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const responseData = await res.json();
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
console.error("❌ Erreur API Strapi :", responseData);
|
|
||||||
throw new Error(`Échec de l'envoi du message: ${responseData.error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("✅ Message envoyé avec succès !", responseData);
|
|
||||||
return responseData;
|
|
||||||
}
|
|
||||||
@ -17,8 +17,8 @@
|
|||||||
| `/portfolio/[slug]` | `app/portfolio/[slug]/page.tsx` | Détail projet (`fetchData('projects', slug)`) |
|
| `/portfolio/[slug]` | `app/portfolio/[slug]/page.tsx` | Détail projet (`fetchData('projects', slug)`) |
|
||||||
| `/competences` | `app/competences/page.jsx` | Liste compétences |
|
| `/competences` | `app/competences/page.jsx` | Liste compétences |
|
||||||
| `/competences/[slug]` | `app/competences/[slug]/page.tsx` | Détail (`fetchData('competences', slug)`) |
|
| `/competences/[slug]` | `app/competences/[slug]/page.tsx` | Détail (`fetchData('competences', slug)`) |
|
||||||
| `/contact` | `app/contact/page.js` | Formulaire → Strapi `messages` |
|
| `/contact` | `app/contact/page.js` | Formulaire → `/api/contact` (Brevo, voir `contact-flow.md`) |
|
||||||
| `/admin/messages` | `app/admin/messages/page.tsx` | Consultation messages (côté Next) |
|
| `/api/contact` | `app/api/contact/route.ts` | Endpoint serveur : envoie un email via Brevo, honeypot + rate-limit |
|
||||||
| `/api/proxy` | `app/api/proxy/route.js` | Proxy GET vers API LLM distante |
|
| `/api/proxy` | `app/api/proxy/route.js` | Proxy GET vers API LLM distante |
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
@ -42,7 +42,7 @@
|
|||||||
|
|
||||||
- Carrousels : `Carousel.tsx`, `CarouselCompetences.tsx` (swiper / react-responsive-carousel).
|
- Carrousels : `Carousel.tsx`, `CarouselCompetences.tsx` (swiper / react-responsive-carousel).
|
||||||
- Sections : `ContentSection.tsx`, `ContentSectionCompetences*.tsx`.
|
- Sections : `ContentSection.tsx`, `ContentSectionCompetences*.tsx`.
|
||||||
- `ContactForm.tsx` → `sendMessage.ts`.
|
- `ContactForm.tsx` → `POST /api/contact` → Brevo API (voir `docs-site-interne/contact-flow.md`).
|
||||||
- `ChatBot.js` → `askAI.js` → `/api/proxy`.
|
- `ChatBot.js` → `askAI.js` → `/api/proxy`.
|
||||||
- `ModalGlossaire.tsx` — glossaire (données Strapi selon usage dans les pages).
|
- `ModalGlossaire.tsx` — glossaire (données Strapi selon usage dans les pages).
|
||||||
|
|
||||||
@ -53,6 +53,6 @@ app/layout.tsx
|
|||||||
app/page.tsx
|
app/page.tsx
|
||||||
app/utils/getApiUrl.ts
|
app/utils/getApiUrl.ts
|
||||||
app/utils/fetchData.ts
|
app/utils/fetchData.ts
|
||||||
app/utils/sendMessage.ts
|
app/api/contact/route.ts
|
||||||
next.config.ts
|
next.config.ts
|
||||||
```
|
```
|
||||||
|
|||||||
@ -43,15 +43,9 @@ Utilisation front : `app/page.tsx` — premier enregistrement `populate=*`, imag
|
|||||||
| `slug` | uid ← `name` | requis |
|
| `slug` | uid ← `name` | requis |
|
||||||
| `order` | integer | optionnel |
|
| `order` | integer | optionnel |
|
||||||
|
|
||||||
### `message` (collection `messages`)
|
### `message` (supprimé le 2026-04-23)
|
||||||
|
|
||||||
| Champ | Type | Notes |
|
Ancien content-type pour stocker les soumissions du formulaire de contact. Supprimé car le formulaire envoie désormais une notification email via **Brevo** (voir `docs-site-interne/contact-flow.md`) — plus besoin de stockage Strapi. Les 4 fichiers `cmsbackend/src/api/message/**` ont été supprimés ; la table SQLite `messages` reste orpheline (inoffensive, peut être droppée manuellement).
|
||||||
|-------|------|--------|
|
|
||||||
| `name` | string | requis |
|
|
||||||
| `email` | email | requis |
|
|
||||||
| `message` | text | requis |
|
|
||||||
|
|
||||||
Création via **POST** `/api/messages` depuis `sendMessage.ts` (permissions Strapi **create** publique à valider en prod).
|
|
||||||
|
|
||||||
### `glossaire` (collection `glossaires`)
|
### `glossaire` (collection `glossaires`)
|
||||||
|
|
||||||
|
|||||||
@ -361,6 +361,51 @@ Après : **carte vellum légère** centrée, sans ombre ambient (le footer ne do
|
|||||||
- **Validation serveur des champs du form** (anti-spam, honeypot, reCAPTCHA) : hors scope refonte visuelle. Strapi ne filtre pour l'instant que sur la structure JSON attendue.
|
- **Validation serveur des champs du form** (anti-spam, honeypot, reCAPTCHA) : hors scope refonte visuelle. Strapi ne filtre pour l'instant que sur la structure JSON attendue.
|
||||||
- **Fusion Carousel.tsx / CarouselCompetences.tsx** : reste en dette technique (déjà noté §7).
|
- **Fusion Carousel.tsx / CarouselCompetences.tsx** : reste en dette technique (déjà noté §7).
|
||||||
|
|
||||||
|
## 9. Post-refonte — Contact effectif via Brevo (2026-04-23)
|
||||||
|
|
||||||
|
La refonte visuelle a laissé le formulaire de contact en état "joli mais non fonctionnel" : il stockait les messages dans Strapi (content-type `message`), à consulter via `/admin/messages` (page publique, non protégée). Aucune notification.
|
||||||
|
|
||||||
|
**Décision** (discutée avec l'utilisateur) : supprimer le passage par Strapi, passer à une **notification email directe** via l'**API HTTP Brevo** (compte existant pour la newsletter). Plus simple, moins de surface d'attaque, notification immédiate.
|
||||||
|
|
||||||
|
### Changements
|
||||||
|
|
||||||
|
- **Nouveau** : `app/api/contact/route.ts` (Next.js App Router, runtime Node) — reçoit le form, valide, applique honeypot + rate-limit, appelle `POST https://api.brevo.com/v3/smtp/email`, retourne `{ ok: true | false, error }`.
|
||||||
|
- **Modifié** : `app/components/ContactForm.tsx` — appel vers `/api/contact` au lieu de `sendMessage(...)`. Ajout d'un **champ honeypot** `website` caché (position absolue hors écran + `aria-hidden` + `tabindex=-1`). Codes d'erreur serveur mappés en messages FR.
|
||||||
|
- **Supprimé** : `app/utils/sendMessage.ts` (plus utilisé), `app/admin/messages/page.tsx` (plus de consultation Strapi nécessaire, et cette page était exposée publiquement sans auth).
|
||||||
|
- **Supprimé côté Strapi** : `cmsbackend/src/api/message/` (content-type + routes + services + controllers). La table SQLite `messages` est laissée en place (orpheline, inoffensive).
|
||||||
|
- **Nouveau** : `.env.example` en racine pour documenter les variables requises, `docs-site-interne/contact-flow.md` pour l'architecture complète.
|
||||||
|
|
||||||
|
### Variables d'env (.env.local)
|
||||||
|
|
||||||
|
```env
|
||||||
|
BREVO_API_KEY=xkeysib-...
|
||||||
|
CONTACT_FROM_EMAIL=<expéditeur vérifié Brevo>
|
||||||
|
CONTACT_FROM_NAME=Portfolio — nouveau message
|
||||||
|
CONTACT_TO_EMAIL=grascalvet.fernand@gmail.com
|
||||||
|
CONTACT_TO_NAME=Fernand Gras-Calvet
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-abus
|
||||||
|
|
||||||
|
- **Honeypot** : champ caché `website`. Si rempli → 200 silencieux + log warning (pas d'erreur pour ne pas signaler aux bots).
|
||||||
|
- **Rate-limit** : 3 envois / IP / 10 min (Map en mémoire). Limité en serverless multi-instance — acceptable pour un portfolio.
|
||||||
|
- **Validation serveur** : longueurs (120 / 160 / 5000), regex email, codes d'erreur en `UPPER_SNAKE_CASE` stables.
|
||||||
|
|
||||||
|
### Email reçu
|
||||||
|
|
||||||
|
- Sujet : `Nouveau message portfolio — {Nom}`
|
||||||
|
- Expéditeur : `CONTACT_FROM_NAME <CONTACT_FROM_EMAIL>`
|
||||||
|
- Reply-To : email du visiteur (un clic "Répondre" dans Gmail et le mail part au visiteur).
|
||||||
|
- Corps HTML : carte Stitch simplifiée (primary/secondary/surface-container-low) avec table nom/email/date/IP + zone message `pre-wrap`.
|
||||||
|
|
||||||
|
### Leçons retenues
|
||||||
|
|
||||||
|
1. **Surveiller les pages admin "dev"** qui fuitent en prod : `/admin/messages` listait les emails de tous les visiteurs en clair, sans auth. Avec la page supprimée, plus d'exposition.
|
||||||
|
2. **Clé API fuitant en clair dans un chat** : considérer comme compromise et régénérer. Documenté dans `contact-flow.md` § "Sécurité — rappels".
|
||||||
|
3. **Pour un besoin "1 seul destinataire" avec faible volume**, une route Next + API transactionnelle bat clairement une solution SMTP + plugin Strapi : moins de pièces, moins de config, même garantie de délivrabilité.
|
||||||
|
|
||||||
|
Voir `docs-site-interne/contact-flow.md` pour la procédure de test, les codes d'erreur, et le rollback éventuel.
|
||||||
|
|
||||||
## 5. Checklist relecture (à passer à la fin de chaque étape)
|
## 5. Checklist relecture (à passer à la fin de chaque étape)
|
||||||
|
|
||||||
- [ ] Aucune colonne unique globale `max-w-xl` (c'est le format newsletter).
|
- [ ] Aucune colonne unique globale `max-w-xl` (c'est le format newsletter).
|
||||||
|
|||||||
@ -104,12 +104,13 @@ Les captures suivantes **n’ont pas révélé de problème spécifique** après
|
|||||||
| 18 | Contact formulaire mobile | `/contact` | `18-contact-formulaire-mobile.webp` | `fait` (étape 8, 2026-04-22) |
|
| 18 | Contact formulaire mobile | `/contact` | `18-contact-formulaire-mobile.webp` | `fait` (étape 8, 2026-04-22) |
|
||||||
| 19 | Footer desktop | `/` | `19-layout-footer-desktop.webp` | `fait` (étape 8, 2026-04-22) |
|
| 19 | Footer desktop | `/` | `19-layout-footer-desktop.webp` | `fait` (étape 8, 2026-04-22) |
|
||||||
| 20 | Compteur visites desktop | `/` | `20-layout-compteur-visites-desktop.webp` | `OK` |
|
| 20 | Compteur visites desktop | `/` | `20-layout-compteur-visites-desktop.webp` | `OK` |
|
||||||
| 21 | Admin messages desktop | `/admin/messages` | `21-admin-messages-desktop.webp` | `OK` |
|
| 21 | ~~Admin messages desktop~~ | ~~`/admin/messages`~~ | `21-admin-messages-desktop.webp` | `obsolète` (route supprimée le 2026-04-23, voir `contact-flow.md`) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Suite
|
## Suite
|
||||||
|
|
||||||
- **Étape 8 Digital Atelier bouclée** (2026-04-22) : contact + formulaire + footer migrés à la charte Stitch (voir `docs-site-interne/REFONTE-VISUELLE.md §8`).
|
- **Étape 8 Digital Atelier bouclée** (2026-04-22) : contact + formulaire + footer migrés à la charte Stitch (voir `docs-site-interne/REFONTE-VISUELLE.md §8`).
|
||||||
- Prendre de **nouvelles captures** 17 / 18 / 19 pour figer le rendu post-refonte (remplacement des WebP existants dans `docs-site-interne/captures/`).
|
- **Formulaire de contact rendu effectif** (2026-04-23) : envoi via **Brevo** au lieu de Strapi. Content-type `message` supprimé, page `/admin/messages` supprimée, honeypot + rate-limit ajoutés. Voir `docs-site-interne/contact-flow.md` et `REFONTE-VISUELLE.md §9`.
|
||||||
- Dette technique identifiée pour une future passe : fusion `Carousel.tsx` / `CarouselCompetences.tsx` (doublons), persistance serveur du compteur de visites, validation anti-spam du formulaire.
|
- Prendre de **nouvelles captures** 17 / 18 / 19 pour figer le rendu post-refonte (remplacement des WebP existants dans `docs-site-interne/captures/`). La capture 21 est désormais **obsolète** (route supprimée).
|
||||||
|
- Dette technique identifiée : fusion `Carousel.tsx` / `CarouselCompetences.tsx` (doublons), persistance serveur du compteur de visites. Anti-spam formulaire : **fait** (honeypot + rate-limit, 2026-04-23).
|
||||||
|
|||||||
186
docs-site-interne/contact-flow.md
Normal file
186
docs-site-interne/contact-flow.md
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
# Flux "Contact" — notification par email (Brevo)
|
||||||
|
|
||||||
|
**Créé :** 2026-04-23
|
||||||
|
**Statut :** en production
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
Le formulaire de contact du site envoie un **email de notification** sur la boîte Gmail personnelle (`grascalvet.fernand@gmail.com`), via l'**API HTTP transactionnelle de Brevo**.
|
||||||
|
|
||||||
|
**Plus aucun passage par Strapi pour les messages.** Le content-type `message` a été supprimé (ainsi que la page admin associée) le 2026-04-23. La table SQLite `messages` reste présente en base pour l'historique, mais n'est plus écrite ni lue.
|
||||||
|
|
||||||
|
```
|
||||||
|
Visiteur → /contact (Next.js front)
|
||||||
|
↓
|
||||||
|
POST /api/contact (Next.js App Router, server runtime)
|
||||||
|
↓
|
||||||
|
Validation + honeypot + rate-limit
|
||||||
|
↓
|
||||||
|
POST https://api.brevo.com/v3/smtp/email
|
||||||
|
↓
|
||||||
|
Gmail : CONTACT_TO_EMAIL
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pourquoi cette architecture
|
||||||
|
|
||||||
|
### Simplification par rapport à l'ancien flux
|
||||||
|
|
||||||
|
Avant : `Front → Strapi POST /api/messages → SQLite` + consultation via `/admin/messages` (page publique, non protégée).
|
||||||
|
|
||||||
|
Problèmes :
|
||||||
|
- **Pas de notification** : il fallait penser à consulter l'admin pour voir les nouveaux messages.
|
||||||
|
- **Page admin exposée** : `/admin/messages` accessible à n'importe qui en clair (pas d'authentification). Fuite potentielle d'emails privés des visiteurs.
|
||||||
|
- **Content-type dédié** pour un usage limité (3 champs, pas d'historique utile).
|
||||||
|
|
||||||
|
Après : notification directe sur Gmail, zéro stockage intermédiaire, zéro page à sécuriser.
|
||||||
|
|
||||||
|
### Pourquoi l'API HTTP Brevo plutôt que SMTP
|
||||||
|
|
||||||
|
Discussion menée le 2026-04-23. Résumé :
|
||||||
|
|
||||||
|
| Critère | SMTP | API HTTP |
|
||||||
|
|---------|------|----------|
|
||||||
|
| Simplicité plugin Strapi | ✓ | (custom) |
|
||||||
|
| Port potentiellement bloqué | oui | non (HTTPS 443) |
|
||||||
|
| Débogage JSON | non | ✓ |
|
||||||
|
| Notre besoin (1 endroit, 1 mail) | ok | ok |
|
||||||
|
|
||||||
|
Comme on ne passe plus par Strapi, l'avantage "plugin Strapi" disparaît. On a pris **l'API HTTP** pour rester 100 % en HTTPS (aucun souci de port) et avoir des erreurs JSON lisibles.
|
||||||
|
|
||||||
|
## Fichiers concernés
|
||||||
|
|
||||||
|
| Fichier | Rôle |
|
||||||
|
|---------|------|
|
||||||
|
| `app/api/contact/route.ts` | Endpoint serveur Next.js qui reçoit le form et appelle Brevo |
|
||||||
|
| `app/components/ContactForm.tsx` | UI côté client, honeypot, gestion du status |
|
||||||
|
| `.env.local` | Secrets + config (non committé) |
|
||||||
|
| `.env.example` | Template documentaire des variables à remplir |
|
||||||
|
|
||||||
|
## Variables d'environnement
|
||||||
|
|
||||||
|
À définir dans `.env.local` (voir `.env.example` pour la structure) :
|
||||||
|
|
||||||
|
| Variable | Description | Où l'obtenir |
|
||||||
|
|----------|-------------|--------------|
|
||||||
|
| `BREVO_API_KEY` | Clé API Brevo | [app.brevo.com → SMTP & API → API Keys](https://app.brevo.com/settings/keys/api) |
|
||||||
|
| `CONTACT_FROM_EMAIL` | Expéditeur **vérifié** | Senders, Domains & IPs → Senders (pastille SPF/DKIM verte) |
|
||||||
|
| `CONTACT_FROM_NAME` | Nom affiché dans Gmail | Texte libre, ex. "Portfolio — nouveau message" |
|
||||||
|
| `CONTACT_TO_EMAIL` | Destinataire final | L'adresse Gmail personnelle |
|
||||||
|
| `CONTACT_TO_NAME` | Nom du destinataire | Texte libre |
|
||||||
|
|
||||||
|
**L'expéditeur doit être vérifié dans Brevo** : sinon Brevo renvoie une erreur au `POST`. On peut réutiliser l'expéditeur newsletter (pas de cloisonnement côté Brevo) et surcharger le nom d'affichage via `CONTACT_FROM_NAME`.
|
||||||
|
|
||||||
|
## Anti-abus
|
||||||
|
|
||||||
|
### Honeypot
|
||||||
|
|
||||||
|
Un champ caché `website` est ajouté au formulaire :
|
||||||
|
|
||||||
|
- Invisible aux humains (`position: absolute; left: -10000px; width: 1px`).
|
||||||
|
- Masqué aux lecteurs d'écran (`aria-hidden="true"`).
|
||||||
|
- Ignoré par le clavier (`tabindex="-1"`, `autocomplete="off"`).
|
||||||
|
|
||||||
|
Les bots qui parsent le DOM remplissent systématiquement tous les champs texte. Côté serveur, si `website` est non vide :
|
||||||
|
|
||||||
|
- On **renvoie un 200 silencieux** (pas de signal d'erreur aux bots → ils ne retentent pas).
|
||||||
|
- On **n'envoie pas d'email**.
|
||||||
|
- On **logue** un warning avec l'IP pour monitoring.
|
||||||
|
|
||||||
|
### Rate-limit
|
||||||
|
|
||||||
|
`app/api/contact/route.ts` limite à **3 envois par IP par fenêtre de 10 minutes**, via une `Map` en mémoire :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const RATE_LIMIT_MAX = 3;
|
||||||
|
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
|
||||||
|
```
|
||||||
|
|
||||||
|
Au-delà : réponse `429 Too Many Requests` avec header `Retry-After`.
|
||||||
|
|
||||||
|
**Limite connue** : en déploiement serverless avec plusieurs instances (Vercel free, Cloudflare Workers), la `Map` n'est pas partagée — le rate-limit est "best effort". Pour un portfolio, c'est acceptable. Pour du vrai ship : passer à Redis ou [Upstash](https://upstash.com/) (wrapper compatible serverless).
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
Côté serveur (défense en profondeur, jamais faire confiance au client) :
|
||||||
|
|
||||||
|
- Champs requis non vides : `name`, `email`, `message`.
|
||||||
|
- Longueurs max : `name ≤ 120`, `email ≤ 160`, `message ≤ 5000` caractères (retourne `413`).
|
||||||
|
- Format email : regex `^[^\s@]+@[^\s@]+\.[^\s@]+$` (pragmatique, pas RFC 5322 stricte).
|
||||||
|
|
||||||
|
Les codes d'erreur renvoyés au client sont en `UPPER_SNAKE_CASE` (stables pour l'i18n future) : `MISSING_FIELDS`, `INVALID_EMAIL`, `TOO_LONG`, `RATE_LIMITED`, `BREVO_ERROR`, `BREVO_UNREACHABLE`, `SERVER_MISCONFIGURED`, `INVALID_JSON`.
|
||||||
|
|
||||||
|
## Format de l'email reçu
|
||||||
|
|
||||||
|
- **Sujet** : `Nouveau message portfolio — {Nom du visiteur}`
|
||||||
|
- **Expéditeur** : `{CONTACT_FROM_NAME} <{CONTACT_FROM_EMAIL}>`
|
||||||
|
- **Reply-To** : email du visiteur → cliquer "Répondre" dans Gmail écrit directement au visiteur.
|
||||||
|
- **Corps HTML** : carte Stitch simplifiée (palette primary / secondary) avec table récap (nom, email, date, IP) + zone message `pre-wrap`.
|
||||||
|
- **Corps texte** (fallback) : version plain text équivalente.
|
||||||
|
- **Tags Brevo** : `["portfolio", "contact-form"]` pour filtrer dans les statistiques Brevo si besoin.
|
||||||
|
|
||||||
|
## Procédure de test
|
||||||
|
|
||||||
|
### Local (développement)
|
||||||
|
|
||||||
|
1. S'assurer que `.env.local` contient les 5 variables requises + une clé Brevo valide.
|
||||||
|
2. `npm run dev` dans le repo Next (port 3000).
|
||||||
|
3. `/contact` → remplir le formulaire avec une **adresse email qu'on contrôle** (pour vérifier le Reply-To).
|
||||||
|
4. Vérifier :
|
||||||
|
- Feedback visuel "Message envoyé" en succès Stitch vert.
|
||||||
|
- Réception dans Gmail avec expéditeur = `CONTACT_FROM_NAME`.
|
||||||
|
- Clic "Répondre" → destinataire = l'email saisi dans le formulaire.
|
||||||
|
|
||||||
|
### Honeypot
|
||||||
|
|
||||||
|
1. Ouvrir la console DevTools, onglet Elements.
|
||||||
|
2. Trouver l'input caché `<input name="website">` (dans un div `position: absolute; left: -10000px`).
|
||||||
|
3. Y saisir une valeur (ex. "bot").
|
||||||
|
4. Soumettre le formulaire normalement.
|
||||||
|
5. Résultat attendu : UI affiche "Message envoyé" (succès silencieux) MAIS **aucun email n'arrive** dans Gmail.
|
||||||
|
6. Vérifier le warning serveur dans les logs Next (`[/api/contact] Honeypot déclenché`).
|
||||||
|
|
||||||
|
### Rate-limit
|
||||||
|
|
||||||
|
1. Envoyer un formulaire valide 4 fois de suite (modifier juste le message à chaque fois).
|
||||||
|
2. Au 4ᵉ envoi : réponse `429`, UI affiche "Trop d'envois depuis votre IP. Réessayez dans quelques minutes."
|
||||||
|
3. Attendre 10 min puis réessayer → doit repasser.
|
||||||
|
|
||||||
|
## Nettoyage de l'ancienne table SQLite (optionnel)
|
||||||
|
|
||||||
|
La table `messages` existe toujours dans `cmsbackend/.tmp/data.db` (ou le fichier configuré). Strapi ne la touche plus. Pour la supprimer proprement :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Arrêter Strapi d'abord !
|
||||||
|
cd cmsbackend
|
||||||
|
sqlite3 .tmp/data.db "DROP TABLE IF EXISTS messages;"
|
||||||
|
sqlite3 .tmp/data.db "DROP TABLE IF EXISTS messages_cmps;"
|
||||||
|
# Reprise normale
|
||||||
|
npm run develop
|
||||||
|
```
|
||||||
|
|
||||||
|
Non fait par défaut : la table reste orpheline mais inoffensive. Ça évite toute régression en cas de rollback éventuel.
|
||||||
|
|
||||||
|
## Quotas Brevo
|
||||||
|
|
||||||
|
Le **plan gratuit Brevo** inclut traditionnellement **300 emails transactionnels par jour**, partagés avec la newsletter. Pour un portfolio qui reçoit 0 à 5 messages par semaine, c'est largement suffisant.
|
||||||
|
|
||||||
|
Si le site reçoit un jour beaucoup plus de trafic (ou si Brevo change ses quotas gratuits), surveiller :
|
||||||
|
|
||||||
|
- **Brevo → Transactional → Statistics** : nombre d'envois, taux d'erreur.
|
||||||
|
- **Brevo → SMTP & API → Statistics** : quota quotidien consommé.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Si le flux Brevo tombe en panne (compte suspendu, quota dépassé, clé compromise) et qu'on veut **temporairement** retomber sur le flux Strapi ancien :
|
||||||
|
|
||||||
|
1. `git checkout <commit-pre-refonte-contact>` sur les fichiers : `app/api/contact/route.ts` (supprimer), `app/components/ContactForm.tsx` (restaurer `sendMessage`), `app/utils/sendMessage.ts` (restaurer), `cmsbackend/src/api/message/` (restaurer les 4 fichiers), et `app/admin/messages/page.tsx` si on veut la page de consultation.
|
||||||
|
2. Re-démarrer Strapi pour qu'il régénère le content-type en mémoire.
|
||||||
|
3. Vérifier les **permissions publiques** sur `POST /api/messages` dans l'admin Strapi (peut avoir besoin d'être recoché).
|
||||||
|
|
||||||
|
Préférable : **renouveler la clé Brevo** plutôt que rollback.
|
||||||
|
|
||||||
|
## Sécurité — rappels
|
||||||
|
|
||||||
|
- **Ne jamais committer `.env.local`**. Déjà dans `.gitignore` (`.env*`).
|
||||||
|
- **Si la clé Brevo fuit** (exposée dans un commit, un chat, un screenshot) : la **supprimer immédiatement** sur Brevo et en générer une nouvelle. Une clé compromise permet d'envoyer des emails frauduleux depuis l'expéditeur vérifié, ce qui peut faire blacklister le domaine.
|
||||||
|
- L'**IP du visiteur** est loguée dans l'email (pour abus). À supprimer des emails si on veut un template plus "commercial".
|
||||||
@ -1,24 +1,104 @@
|
|||||||
# Configuration des variables d'environnement
|
# Script de démarrage du portfolio — lance les 3 services dans des fenêtres PowerShell séparées.
|
||||||
$env:NEXT_PUBLIC_API_URL = "https://api.fernandgrascalvet.com"
|
#
|
||||||
$env:PUBLIC_URL = "https://api.fernandgrascalvet.com"
|
# Pendant inverse : stop-my-site.ps1 (arrête proprement les services par port).
|
||||||
|
#
|
||||||
|
# Services lancés :
|
||||||
|
# - Strapi (CMS) → port 1337 → cmsbackend/ (npm run develop)
|
||||||
|
# - Next.js (site) → port 3000 → racine (npm run dev)
|
||||||
|
# - FastAPI (GrasBot) → port 8000 → llm-api/ (uvicorn api:app)
|
||||||
|
#
|
||||||
|
# Variables d'environnement propagées aux sous-shells :
|
||||||
|
# - NEXT_PUBLIC_API_URL / PUBLIC_URL = https://api.fernandgrascalvet.com
|
||||||
|
#
|
||||||
|
# Vérifications automatiques :
|
||||||
|
# - Si un port est déjà occupé, on ne relance pas le service concerné
|
||||||
|
# (évite l'erreur "address already in use" et le doublon de fenêtre).
|
||||||
|
# - Si Start-Process échoue, on continue avec les autres services et on
|
||||||
|
# consigne l'échec dans le bilan final.
|
||||||
|
#
|
||||||
|
# Portabilité :
|
||||||
|
# - $PSScriptRoot résout la racine du repo à partir du script → pas de chemin
|
||||||
|
# codé en dur. Le script marche donc même si le repo est cloné ailleurs.
|
||||||
|
#
|
||||||
|
# Usage :
|
||||||
|
# PS > .\start-my-site.ps1
|
||||||
|
|
||||||
Write-Host "🚀 Démarrage du site avec configuration HTTPS..." -ForegroundColor Green
|
$root = $PSScriptRoot
|
||||||
Write-Host "📡 API URL: $env:NEXT_PUBLIC_API_URL" -ForegroundColor Cyan
|
$apiUrl = "https://api.fernandgrascalvet.com"
|
||||||
|
|
||||||
# Lancer Strapi avec configuration HTTPS
|
$services = @(
|
||||||
Write-Host "🔧 Démarrage de Strapi..." -ForegroundColor Yellow
|
@{
|
||||||
Start-Process powershell -ArgumentList "cd 'J:\my-next-site\cmsbackend'; `$env:PUBLIC_URL='https://api.fernandgrascalvet.com'; npm run develop" -WindowStyle Normal
|
Nom = "Strapi (CMS)"
|
||||||
|
Port = 1337
|
||||||
|
Cwd = Join-Path $root "cmsbackend"
|
||||||
|
Commande = "`$env:PUBLIC_URL='$apiUrl'; npm run develop"
|
||||||
|
},
|
||||||
|
@{
|
||||||
|
Nom = "Next.js (site)"
|
||||||
|
Port = 3000
|
||||||
|
Cwd = $root
|
||||||
|
Commande = "`$env:NEXT_PUBLIC_API_URL='$apiUrl'; npm run dev"
|
||||||
|
},
|
||||||
|
@{
|
||||||
|
Nom = "FastAPI (GrasBot)"
|
||||||
|
Port = 8000
|
||||||
|
Cwd = Join-Path $root "llm-api"
|
||||||
|
Commande = "uvicorn api:app --host 0.0.0.0 --port 8000"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Attendre un peu pour que Strapi démarre
|
Write-Host "🚀 Démarrage des services du portfolio..." -ForegroundColor Green
|
||||||
Start-Sleep -Seconds 3
|
Write-Host " API URL : $apiUrl" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
# Lancer Next.js
|
$lances = 0
|
||||||
Write-Host "⚡ Démarrage de Next.js..." -ForegroundColor Yellow
|
$dejaActifs = 0
|
||||||
Start-Process powershell -ArgumentList "cd 'J:\my-next-site'; `$env:NEXT_PUBLIC_API_URL='https://api.fernandgrascalvet.com'; npm run dev" -WindowStyle Normal
|
$echecs = 0
|
||||||
|
|
||||||
# Lancer FastAPI
|
foreach ($s in $services) {
|
||||||
Write-Host "🤖 Démarrage de FastAPI..." -ForegroundColor Yellow
|
# Si le port est déjà écouté, le service tourne sans doute déjà — on saute.
|
||||||
Start-Process powershell -ArgumentList "cd 'J:\my-next-site\llm-api'; uvicorn api:app --host 0.0.0.0 --port 8000" -WindowStyle Normal
|
$existing = Get-NetTCPConnection -LocalPort $s.Port -State Listen -ErrorAction SilentlyContinue
|
||||||
|
if ($existing) {
|
||||||
|
Write-Host " ⚪ $($s.Nom) — port $($s.Port) déjà occupé, pas de relance" -ForegroundColor Gray
|
||||||
|
$dejaActifs++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host "✅ Tous les services sont en cours de démarrage!" -ForegroundColor Green
|
try {
|
||||||
|
Write-Host " 🔵 $($s.Nom) — port $($s.Port) → $($s.Cwd)" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Construction de la commande passée au sous-shell : on se place dans
|
||||||
|
# le bon répertoire, puis on lance la commande du service. -NoExit
|
||||||
|
# laisse la fenêtre ouverte même si la commande se termine (utile pour
|
||||||
|
# voir le message d'erreur si un service crashe au démarrage).
|
||||||
|
$fullCommand = "Set-Location -LiteralPath '$($s.Cwd)'; $($s.Commande)"
|
||||||
|
Start-Process powershell `
|
||||||
|
-ArgumentList @('-NoExit', '-Command', $fullCommand) `
|
||||||
|
-WindowStyle Normal `
|
||||||
|
-ErrorAction Stop
|
||||||
|
|
||||||
|
Write-Host " ✅ lancé" -ForegroundColor Green
|
||||||
|
$lances++
|
||||||
|
|
||||||
|
# Petite respiration entre les services pour étaler la charge CPU au
|
||||||
|
# boot (Strapi et Next sont tous deux gourmands au premier démarrage).
|
||||||
|
Start-Sleep -Milliseconds 800
|
||||||
|
} catch {
|
||||||
|
Write-Host " ⚠️ impossible de lancer : $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
$echecs++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "── Bilan ─────────────────────────────" -ForegroundColor Cyan
|
||||||
|
Write-Host " Services lancés : $lances" -ForegroundColor Green
|
||||||
|
Write-Host " Déjà actifs : $dejaActifs" -ForegroundColor Gray
|
||||||
|
if ($echecs -gt 0) {
|
||||||
|
Write-Host " Échecs : $echecs" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($lances -gt 0 -or $dejaActifs -ge $services.Count) {
|
||||||
|
Write-Host ""
|
||||||
Write-Host "🌐 Site accessible sur : http://localhost:3000" -ForegroundColor Cyan
|
Write-Host "🌐 Site accessible sur : http://localhost:3000" -ForegroundColor Cyan
|
||||||
|
Write-Host " (attendre ~15 s que Strapi et Next soient prêts au premier lancement)" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|||||||
72
stop-my-site.ps1
Normal file
72
stop-my-site.ps1
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
# Script inverse de start-my-site.ps1 — arrête proprement les 3 services du portfolio.
|
||||||
|
#
|
||||||
|
# Stratégie : on identifie le processus qui écoute chaque port attendu, puis on
|
||||||
|
# le termine. C'est plus sûr que `Stop-Process -Name node` qui tuerait TOUS les
|
||||||
|
# node.exe de la machine (y compris d'autres projets que tu aurais ouverts).
|
||||||
|
#
|
||||||
|
# Ports surveillés :
|
||||||
|
# - 1337 → Strapi (CMS)
|
||||||
|
# - 3000 → Next.js (site)
|
||||||
|
# - 8000 → FastAPI / uvicorn (GrasBot LLM)
|
||||||
|
#
|
||||||
|
# Ce script ne ferme PAS les fenêtres PowerShell elles-mêmes : elles restent
|
||||||
|
# ouvertes avec un prompt une fois le processus enfant terminé. Tu peux les
|
||||||
|
# fermer manuellement ou ajouter un alias "exit" dans ton profil PowerShell.
|
||||||
|
#
|
||||||
|
# Usage :
|
||||||
|
# PS > .\stop-my-site.ps1
|
||||||
|
|
||||||
|
$services = @(
|
||||||
|
@{ Nom = "Strapi (CMS)"; Port = 1337 },
|
||||||
|
@{ Nom = "Next.js (site)"; Port = 3000 },
|
||||||
|
@{ Nom = "FastAPI (GrasBot)"; Port = 8000 }
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-Host "🛑 Arrêt des services du portfolio..." -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
$arretes = 0
|
||||||
|
$nonTrouves = 0
|
||||||
|
$echecs = 0
|
||||||
|
|
||||||
|
foreach ($s in $services) {
|
||||||
|
# -ErrorAction SilentlyContinue : Get-NetTCPConnection lève une erreur
|
||||||
|
# bruyante si aucun processus n'écoute le port. On préfère gérer nous-mêmes.
|
||||||
|
$conn = Get-NetTCPConnection -LocalPort $s.Port -State Listen -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
if ($null -eq $conn) {
|
||||||
|
Write-Host " ⚪ $($s.Nom) — port $($s.Port) libre, rien à arrêter" -ForegroundColor Gray
|
||||||
|
$nonTrouves++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Un même processus peut exposer le port en IPv4 et IPv6 (2 lignes, même PID).
|
||||||
|
$procIds = $conn | Select-Object -ExpandProperty OwningProcess -Unique
|
||||||
|
|
||||||
|
foreach ($procId in $procIds) {
|
||||||
|
try {
|
||||||
|
$proc = Get-Process -Id $procId -ErrorAction Stop
|
||||||
|
Write-Host " 🔴 $($s.Nom) — port $($s.Port) → PID $procId ($($proc.ProcessName))" -ForegroundColor Red
|
||||||
|
Stop-Process -Id $procId -Force -ErrorAction Stop
|
||||||
|
Write-Host " ✅ arrêté" -ForegroundColor Green
|
||||||
|
$arretes++
|
||||||
|
} catch {
|
||||||
|
Write-Host " ⚠️ impossible d'arrêter le PID $procId : $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
|
$echecs++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "── Bilan ─────────────────────────────" -ForegroundColor Cyan
|
||||||
|
Write-Host " Services arrêtés : $arretes" -ForegroundColor Green
|
||||||
|
Write-Host " Déjà libres : $nonTrouves" -ForegroundColor Gray
|
||||||
|
if ($echecs -gt 0) {
|
||||||
|
Write-Host " Échecs : $echecs" -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "💡 Échecs possibles : droits administrateur requis pour tuer certains processus." -ForegroundColor Yellow
|
||||||
|
Write-Host " Relance le script dans un PowerShell admin si besoin." -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "✅ Tous les services ciblés ont été traités." -ForegroundColor Green
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user