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
|
||||
├── 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
|
||||
|
||||
@ -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";
|
||||
|
||||
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<typeof statusKind, string> = {
|
||||
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 (
|
||||
<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">
|
||||
<span className="font-headline text-[11px] font-bold uppercase tracking-[0.3em] text-secondary">
|
||||
Votre nom
|
||||
@ -88,6 +155,7 @@ export default function ContactForm() {
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={fieldClass}
|
||||
required
|
||||
maxLength={120}
|
||||
autoComplete="name"
|
||||
/>
|
||||
</label>
|
||||
@ -103,6 +171,7 @@ export default function ContactForm() {
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={fieldClass}
|
||||
required
|
||||
maxLength={160}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</label>
|
||||
@ -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
|
||||
/>
|
||||
|
||||
@ -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)`) |
|
||||
| `/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
|
||||
```
|
||||
|
||||
@ -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`)
|
||||
|
||||
|
||||
@ -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=<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)
|
||||
|
||||
- [ ] 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) |
|
||||
| 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).
|
||||
|
||||
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
|
||||
$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
|
||||
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
|
||||
}
|
||||
|
||||
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