Portfolio mprost.fr — Conception, Sécurisation et Hébergement d'un site Next.js
Conception et mise en ligne de mon portfolio professionnel avec Next.js et MDX, hébergé sur Plesk. Le formulaire de contact est protégé côté serveur : CAPTCHA Cloudflare Turnstile, limitation de débit, honeypot, contrôle d'origine, secrets en variables d'environnement et journal de sécurité. Le projet intègre aussi la conformité RGPD et le suivi SEO.
Portfolio mprost.fr
Introduction
Ce site est mon portfolio professionnel. Il regroupe ma présentation, mon CV, mon parcours, mes documentations techniques et mes projets. Je l'ai développé moi-même, de la conception à l'hébergement, afin de maîtriser toute la chaîne : code, déploiement, sécurité et référencement.
Le site est volontairement léger et statique pour l'essentiel. Seul le formulaire de contact passe par une route serveur, ce qui m'a permis de travailler les enjeux de sécurité d'une application web exposée sur Internet : protection contre le spam et les abus, gestion des secrets, traçabilité des événements et protection des données personnelles.
Objectifs
- Présenter mon profil, mon CV et mes réalisations dans un site unique et professionnel.
- Publier mes documentations techniques et mes projets sans toucher au code : ajouter un fichier suffit.
- Protéger le formulaire de contact contre les robots, le spam et l'épuisement de mon quota d'envoi.
- Garder une trace des événements de sécurité pour détecter les abus et diagnostiquer les pannes.
- Respecter le RGPD : peu de données collectées, information claire, aucun traceur publicitaire.
- Suivre la visibilité et la performance du site (Google Search Console, Lighthouse).
Stack technique
| Domaine | Technologies | Rôle |
|---|---|---|
| Framework | Next.js 16 (App Router), React 19, TypeScript | Pages, routage, rendu serveur, route API |
| Interface | Tailwind CSS, Tailwind Typography, Framer Motion, Lucide | Design, animations, icônes |
| Thème | next-themes | Mode clair et sombre |
| Contenu | MDX (next-mdx-remote), gray-matter | Projets et documentations écrits en fichiers MDX avec métadonnées |
| Veille | rss-parser | Composant de veille technologique alimenté par des flux RSS |
| Anti-robot | Cloudflare Turnstile | Vérification que l'expéditeur du formulaire est un humain |
| Messagerie du formulaire | Formspree (relais d'emails) | Acheminement des messages reçus |
| Hébergement | Plesk, Node.js | Exécution de l'application en production |
Architecture
Organisation du contenu
Chaque projet ou documentation est un fichier .mdx placé dans le dossier content/projects. L'en-tête du fichier (titre, description, date, tags, liens) alimente automatiquement :
- la carte affichée sur la page Projets ;
- l'en-tête de la page de détail du projet ;
- les pages statiques, générées au build à partir de la liste des fichiers.
Ajouter un projet ne demande donc aucune modification de code : un nouveau fichier suffit. Des composants React peuvent aussi être insérés dans le contenu, comme le composant de veille automatique.
Pages du site
- Accueil, avec la section des compétences.
- À propos, avec la formation et l'expérience.
- Projets et Docs, alimentés par les fichiers MDX.
- Contact, avec le formulaire.
- Mentions légales et politique de confidentialité.
Parcours d'un message de contact
- Le visiteur remplit le formulaire et valide la vérification anti-robot Turnstile.
- La page envoie les données à la route serveur
/api/contactde mon site, jamais directement à un service externe. - La route applique ses contrôles, dans cet ordre : origine, limite par IP, validation des champs, honeypot, CAPTCHA, plafond global.
- Si tout est valide, le serveur relaie le message à Formspree, qui me le transmet par email.
- Chaque étape importante est consignée dans le journal de sécurité.
Sécurité
La sécurité a été pensée dès la conception, en particulier autour du formulaire de contact, seule partie du site qui reçoit des données de visiteurs.
Un formulaire protégé côté serveur
À l'origine, la page appelait directement Formspree depuis le navigateur. Cette méthode expose l'adresse du service : n'importe qui peut l'appeler en boucle sans passer par mon site, et consommer tout mon quota. Une limitation côté page n'aurait rien changé, puisqu'elle se contourne en appelant directement l'adresse.
J'ai donc déplacé l'envoi dans une route serveur (/api/contact). Le visiteur n'appelle que mon site, et c'est le serveur qui relaie le message. L'adresse du service est stockée dans une variable d'environnement serveur.
Mesures mises en place
| Risque | Mesure |
|---|---|
| Robots et envois automatisés | CAPTCHA Cloudflare Turnstile, dont le jeton est vérifié côté serveur auprès de Cloudflare avant tout envoi |
| Envois répétés par une même personne | Limitation par adresse IP : 3 messages maximum toutes les 10 minutes |
| Épuisement du quota d'envoi | Plafond global de 20 messages par heure, toutes IP confondues, qui reste efficace même si une IP est falsifiée |
| Robots de spam simples | Champ piège invisible (honeypot) : si un robot le remplit, la requête est ignorée sans l'alerter |
| Appels depuis un autre site | Vérification de l'en-tête Origin : seul mprost.fr est accepté |
| Exposition des secrets | L'adresse du formulaire et la clé secrète Turnstile sont des variables d'environnement serveur, jamais préfixées NEXT_PUBLIC pour ne pas être envoyées au navigateur |
| Données invalides ou démesurées | Validation côté serveur : longueur maximale des champs, format de l'email, taille maximale du jeton |
| Panne du service anti-robot | Fonctionnement en échec fermé : si Cloudflare est injoignable ou trop lent, le message est refusé plutôt qu'accepté sans contrôle |
| Pages sans intérêt dans les moteurs de recherche | Balise noindex sur la politique de confidentialité |
| Interception des échanges | Connexion HTTPS / TLS |
Le CAPTCHA : pourquoi la vérification serveur est indispensable
Le widget Turnstile affiché dans la page ne protège rien à lui seul : un attaquant peut envoyer n'importe quelle chaîne de caractères à la route sans jamais passer par la page. Un jeton n'a de valeur que s'il est validé par le serveur auprès de Cloudflare, qui indique s'il est authentique. Un jeton est valable 5 minutes et ne peut servir qu'une seule fois. La page en redemande donc un nouveau après chaque envoi.
En production, le serveur vérifie aussi que le jeton a été émis pour le domaine mprost.fr.
Principe de la limitation de débit
Le serveur conserve, pour chaque IP, l'horodatage des dernières requêtes. À chaque envoi, il ne garde que celles qui sont dans la fenêtre de temps, puis compare leur nombre à la limite :
const recent = (hits.get(key) ?? []).filter((t) => now - t < windowMs);
if (recent.length >= limit) {
// Trop de requêtes : code 429 et en-tête Retry-After
}
Lorsque la limite est atteinte, la route répond par un code 429 (Too Many Requests) avec un en-tête Retry-After. La page affiche alors un message invitant à patienter, distinct d'une erreur technique et distinct d'un échec du CAPTCHA.
Limites assumées
- Les compteurs de limitation sont en mémoire : ils sont remis à zéro à chaque redémarrage de l'application. C'est suffisant pour un portfolio mono-instance.
- L'en-tête Origin filtre les usages depuis un navigateur, mais un script peut le falsifier. C'est pour cela que le CAPTCHA, le plafond global et le honeypot complètent le dispositif.
- La limite par IP dépend de la configuration du serveur web frontal, qui transmet l'adresse réelle du visiteur.
- Un visiteur dont un bloqueur de contenu empêche le chargement de Turnstile ne peut pas envoyer de message. Son adresse email reste affichée sur la page de contact.
Journalisation et détection
Pour savoir ce qui se passe réellement sur le formulaire, la route enregistre chaque événement dans un journal.
Fonctionnement
- Un fichier par jour :
logs/contact-AAAA-MM-JJ.log. - Une ligne JSON par événement, avec la date, le type d'événement, l'adresse IP et le navigateur.
- Les événements sont aussi écrits dans les logs Node.js de Plesk, avec le préfixe
[contact]. - Les fichiers de plus de 30 jours sont supprimés automatiquement.
- Le dossier
logsn'est pas dans le dossierpublicde Next.js, donc il n'est pas servi aux visiteurs. - Une erreur d'écriture du journal ne bloque jamais le formulaire.
Exemple de ligne, avec une adresse fictive :
"ts":"2026-10-17T15:42:10.000Z","event":"captcha_failed","ip":"203.0.113.7","codes":["timeout-or-duplicate"]
Événements consignés
| Événement | Signification | Utilité |
|---|---|---|
| sent | Message transmis avec succès | Vérifier le bon fonctionnement |
| rate_limited_ip / rate_limited_global | Limite atteinte | Repérer une rafale d'envois |
| captcha_failed / captcha_missing | Vérification refusée ou absente, avec le code d'erreur Cloudflare | Repérer les robots et les appels directs à la route |
| captcha_unreachable | Cloudflare injoignable | Diagnostiquer une panne du service anti-robot |
| honeypot | Un robot a rempli le champ piège | Mesurer le spam automatisé |
| bad_origin | Appel depuis un autre site | Repérer les tentatives de détournement |
| invalid_fields / invalid_body | Données invalides | Repérer des requêtes malformées |
| formspree_error / formspree_unreachable | Problème côté service d'emails | Diagnostiquer une panne d'acheminement |
| config_error | Variable d'environnement manquante | Diagnostiquer une erreur de configuration |
Données consignées et vie privée
Une adresse IP étant une donnée personnelle, le journal est limité au strict nécessaire : le nom, l'adresse email et le contenu du message n'y figurent jamais. La durée de conservation est limitée à 30 jours, et la politique de confidentialité du site décrit ce traitement, sa finalité (protection contre les abus) et son prestataire anti-robot.
Pour garantir qu'une ligne de journal ne peut pas être fabriquée par un visiteur, chaque événement est sérialisé en JSON : les retours à la ligne ou guillemets injectés dans un en-tête comme le User-Agent sont neutralisés, et la longueur de ces champs est limitée.
Conformité RGPD
Le formulaire de contact est le seul traitement de données personnelles du site : nom, adresse email et message, auxquels s'ajoutent les données techniques du journal de sécurité.
- Une politique de confidentialité détaille le responsable de traitement, les finalités, la base légale (intérêt légitime), les prestataires (Formspree et Cloudflare), les transferts hors UE et les droits des personnes.
- Les messages sont conservés 12 mois au maximum, et le journal de sécurité 30 jours.
- Le site n'utilise ni cookies publicitaires, ni outil d'analyse d'audience, ni pistage inter-sites.
- Une case à cocher recueille l'accord de l'utilisateur avant l'envoi, avec un rappel d'information sous le formulaire qui mentionne la vérification anti-robot et le journal de sécurité.
- La page de politique de confidentialité est exclue des moteurs de recherche, tout en restant accessible depuis le site.
Hébergement et déploiement
Le site est hébergé sur Plesk avec l'extension Node.js. Le déploiement suit ces étapes :
- Mise à jour du code et de la configuration (
package.json, variables d'environnement). - Installation des dépendances.
- Compilation avec le script
build. - Redémarrage de l'application.
Les secrets (adresse du formulaire, clé secrète Turnstile) sont fournis à l'application côté serveur. Ils ne sont écrits ni dans le code, ni dans le dépôt, et ne sont jamais envoyés au navigateur.
Problème rencontré : permission refusée sur le binaire Next.js
Au lancement, l'application échouait avec l'erreur next: Permission denied (code 126). Les fichiers du dossier node_modules/.bin avaient perdu leur droit d'exécution, ce qui arrive quand des fichiers sont transférés depuis un autre système.
Solution : appeler directement le fichier JavaScript de Next.js avec Node, sans passer par le binaire :
"build": "node ./node_modules/next/dist/bin/next build",
"start": "node ./node_modules/next/dist/bin/next start"
Cette correction contourne le problème de droits sans modifier le serveur. Elle m'a aussi appris à distinguer une erreur de code d'une erreur d'environnement : l'application était correcte, c'est l'exécution qui bloquait.
Problème rencontré : variable d'environnement non transmise
Après l'ajout de la route serveur, le formulaire renvoyait une erreur 500. La réponse de la route indiquait « Configuration serveur manquante » : l'application ne retrouvait pas l'adresse du formulaire, preuve que la variable d'environnement n'était pas transmise au processus Node.js.
Ce diagnostic a été rapide grâce à deux choix de conception : la route renvoie un code d'erreur précis plutôt qu'un échec générique, et elle consigne la cause dans le journal sans jamais afficher le secret au visiteur.
Référencement et performance
Le site est suivi avec deux outils :
- Lighthouse, pour mesurer la performance, l'accessibilité, les bonnes pratiques et le SEO.
- Google Search Console, pour suivre l'indexation et les performances dans les résultats de recherche.


Les métadonnées (titre, description) sont définies page par page. J'ai aussi corrigé un résultat parasite : la page de confidentialité apparaissait dans les moteurs de recherche avec le titre du site, ce qui a été réglé avec la balise noindex.
Compétences mobilisées
- Développement web : Next.js, React, TypeScript, Tailwind CSS, MDX.
- Sécurité applicative : CAPTCHA vérifié côté serveur, limitation de débit, validation des entrées, gestion des secrets, protection contre le spam.
- Journalisation : conception d'un journal d'événements exploitable, sans donnée sensible, avec rotation automatique.
- Hébergement et exploitation : Plesk, Node.js, variables d'environnement, diagnostic d'erreurs de déploiement.
- Conformité : RGPD, politique de confidentialité, minimisation des données, durées de conservation.
- Référencement : métadonnées, indexation, suivi des performances.
Pistes d'amélioration
- Ajouter des en-têtes de sécurité (Content-Security-Policy, HSTS, X-Frame-Options) et vérifier le résultat avec un outil d'audit comme Mozilla Observatory. La Content-Security-Policy devra autoriser le domaine de Turnstile.
- Stocker les compteurs de limitation dans une base persistante (Redis) pour qu'ils survivent aux redémarrages.
- Centraliser et analyser les journaux (par exemple avec Wazuh ou Graylog) pour déclencher des alertes automatiques en cas de pic de tentatives.
- Envoyer une alerte par email lorsqu'un seuil d'événements suspects est dépassé.