✉️ Feature: Epic 5 - Formulaire de Contact (Stories 5.1-5.7)

- Formulaire HTML5 avec validation (nom, prénom, email, entreprise, catégorie, objet, message)
- Validation JavaScript côté client (FormValidator)
- Persistance localStorage des données (AppState)
- Intégration reCAPTCHA v3 avec dégradation gracieuse
- Traitement PHP sécurisé (CSRF, validation, envoi email)
- Feedback utilisateur AJAX (succès/erreur)
- Liens contact secondaires (LinkedIn, GitHub, Email protégé)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-24 01:43:13 +01:00
parent 08402e3ed2
commit 9180f116ec
25 changed files with 1293 additions and 22 deletions

59
includes/config.php Normal file
View File

@@ -0,0 +1,59 @@
<?php
/**
* Configuration de l'application
* Charge les variables d'environnement depuis .env
*/
/**
* Charge un fichier .env et définit les variables d'environnement
* @param string $path Chemin vers le fichier .env
*/
function loadEnv(string $path): void
{
if (!file_exists($path)) {
return;
}
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
// Ignorer les commentaires
if (str_starts_with(trim($line), '#')) {
continue;
}
// Parser KEY=value
if (str_contains($line, '=')) {
[$key, $value] = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
// Supprimer les guillemets si présents
if (preg_match('/^["\'](.*)["\']\s*$/', $value, $matches)) {
$value = $matches[1];
}
$_ENV[$key] = $value;
putenv("{$key}={$value}");
}
}
}
// Charger le fichier .env
loadEnv(__DIR__ . '/../.env');
// Définir les constantes de configuration
define('APP_ENV', $_ENV['APP_ENV'] ?? 'production');
define('APP_DEBUG', ($_ENV['APP_DEBUG'] ?? 'false') === 'true');
define('APP_URL', $_ENV['APP_URL'] ?? 'http://localhost');
// reCAPTCHA v3
define('RECAPTCHA_SITE_KEY', $_ENV['RECAPTCHA_SITE_KEY'] ?? '');
define('RECAPTCHA_SECRET_KEY', $_ENV['RECAPTCHA_SECRET_KEY'] ?? '');
define('RECAPTCHA_THRESHOLD', 0.5); // Score minimum (0.0 à 1.0)
// Contact
define('CONTACT_EMAIL', $_ENV['CONTACT_EMAIL'] ?? '');
// Sécurité
define('APP_SECRET', $_ENV['APP_SECRET'] ?? '');

View File

