✉️ 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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user