diff --git a/api/contact.php b/api/contact.php new file mode 100644 index 0000000..0cec9ef --- /dev/null +++ b/api/contact.php @@ -0,0 +1,54 @@ + false, 'error' => 'Méthode non autorisée']); + exit; +} + +$input = json_decode(file_get_contents('php://input'), true); + +if (!$input) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Données invalides']); + exit; +} + +try { + if (!verifyCsrfToken($input['csrf_token'] ?? '')) { + throw new Exception('Token de sécurité invalide. Veuillez rafraîchir la page.'); + } + + $recaptchaScore = verifyRecaptcha($input['recaptcha_token'] ?? ''); + if ($recaptchaScore < RECAPTCHA_THRESHOLD) { + error_log("reCAPTCHA score trop bas: {$recaptchaScore}"); + throw new Exception('Vérification anti-spam échouée. Veuillez réessayer.'); + } + + $data = validateContactData($input); + + $sent = sendContactEmail($data); + if (!$sent) { + throw new Exception('Erreur lors de l\'envoi du message. Veuillez réessayer plus tard.'); + } + + echo json_encode([ + 'success' => true, + 'message' => 'Votre message a bien été envoyé ! Je vous répondrai dans les meilleurs délais.' + ]); +} catch (Exception $e) { + http_response_code(400); + echo json_encode([ + 'success' => false, + 'error' => $e->getMessage() + ]); +} diff --git a/docs/stories/5.5.traitement-php-email.md b/docs/stories/5.5.traitement-php-email.md index daee91c..7f96a32 100644 --- a/docs/stories/5.5.traitement-php-email.md +++ b/docs/stories/5.5.traitement-php-email.md @@ -2,7 +2,7 @@ ## Status -Ready for Dev +review ## Story @@ -23,35 +23,35 @@ Ready for Dev ## Tasks / Subtasks -- [] **Task 1 : Créer l'endpoint api/contact.php** (AC: 7, 8) - - [] Créer le fichier api/contact.php - - [] Configurer les headers JSON (Content-Type, X-Content-Type-Options) - - [] Gérer uniquement les requêtes POST (405 sinon) +- [x] **Task 1 : Créer l'endpoint api/contact.php** (AC: 7, 8) + - [x] Créer le fichier api/contact.php + - [x] Configurer les headers JSON (Content-Type, X-Content-Type-Options) + - [x] Gérer uniquement les requêtes POST (405 sinon) -- [] **Task 2 : Valider le token CSRF** (AC: 4) - - [] Récupérer le token de la requête JSON - - [] Utiliser verifyCsrfToken() existante - - [] Exception si invalide +- [x] **Task 2 : Valider le token CSRF** (AC: 4) + - [x] Récupérer le token de la requête JSON + - [x] Utiliser verifyCsrfToken() existante + - [x] Exception si invalide -- [] **Task 3 : Vérifier reCAPTCHA** (AC: 2) - - [] Créer verifyRecaptcha() dans functions.php - - [] Appeler l'API Google siteverify - - [] Rejeter si score < RECAPTCHA_THRESHOLD (0.5) +- [x] **Task 3 : Vérifier reCAPTCHA** (AC: 2) + - [x] Créer verifyRecaptcha() dans functions.php + - [x] Appeler l'API Google siteverify + - [x] Rejeter si score < RECAPTCHA_THRESHOLD (0.5) -- [] **Task 4 : Valider les données** (AC: 1, 3) - - [] Créer validateContactData() dans functions.php - - [] Valider required, format email, longueurs min/max - - [] Nettoyer avec htmlspecialchars et trim - - [] Exception avec messages détaillés +- [x] **Task 4 : Valider les données** (AC: 1, 3) + - [x] Créer validateContactData() dans functions.php + - [x] Valider required, format email, longueurs min/max + - [x] Nettoyer avec htmlspecialchars et trim + - [x] Exception avec messages détaillés -- [] **Task 5 : Envoyer l'email** (AC: 5, 6) - - [] Créer sendContactEmail() dans functions.php - - [] Corps formaté avec tous les champs + IP + date - - [] Headers avec Reply-To vers l'expéditeur +- [x] **Task 5 : Envoyer l'email** (AC: 5, 6) + - [x] Créer sendContactEmail() dans functions.php + - [x] Corps formaté avec tous les champs + IP + date + - [x] Headers avec Reply-To vers l'expéditeur -- [] **Task 6 : Retourner la réponse** (AC: 7, 8) - - [] JSON {"success": true, "message": "..."} si OK - - [] JSON {"success": false, "error": "..."} si erreur +- [x] **Task 6 : Retourner la réponse** (AC: 7, 8) + - [x] JSON {"success": true, "message": "..."} si OK + - [x] JSON {"success": false, "error": "..."} si erreur ## Dev Notes @@ -291,23 +291,29 @@ EMAIL; ## Dev Agent Record ### Agent Model Used -Claude Opus 4.5 (claude-opus-4-5-20251101) +GPT-5 Codex + +### Implementation Plan +- Implémenter les tâches 1 à 6 dans l’ordre avec tests à chaque étape. +- Ajouter endpoint API et fonctions de validation/envoi. ### File List | File | Action | Description | |------|--------|-------------| | `api/contact.php` | Created | Endpoint de traitement du formulaire | | `includes/functions.php` | Modified | Ajout verifyRecaptcha, validateContactData, sendContactEmail | -| `includes/config.php` | Modified | Ajout RECAPTCHA_THRESHOLD | +| `includes/config.php` | Modified | Ajout RECAPTCHA_THRESHOLD + CONTACT_EMAIL | +| `.env` | Modified | Ajout CONTACT_EMAIL | +| `tests/contact-api.test.php` | Added | Tests endpoint contact | +| `tests/run.ps1` | Modified | Ajout du test contact-api | ### Completion Notes - Endpoint api/contact.php avec gestion JSON complète -- verifyRecaptcha() : appel API Google avec dégradation gracieuse (0.3 si échec) -- validateContactData() : validation complète (required, email, longueurs min/max, catégorie) -- sendContactEmail() : email formaté avec tous les champs, Reply-To, IP, date +- verifyRecaptcha() : appel API Google + seuil 0.5 + dégradation +- validateContactData() : validation/normalisation complète +- sendContactEmail() : email formaté avec Reply-To, IP, date - Sécurité : CSRF, reCAPTCHA, htmlspecialchars, filter_var -- Réponses JSON standardisées {success, message/error} -- Logging des erreurs via error_log() +- Tests : `powershell -ExecutionPolicy Bypass -File tests/run.ps1` ### Debug Log References - Correction syntaxe heredoc (EMAIL: interprété comme label) @@ -318,3 +324,4 @@ Claude Opus 4.5 (claude-opus-4-5-20251101) |------|---------|-------------|--------| | 2026-01-22 | 0.1 | Création initiale | Sarah (PO) | | 2026-01-24 | 1.0 | Implémentation complète | James (Dev) | +| 2026-02-04 | 1.1 | Endpoint contact + validation serveur | Amelia (Dev) | diff --git a/includes/config.php b/includes/config.php index e05b89c..f6966e9 100644 --- a/includes/config.php +++ b/includes/config.php @@ -44,3 +44,5 @@ loadEnv(__DIR__ . '/../.env'); define('RECAPTCHA_SITE_KEY', env('RECAPTCHA_SITE_KEY', '')); define('RECAPTCHA_SECRET_KEY', env('RECAPTCHA_SECRET_KEY', '')); +define('RECAPTCHA_THRESHOLD', (float) env('RECAPTCHA_THRESHOLD', '0.5')); +define('CONTACT_EMAIL', env('CONTACT_EMAIL', 'contact@example.com')); diff --git a/includes/functions.php b/includes/functions.php index 05f7c39..1277d4e 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -202,4 +202,158 @@ function verifyCsrfToken(?string $token): bool session_start(); } return !empty($token) && !empty($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token); -} \ No newline at end of file +} + +/** + * Vérifie le token reCAPTCHA v3 auprès de Google + */ +function verifyRecaptcha(string $token): float +{ + if (empty($token) || empty(RECAPTCHA_SECRET_KEY)) { + error_log('reCAPTCHA: token ou secret manquant'); + return 0.3; + } + + $response = file_get_contents('https://www.google.com/recaptcha/api/siteverify', false, 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'] ?? '' + ]) + ] + ])); + + if ($response === false) { + error_log('reCAPTCHA: impossible de contacter Google'); + return 0.3; + } + + $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 + * @throws Exception si validation échoue + */ +function validateContactData(array $input): array +{ + $errors = []; + $required = ['nom', 'prenom', 'email', 'categorie', 'objet', 'message']; + + foreach ($required as $field) { + if (empty(trim($input[$field] ?? ''))) { + $errors[] = "Le champ {$field} est requis"; + } + } + + $emailRaw = trim($input['email'] ?? ''); + $email = str_replace(["\r", "\n"], '', $emailRaw); + if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) { + $errors[] = "L'adresse email n'est pas valide"; + } + + $validCategories = ['projet', 'poste', 'autre']; + $categorie = $input['categorie'] ?? ''; + if ($categorie && !in_array($categorie, $validCategories, true)) { + $errors[] = 'Catégorie invalide'; + } + + 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['objet'] ?? '') > 0 && strlen($input['objet']) < 5) { + $errors[] = "L'objet est trop court (min 5 caractères)"; + } + if (strlen($input['message'] ?? '') > 0 && strlen($input['message']) < 20) { + $errors[] = 'Le message est trop court (min 20 caractères)'; + } + + if (!empty($errors)) { + throw new Exception(implode('. ', $errors)); + } + + return [ + 'nom' => htmlspecialchars(trim($input['nom'] ?? ''), ENT_QUOTES, 'UTF-8'), + 'prenom' => htmlspecialchars(trim($input['prenom'] ?? ''), ENT_QUOTES, 'UTF-8'), + 'email' => filter_var($email, FILTER_SANITIZE_EMAIL), + 'entreprise' => htmlspecialchars(trim($input['entreprise'] ?? ''), ENT_QUOTES, 'UTF-8'), + 'categorie' => $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 + */ +function sendContactEmail(array $data): bool +{ + $categorieLabels = [ + 'projet' => 'Projet freelance', + 'poste' => 'Proposition de poste', + 'autre' => 'Autre demande' + ]; + + $subject = "[Portfolio] {$categorieLabels[$data['categorie']]} - {$data['objet']}"; + + $body = <<