✨ Story 5.5: endpoint contact PHP
This commit is contained in:
54
api/contact.php
Normal file
54
api/contact.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Endpoint de traitement du formulaire de contact
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../includes/config.php';
|
||||||
|
require_once __DIR__ . '/../includes/functions.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['success' => 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()
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Ready for Dev
|
review
|
||||||
|
|
||||||
## Story
|
## Story
|
||||||
|
|
||||||
@@ -23,35 +23,35 @@ Ready for Dev
|
|||||||
|
|
||||||
## Tasks / Subtasks
|
## Tasks / Subtasks
|
||||||
|
|
||||||
- [] **Task 1 : Créer l'endpoint api/contact.php** (AC: 7, 8)
|
- [x] **Task 1 : Créer l'endpoint api/contact.php** (AC: 7, 8)
|
||||||
- [] Créer le fichier api/contact.php
|
- [x] Créer le fichier api/contact.php
|
||||||
- [] Configurer les headers JSON (Content-Type, X-Content-Type-Options)
|
- [x] Configurer les headers JSON (Content-Type, X-Content-Type-Options)
|
||||||
- [] Gérer uniquement les requêtes POST (405 sinon)
|
- [x] Gérer uniquement les requêtes POST (405 sinon)
|
||||||
|
|
||||||
- [] **Task 2 : Valider le token CSRF** (AC: 4)
|
- [x] **Task 2 : Valider le token CSRF** (AC: 4)
|
||||||
- [] Récupérer le token de la requête JSON
|
- [x] Récupérer le token de la requête JSON
|
||||||
- [] Utiliser verifyCsrfToken() existante
|
- [x] Utiliser verifyCsrfToken() existante
|
||||||
- [] Exception si invalide
|
- [x] Exception si invalide
|
||||||
|
|
||||||
- [] **Task 3 : Vérifier reCAPTCHA** (AC: 2)
|
- [x] **Task 3 : Vérifier reCAPTCHA** (AC: 2)
|
||||||
- [] Créer verifyRecaptcha() dans functions.php
|
- [x] Créer verifyRecaptcha() dans functions.php
|
||||||
- [] Appeler l'API Google siteverify
|
- [x] Appeler l'API Google siteverify
|
||||||
- [] Rejeter si score < RECAPTCHA_THRESHOLD (0.5)
|
- [x] Rejeter si score < RECAPTCHA_THRESHOLD (0.5)
|
||||||
|
|
||||||
- [] **Task 4 : Valider les données** (AC: 1, 3)
|
- [x] **Task 4 : Valider les données** (AC: 1, 3)
|
||||||
- [] Créer validateContactData() dans functions.php
|
- [x] Créer validateContactData() dans functions.php
|
||||||
- [] Valider required, format email, longueurs min/max
|
- [x] Valider required, format email, longueurs min/max
|
||||||
- [] Nettoyer avec htmlspecialchars et trim
|
- [x] Nettoyer avec htmlspecialchars et trim
|
||||||
- [] Exception avec messages détaillés
|
- [x] Exception avec messages détaillés
|
||||||
|
|
||||||
- [] **Task 5 : Envoyer l'email** (AC: 5, 6)
|
- [x] **Task 5 : Envoyer l'email** (AC: 5, 6)
|
||||||
- [] Créer sendContactEmail() dans functions.php
|
- [x] Créer sendContactEmail() dans functions.php
|
||||||
- [] Corps formaté avec tous les champs + IP + date
|
- [x] Corps formaté avec tous les champs + IP + date
|
||||||
- [] Headers avec Reply-To vers l'expéditeur
|
- [x] Headers avec Reply-To vers l'expéditeur
|
||||||
|
|
||||||
- [] **Task 6 : Retourner la réponse** (AC: 7, 8)
|
- [x] **Task 6 : Retourner la réponse** (AC: 7, 8)
|
||||||
- [] JSON {"success": true, "message": "..."} si OK
|
- [x] JSON {"success": true, "message": "..."} si OK
|
||||||
- [] JSON {"success": false, "error": "..."} si erreur
|
- [x] JSON {"success": false, "error": "..."} si erreur
|
||||||
|
|
||||||
## Dev Notes
|
## Dev Notes
|
||||||
|
|
||||||
@@ -291,23 +291,29 @@ EMAIL;
|
|||||||
## Dev Agent Record
|
## Dev Agent Record
|
||||||
|
|
||||||
### Agent Model Used
|
### 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 List
|
||||||
| File | Action | Description |
|
| File | Action | Description |
|
||||||
|------|--------|-------------|
|
|------|--------|-------------|
|
||||||
| `api/contact.php` | Created | Endpoint de traitement du formulaire |
|
| `api/contact.php` | Created | Endpoint de traitement du formulaire |
|
||||||
| `includes/functions.php` | Modified | Ajout verifyRecaptcha, validateContactData, sendContactEmail |
|
| `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
|
### Completion Notes
|
||||||
- Endpoint api/contact.php avec gestion JSON complète
|
- Endpoint api/contact.php avec gestion JSON complète
|
||||||
- verifyRecaptcha() : appel API Google avec dégradation gracieuse (0.3 si échec)
|
- verifyRecaptcha() : appel API Google + seuil 0.5 + dégradation
|
||||||
- validateContactData() : validation complète (required, email, longueurs min/max, catégorie)
|
- validateContactData() : validation/normalisation complète
|
||||||
- sendContactEmail() : email formaté avec tous les champs, Reply-To, IP, date
|
- sendContactEmail() : email formaté avec Reply-To, IP, date
|
||||||
- Sécurité : CSRF, reCAPTCHA, htmlspecialchars, filter_var
|
- Sécurité : CSRF, reCAPTCHA, htmlspecialchars, filter_var
|
||||||
- Réponses JSON standardisées {success, message/error}
|
- Tests : `powershell -ExecutionPolicy Bypass -File tests/run.ps1`
|
||||||
- Logging des erreurs via error_log()
|
|
||||||
|
|
||||||
### Debug Log References
|
### Debug Log References
|
||||||
- Correction syntaxe heredoc (EMAIL: interprété comme label)
|
- 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-22 | 0.1 | Création initiale | Sarah (PO) |
|
||||||
| 2026-01-24 | 1.0 | Implémentation complète | James (Dev) |
|
| 2026-01-24 | 1.0 | Implémentation complète | James (Dev) |
|
||||||
|
| 2026-02-04 | 1.1 | Endpoint contact + validation serveur | Amelia (Dev) |
|
||||||
|
|||||||
@@ -44,3 +44,5 @@ loadEnv(__DIR__ . '/../.env');
|
|||||||
|
|
||||||
define('RECAPTCHA_SITE_KEY', env('RECAPTCHA_SITE_KEY', ''));
|
define('RECAPTCHA_SITE_KEY', env('RECAPTCHA_SITE_KEY', ''));
|
||||||
define('RECAPTCHA_SECRET_KEY', env('RECAPTCHA_SECRET_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'));
|
||||||
|
|||||||
@@ -203,3 +203,157 @@ function verifyCsrfToken(?string $token): bool
|
|||||||
}
|
}
|
||||||
return !empty($token) && !empty($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
|
return !empty($token) && !empty($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = <<<EMAIL
|
||||||
|
============================================
|
||||||
|
NOUVEAU MESSAGE - PORTFOLIO
|
||||||
|
============================================
|
||||||
|
|
||||||
|
DE: {$data['prenom']} {$data['nom']}
|
||||||
|
ADRESSE EMAIL: {$data['email']}
|
||||||
|
ENTREPRISE: {$data['entreprise']}
|
||||||
|
CATEGORIE: {$categorieLabels[$data['categorie']]}
|
||||||
|
|
||||||
|
--------------------------------------------
|
||||||
|
OBJET: {$data['objet']}
|
||||||
|
--------------------------------------------
|
||||||
|
|
||||||
|
MESSAGE:
|
||||||
|
|
||||||
|
{$data['message']}
|
||||||
|
|
||||||
|
============================================
|
||||||
|
Envoye le {$data['date']}
|
||||||
|
IP: {$data['ip']}
|
||||||
|
============================================
|
||||||
|
EMAIL;
|
||||||
|
|
||||||
|
$headers = implode("\r\n", [
|
||||||
|
'From: ' . CONTACT_EMAIL,
|
||||||
|
'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: ' . print_r($data, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|||||||
23
tests/contact-api.test.php
Normal file
23
tests/contact-api.test.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
function assertTrue($cond, $msg) {
|
||||||
|
if (!$cond) {
|
||||||
|
fwrite(STDERR, $msg . PHP_EOL);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = __DIR__ . '/../api/contact.php';
|
||||||
|
assertTrue(file_exists($path), 'missing api/contact.php');
|
||||||
|
$content = file_get_contents($path);
|
||||||
|
|
||||||
|
assertTrue(strpos($content, 'Content-Type: application/json') !== false, 'missing json header');
|
||||||
|
assertTrue(strpos($content, 'X-Content-Type-Options') !== false, 'missing nosniff');
|
||||||
|
assertTrue(strpos($content, "REQUEST_METHOD'] !== 'POST'") !== false, 'missing method check');
|
||||||
|
assertTrue(strpos($content, 'verifyCsrfToken') !== false, 'missing csrf verification');
|
||||||
|
assertTrue(strpos($content, 'verifyRecaptcha') !== false, 'missing recaptcha verification');
|
||||||
|
assertTrue(strpos($content, 'validateContactData') !== false, 'missing data validation');
|
||||||
|
assertTrue(strpos($content, 'sendContactEmail') !== false, 'missing email send');
|
||||||
|
assertTrue(strpos($content, 'success') !== false, 'missing success response');
|
||||||
|
assertTrue(strpos($content, 'error') !== false, 'missing error response');
|
||||||
|
|
||||||
|
fwrite(STDOUT, "OK\n");
|
||||||
@@ -24,4 +24,5 @@ php (Join-Path $here 'contact.test.php')
|
|||||||
php (Join-Path $here 'contact-validation.test.php')
|
php (Join-Path $here 'contact-validation.test.php')
|
||||||
php (Join-Path $here 'contact-state.test.php')
|
php (Join-Path $here 'contact-state.test.php')
|
||||||
php (Join-Path $here 'recaptcha.test.php')
|
php (Join-Path $here 'recaptcha.test.php')
|
||||||
|
php (Join-Path $here 'contact-api.test.php')
|
||||||
'OK'
|
'OK'
|
||||||
|
|||||||
Reference in New Issue
Block a user