diff --git a/CONFIGURATION_SITE.md b/CONFIGURATION_SITE.md index 134aaec..47275b0 100644 --- a/CONFIGURATION_SITE.md +++ b/CONFIGURATION_SITE.md @@ -16,6 +16,7 @@ my-next-site/ ├── cmsbackend/ # Backend Strapi ├── llm-api/ # API FastAPI pour IA ├── start-my-site.ps1 # Script de démarrage +├── stop-my-site.ps1 # Script d'arrêt propre └── 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. +**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 ### 1. Frontend Next.js diff --git a/app/admin/messages/page.tsx b/app/admin/messages/page.tsx deleted file mode 100644 index 7ed4721..0000000 --- a/app/admin/messages/page.tsx +++ /dev/null @@ -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 ( -
- {/* Titre de la page */} -

📬 Messages reçus

- - {data.length === 0 ? ( -

Aucun message reçu.

- ) : ( - - )} -
- ); - } \ No newline at end of file diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts new file mode 100644 index 0000000..e84ae0b --- /dev/null +++ b/app/api/contact/route.ts @@ -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` 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(); +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, "'"); +} + +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, "
"); + + 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 = ` + + + +
+

+ Nouveau message portfolio +

+ + + + + + + + + + + + + + + + + +
Nom${safeName}
Email${safeEmail}
Date${dateTime}
IP${escapeHtml(input.ip)}
+
+
Message
+
${safeMessage}
+
+

+ Répondre directement à ce mail renverra vers ${safeEmail}. +