@@ -224,3 +224,240 @@ function getTestimonialByProject(string $projectSlug): ?array
}
return null;
}
/**
* Génère un token CSRF et le stocke en session
* @return string Token CSRF
*/
function generateCsrfToken(): string
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$token = bin2hex(random_bytes(32));
$_SESSION['csrf_token'] = $token;
$_SESSION['csrf_token_time'] = time();
return $token;
}
/**
* Vérifie la validité d'un token CSRF
* @param string $token Token à vérifier
* @param int $maxAge Durée de validité en secondes (défaut: 1 heure)
* @return bool True si valide
*/
function verifyCsrfToken(string $token, int $maxAge = 3600): bool
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (empty($_SESSION['csrf_token']) || empty($_SESSION['csrf_token_time'])) {
return false;
}
// Vérifier l'expiration
if (time() - $_SESSION['csrf_token_time'] > $maxAge) {
return false;
}
return hash_equals($_SESSION['csrf_token'], $token);
}
/**
* Vérifie le token reCAPTCHA v3 auprès de Google
* @param string $token Token reçu du client
* @return float Score (0.0 à 1.0), 0.3 si échec (dégradation gracieuse)
*/
function verifyRecaptcha(string $token): float
{
// Si pas de clé secrète configurée, retourner un score acceptable
if (!defined('RECAPTCHA_SECRET_KEY') || empty(RECAPTCHA_SECRET_KEY)) {
return 0.9;
}
// Si pas de token, retourner un score bas mais pas bloquant
if (empty($token)) {
error_log('reCAPTCHA: token vide');
return 0.3;
}
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => 'Content-Type: application/x-www-form-urlencoded',
'content' => http_build_query([
'secret' => RECAPTCHA_SECRET_KEY,
'response' => $token,
'remoteip' => $_SERVER['REMOTE_ADDR'] ?? ''
]),
'timeout' => 10
]
]);
$response = @file_get_contents('https://www.google.com/recaptcha/api/siteverify', false, $context);
if ($response === false) {
error_log('reCAPTCHA: impossible de contacter Google');
return 0.3; // Dégradation gracieuse
}
$result = json_decode($response, true);
if (!($result['success'] ?? false)) {
error_log('reCAPTCHA: échec - ' . json_encode($result['error-codes'] ?? []));
return 0.0;
}
return (float) ($result['score'] ?? 0.0);
}
/**
* Valide et nettoie les données du formulaire de contact
* @param array $input Données brutes
* @return array Données nettoyées
* @throws Exception si validation échoue
*/
function validateContactData(array $input): array
{
$errors = [];
// Champs requis
$required = ['nom', 'prenom', 'email', 'categorie', 'objet', 'message'];
$labels = [
'nom' => 'Nom',
'prenom' => 'Prénom',
'email' => 'Email',
'categorie' => 'Catégorie',
'objet' => 'Objet',
'message' => 'Message'
];
foreach ($required as $field) {
if (empty(trim($input[$field] ?? ''))) {
$errors[] = "Le champ {$labels[$field]} est requis";
}
}
// Validation email
$email = trim($input['email'] ?? '');
if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = "L'adresse email n'est pas valide";
}
// Validation catégorie
$validCategories = ['projet', 'poste', 'autre'];
$categorie = $input['categorie'] ?? '';
if ($categorie && !in_array($categorie, $validCategories)) {
$errors[] = "Catégorie invalide";
}
// Validation longueurs
if (strlen($input['nom'] ?? '') > 100) {
$errors[] = "Le nom est trop long (max 100 caractères)";
}
if (strlen($input['prenom'] ?? '') > 100) {
$errors[] = "Le prénom est trop long (max 100 caractères)";
}
if (strlen($input['objet'] ?? '') > 200) {
$errors[] = "L'objet est trop long (max 200 caractères)";
}
if (strlen($input['message'] ?? '') > 5000) {
$errors[] = "Le message est trop long (max 5000 caractères)";
}
if (strlen($input['entreprise'] ?? '') > 200) {
$errors[] = "Le nom d'entreprise est trop long (max 200 caractères)";
}
// Longueurs minimales
if (strlen(trim($input['nom'] ?? '')) > 0 && strlen(trim($input['nom'])) < 2) {
$errors[] = "Le nom doit contenir au moins 2 caractères";
}
if (strlen(trim($input['prenom'] ?? '')) > 0 && strlen(trim($input['prenom'])) < 2) {
$errors[] = "Le prénom doit contenir au moins 2 caractères";
}
if (strlen(trim($input['objet'] ?? '')) > 0 && strlen(trim($input['objet'])) < 5) {
$errors[] = "L'objet doit contenir au moins 5 caractères";
}
if (strlen(trim($input['message'] ?? '')) > 0 && strlen(trim($input['message'])) < 20) {
$errors[] = "Le message doit contenir au moins 20 caractères";
}
// Si erreurs, les lancer
if (!empty($errors)) {
throw new Exception(implode('. ', $errors));
}
// Nettoyer et retourner
return [
'nom' => htmlspecialchars(trim($input['nom']), ENT_QUOTES, 'UTF-8'),
'prenom' => htmlspecialchars(trim($input['prenom']), ENT_QUOTES, 'UTF-8'),
'email' => filter_var(trim($input['email']), FILTER_SANITIZE_EMAIL),
'entreprise' => htmlspecialchars(trim($input['entreprise'] ?? ''), ENT_QUOTES, 'UTF-8'),
'categorie' => $input['categorie'],
'objet' => htmlspecialchars(trim($input['objet']), ENT_QUOTES, 'UTF-8'),
'message' => htmlspecialchars(trim($input['message']), ENT_QUOTES, 'UTF-8'),
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'inconnue',
'date' => date('d/m/Y à H:i:s'),
];
}
/**
* Envoie l'email de contact
* @param array $data Données validées et nettoyées
* @return bool True si envoyé avec succès
*/
function sendContactEmail(array $data): bool
{
$categorieLabels = [
'projet' => 'Projet freelance',
'poste' => 'Proposition de poste',
'autre' => 'Autre demande'
];
$categorie = $categorieLabels[$data['categorie']] ?? 'Autre';
$entreprise = $data['entreprise'] ?: 'Non renseignée';
$subject = "[Portfolio] {$categorie} - {$data['objet']}";
$body = "═══════════════════════════════════════════\n";
$body .= "NOUVEAU MESSAGE - PORTFOLIO\n";
$body .= "═══════════════════════════════════════════\n\n";
$body .= "DE: {$data['prenom']} {$data['nom']}\n";
$body .= "EMAIL: {$data['email']}\n";
$body .= "ENTREPRISE: {$entreprise}\n";
$body .= "CATÉGORIE: {$categorie}\n\n";
$body .= "───────────────────────────────────────────\n";
$body .= "OBJET: {$data['objet']}\n";
$body .= "───────────────────────────────────────────\n\n";
$body .= "MESSAGE:\n\n";
$body .= "{$data['message']}\n\n";
$body .= "═══════════════════════════════════════════\n";
$body .= "Envoyé le {$data['date']}\n";
$body .= "IP: {$data['ip']}\n";
$body .= "═══════════════════════════════════════════";
// Vérifier que CONTACT_EMAIL est défini
if (!defined('CONTACT_EMAIL') || empty(CONTACT_EMAIL)) {
error_log('CONTACT_EMAIL non configuré');
return false;
}
$headers = implode("\r\n", [
'From: noreply@' . ($_SERVER['HTTP_HOST'] ?? 'localhost'),
'Reply-To: ' . $data['email'],
'Content-Type: text/plain; charset=UTF-8',
'X-Mailer: PHP/' . phpversion(),
'X-Priority: 1'
]);
$result = @mail(CONTACT_EMAIL, $subject, $body, $headers);
if (!$result) {
error_log("Échec envoi email contact: " . json_encode($data));
}
return $result;
}