+
+ + +`.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 }); +} diff --git a/app/components/ContactForm.tsx b/app/components/ContactForm.tsx index 47076f3..433d494 100644 --- a/app/components/ContactForm.tsx +++ b/app/components/ContactForm.tsx @@ -1,23 +1,33 @@ "use client"; 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 - * monté dans la carte vellum de `app/contact/page.js`. - * - 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 - * `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`, - * chargement en `surface-container`. Chaque état porte une Material Symbol. + * Architecture : + * Form → POST /api/contact (Next.js server route, voir app/api/contact/route.ts) + * ↓ + * Brevo API HTTP → Gmail + * + * Plus de passage par Strapi pour les messages : la route serveur valide, + * 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() { const [name, setName] = useState(""); const [email, setEmail] = 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 [statusKind, setStatusKind] = useState< "idle" | "loading" | "success" | "error" @@ -44,24 +54,55 @@ export default function ContactForm() { setStatusKind("loading"); 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."); setStatusKind("success"); setName(""); setEmail(""); setMessage(""); + setWebsite(""); } 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"); } }; const statusStyles: Record = { idle: "", - loading: - "bg-surface-container text-on-surface-variant", - success: - "bg-primary-fixed/70 text-on-primary-fixed", + loading: "bg-surface-container text-on-surface-variant", + success: "bg-primary-fixed/70 text-on-primary-fixed", error: "bg-error-container text-on-error-container", }; @@ -77,6 +118,32 @@ export default function ContactForm() { return (
+ {/* 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. */} + + @@ -103,6 +171,7 @@ export default function ContactForm() { onChange={(e) => setEmail(e.target.value)} className={fieldClass} required + maxLength={160} autoComplete="email" /> @@ -116,6 +185,7 @@ export default function ContactForm() { value={message} onChange={(e) => setMessage(e.target.value)} rows={5} + maxLength={5000} className={`${fieldClass} min-h-[9rem] resize-y`} required /> diff --git a/app/utils/sendMessage.ts b/app/utils/sendMessage.ts deleted file mode 100644 index 2ad6dff..0000000 --- a/app/utils/sendMessage.ts +++ /dev/null @@ -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; -} diff --git a/docs-site-interne/02-frontend-next.md b/docs-site-interne/02-frontend-next.md index 0219797..b7bbeaa 100644 --- a/docs-site-interne/02-frontend-next.md +++ b/docs-site-interne/02-frontend-next.md @@ -17,8 +17,8 @@ | `/portfolio/[slug]` | `app/portfolio/[slug]/page.tsx` | Détail projet (`fetchData('projects', slug)`) | | `/competences` | `app/competences/page.jsx` | Liste compétences | | `/competences/[slug]` | `app/competences/[slug]/page.tsx` | Détail (`fetchData('competences', slug)`) | -| `/contact` | `app/contact/page.js` | Formulaire → Strapi `messages` | -| `/admin/messages` | `app/admin/messages/page.tsx` | Consultation messages (côté Next) | +| `/contact` | `app/contact/page.js` | Formulaire → `/api/contact` (Brevo, voir `contact-flow.md`) | +| `/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 | ## Layout @@ -42,7 +42,7 @@ - Carrousels : `Carousel.tsx`, `CarouselCompetences.tsx` (swiper / react-responsive-carousel). - 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`. - `ModalGlossaire.tsx` — glossaire (données Strapi selon usage dans les pages). @@ -53,6 +53,6 @@ app/layout.tsx app/page.tsx app/utils/getApiUrl.ts app/utils/fetchData.ts -app/utils/sendMessage.ts +app/api/contact/route.ts next.config.ts ``` diff --git a/docs-site-interne/03-cms-strapi.md b/docs-site-interne/03-cms-strapi.md index 8ac00b6..070278e 100644 --- a/docs-site-interne/03-cms-strapi.md +++ b/docs-site-interne/03-cms-strapi.md @@ -43,15 +43,9 @@ Utilisation front : `app/page.tsx` — premier enregistrement `populate=*`, imag | `slug` | uid ← `name` | requis | | `order` | integer | optionnel | -### `message` (collection `messages`) +### `message` (supprimé le 2026-04-23) -| Champ | Type | Notes | -|-------|------|--------| -| `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). +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). ### `glossaire` (collection `glossaires`) diff --git a/docs-site-interne/REFONTE-VISUELLE.md b/docs-site-interne/REFONTE-VISUELLE.md index d552c28..1a947f8 100644 --- a/docs-site-interne/REFONTE-VISUELLE.md +++ b/docs-site-interne/REFONTE-VISUELLE.md @@ -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. - **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= +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 ` +- 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) - [ ] Aucune colonne unique globale `max-w-xl` (c'est le format newsletter). diff --git a/docs-site-interne/captures/AUDIT-VISUEL.md b/docs-site-interne/captures/AUDIT-VISUEL.md index 76fe758..8d5c2bc 100644 --- a/docs-site-interne/captures/AUDIT-VISUEL.md +++ b/docs-site-interne/captures/AUDIT-VISUEL.md @@ -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) | | 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` | -| 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 - **É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/`). -- 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. +- **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`. +- 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). diff --git a/docs-site-interne/contact-flow.md b/docs-site-interne/contact-flow.md new file mode 100644 index 0000000..7ab84b5 --- /dev/null +++ b/docs-site-interne/contact-flow.md @@ -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é `` (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 ` 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". diff --git a/start-my-site.ps1 b/start-my-site.ps1 index 99e6bac..e78b618 100644 --- a/start-my-site.ps1 +++ b/start-my-site.ps1 @@ -1,24 +1,104 @@ -# Configuration des variables d'environnement -$env:NEXT_PUBLIC_API_URL = "https://api.fernandgrascalvet.com" -$env:PUBLIC_URL = "https://api.fernandgrascalvet.com" +# Script de démarrage du portfolio — lance les 3 services dans des fenêtres PowerShell séparées. +# +# 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 -Write-Host "📡 API URL: $env:NEXT_PUBLIC_API_URL" -ForegroundColor Cyan +$root = $PSScriptRoot +$apiUrl = "https://api.fernandgrascalvet.com" -# Lancer Strapi avec configuration HTTPS -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 +$services = @( + @{ + 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 -Start-Sleep -Seconds 3 +Write-Host "🚀 Démarrage des services du portfolio..." -ForegroundColor Green +Write-Host " API URL : $apiUrl" -ForegroundColor Cyan +Write-Host "" -# Lancer Next.js -Write-Host "⚡ Démarrage de Next.js..." -ForegroundColor Yellow -Start-Process powershell -ArgumentList "cd 'J:\my-next-site'; `$env:NEXT_PUBLIC_API_URL='https://api.fernandgrascalvet.com'; npm run dev" -WindowStyle Normal +$lances = 0 +$dejaActifs = 0 +$echecs = 0 -# Lancer FastAPI -Write-Host "🤖 Démarrage de FastAPI..." -ForegroundColor Yellow -Start-Process powershell -ArgumentList "cd 'J:\my-next-site\llm-api'; uvicorn api:app --host 0.0.0.0 --port 8000" -WindowStyle Normal +foreach ($s in $services) { + # Si le port est déjà écouté, le service tourne sans doute déjà — on saute. + $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 -Write-Host "🌐 Site accessible sur: http://localhost:3000" -ForegroundColor Cyan \ No newline at end of file + 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 " (attendre ~15 s que Strapi et Next soient prêts au premier lancement)" -ForegroundColor Gray +} diff --git a/stop-my-site.ps1 b/stop-my-site.ps1 new file mode 100644 index 0000000..0880dde --- /dev/null +++ b/stop-my-site.ps1 @@ -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 +}