diff --git a/api/contact.php b/api/contact.php new file mode 100644 index 0000000..744f30e --- /dev/null +++ b/api/contact.php @@ -0,0 +1,70 @@ + false, 'error' => 'Méthode non autorisée']); + exit; +} + +// Récupérer les données JSON +$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 { + // 1. Valider le token CSRF + if (!verifyCsrfToken($input['csrf_token'] ?? '')) { + throw new Exception('Token de sécurité invalide. Veuillez rafraîchir la page.'); + } + + // 2. Vérifier reCAPTCHA + $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.'); + } + + // 3. Valider et nettoyer les données + $data = validateContactData($input); + + // 4. Envoyer l'email + $sent = sendContactEmail($data); + + if (!$sent) { + throw new Exception('Erreur lors de l\'envoi du message. Veuillez réessayer plus tard.'); + } + + // 5. Succès + 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/assets/img/projects/acsaintdie-thumb.jpg b/assets/img/projects/acsaintdie-thumb.jpg new file mode 100644 index 0000000..1952b03 Binary files /dev/null and b/assets/img/projects/acsaintdie-thumb.jpg differ diff --git a/assets/img/projects/acsaintdie-thumb.png b/assets/img/projects/acsaintdie-thumb.png new file mode 100644 index 0000000..24f44e7 Binary files /dev/null and b/assets/img/projects/acsaintdie-thumb.png differ diff --git a/assets/img/projects/acsaintdie-thumb.webp b/assets/img/projects/acsaintdie-thumb.webp new file mode 100644 index 0000000..916be75 Binary files /dev/null and b/assets/img/projects/acsaintdie-thumb.webp differ diff --git a/assets/img/projects/darkteur-thumb.jpg b/assets/img/projects/darkteur-thumb.jpg new file mode 100644 index 0000000..9302d5e Binary files /dev/null and b/assets/img/projects/darkteur-thumb.jpg differ diff --git a/assets/img/projects/darkteur-thumb.png b/assets/img/projects/darkteur-thumb.png new file mode 100644 index 0000000..1d8941b Binary files /dev/null and b/assets/img/projects/darkteur-thumb.png differ diff --git a/assets/img/projects/darkteur-thumb.webp b/assets/img/projects/darkteur-thumb.webp new file mode 100644 index 0000000..7172f6b Binary files /dev/null and b/assets/img/projects/darkteur-thumb.webp differ diff --git a/assets/img/projects/feels-thumb.jpg b/assets/img/projects/feels-thumb.jpg new file mode 100644 index 0000000..4b6b9f3 Binary files /dev/null and b/assets/img/projects/feels-thumb.jpg differ diff --git a/assets/img/projects/feels-thumb.png b/assets/img/projects/feels-thumb.png new file mode 100644 index 0000000..180c532 Binary files /dev/null and b/assets/img/projects/feels-thumb.png differ diff --git a/assets/img/projects/feels-thumb.webp b/assets/img/projects/feels-thumb.webp new file mode 100644 index 0000000..88247c6 Binary files /dev/null and b/assets/img/projects/feels-thumb.webp differ diff --git a/assets/img/projects/firewall-pfsense.png b/assets/img/projects/firewall-pfsense.png new file mode 100644 index 0000000..2e4a56c Binary files /dev/null and b/assets/img/projects/firewall-pfsense.png differ diff --git a/assets/img/projects/proxmox.png b/assets/img/projects/proxmox.png new file mode 100644 index 0000000..0105a86 Binary files /dev/null and b/assets/img/projects/proxmox.png differ diff --git a/assets/img/projects/wacbio-thumb.jpg b/assets/img/projects/wacbio-thumb.jpg new file mode 100644 index 0000000..55e3ede Binary files /dev/null and b/assets/img/projects/wacbio-thumb.jpg differ diff --git a/assets/img/projects/wacbio-thumb.png b/assets/img/projects/wacbio-thumb.png new file mode 100644 index 0000000..37f593c Binary files /dev/null and b/assets/img/projects/wacbio-thumb.png differ diff --git a/assets/img/projects/wacbio-thumb.webp b/assets/img/projects/wacbio-thumb.webp new file mode 100644 index 0000000..f984975 Binary files /dev/null and b/assets/img/projects/wacbio-thumb.webp differ diff --git a/assets/js/contact-form.js b/assets/js/contact-form.js new file mode 100644 index 0000000..49da477 --- /dev/null +++ b/assets/js/contact-form.js @@ -0,0 +1,561 @@ +/** + * Validation du formulaire de contact + * JavaScript vanilla - pas de dépendances + */ + +/** + * Service reCAPTCHA v3 + * Gestion du token avec dégradation gracieuse + */ +const RecaptchaService = { + siteKey: null, + + init() { + this.siteKey = window.RECAPTCHA_SITE_KEY || null; + }, + + isAvailable() { + return this.siteKey && typeof grecaptcha !== 'undefined'; + }, + + /** + * Obtient un token reCAPTCHA + * @param {string} action - Action à valider (ex: 'contact') + * @returns {Promise} - Token ou chaîne vide si indisponible + */ + async getToken(action = 'contact') { + // Dégradation gracieuse si reCAPTCHA non disponible + if (!this.isAvailable()) { + console.warn('reCAPTCHA non disponible, envoi sans protection'); + return ''; + } + + return new Promise((resolve) => { + grecaptcha.ready(() => { + grecaptcha.execute(this.siteKey, { action }) + .then(token => resolve(token)) + .catch(error => { + console.error('Erreur reCAPTCHA:', error); + resolve(''); // Permettre l'envoi quand même + }); + }); + }); + } +}; + +class FormValidator { + constructor(formId) { + this.form = document.getElementById(formId); + if (!this.form) return; + + this.submitBtn = document.getElementById('submit-btn'); + this.submitText = document.getElementById('submit-text'); + this.submitLoading = document.getElementById('submit-loading'); + this.fields = {}; + this.errors = {}; + + this.init(); + } + + init() { + // Définir les règles de validation + this.rules = { + nom: { + required: true, + minLength: 2, + maxLength: 100, + message: 'Veuillez entrer votre nom (2 caractères minimum)' + }, + prenom: { + required: true, + minLength: 2, + maxLength: 100, + message: 'Veuillez entrer votre prénom (2 caractères minimum)' + }, + email: { + required: true, + email: true, + message: 'Veuillez entrer une adresse email valide' + }, + categorie: { + required: true, + message: 'Veuillez sélectionner une catégorie' + }, + objet: { + required: true, + minLength: 5, + maxLength: 200, + message: 'Veuillez entrer un objet (5 caractères minimum)' + }, + message: { + required: true, + minLength: 20, + maxLength: 5000, + message: 'Veuillez entrer votre message (20 caractères minimum)' + } + }; + + // Récupérer les champs + Object.keys(this.rules).forEach(fieldName => { + this.fields[fieldName] = this.form.querySelector(`[name="${fieldName}"]`); + }); + + this.bindEvents(); + } + + bindEvents() { + // Validation au blur + Object.keys(this.fields).forEach(fieldName => { + const field = this.fields[fieldName]; + if (field) { + field.addEventListener('blur', () => this.validateField(fieldName)); + field.addEventListener('input', () => { + // Effacer l'erreur pendant la saisie + if (this.errors[fieldName]) { + this.clearError(fieldName); + } + }); + } + }); + + // Validation à la soumission + this.form.addEventListener('submit', (e) => this.handleSubmit(e)); + + // Compteur de caractères pour le message + const messageField = this.fields.message; + if (messageField) { + messageField.addEventListener('input', () => this.updateCharCount()); + } + + // Bouton reset + this.form.addEventListener('reset', () => { + setTimeout(() => { + this.clearAllErrors(); + this.updateCharCount(); + }, 10); + }); + } + + validateField(fieldName) { + const field = this.fields[fieldName]; + const rule = this.rules[fieldName]; + + if (!field || !rule) return true; + + const value = field.value.trim(); + let isValid = true; + let errorMessage = ''; + + // Required + if (rule.required && !value) { + isValid = false; + errorMessage = rule.message; + } + + // Min length + if (isValid && rule.minLength && value.length < rule.minLength) { + isValid = false; + errorMessage = rule.message; + } + + // Max length + if (isValid && rule.maxLength && value.length > rule.maxLength) { + isValid = false; + errorMessage = `Maximum ${rule.maxLength} caractères`; + } + + // Email format + if (isValid && rule.email && value) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(value)) { + isValid = false; + errorMessage = rule.message; + } + } + + // Afficher ou masquer l'erreur + if (isValid) { + this.clearError(fieldName); + } else { + this.showError(fieldName, errorMessage); + } + + this.errors[fieldName] = !isValid; + this.updateSubmitButton(); + + return isValid; + } + + validateAll() { + let allValid = true; + + Object.keys(this.rules).forEach(fieldName => { + if (!this.validateField(fieldName)) { + allValid = false; + } + }); + + return allValid; + } + + showError(fieldName, message) { + const field = this.fields[fieldName]; + const errorEl = this.form.querySelector(`[data-error="${fieldName}"]`); + + if (field) { + field.classList.add('border-red-500', 'focus:ring-red-500/50', 'focus:border-red-500'); + field.classList.remove('border-border', 'focus:ring-primary/50', 'focus:border-primary'); + field.setAttribute('aria-invalid', 'true'); + } + + if (errorEl) { + errorEl.textContent = message; + errorEl.classList.remove('hidden'); + } + } + + clearError(fieldName) { + const field = this.fields[fieldName]; + const errorEl = this.form.querySelector(`[data-error="${fieldName}"]`); + + if (field) { + field.classList.remove('border-red-500', 'focus:ring-red-500/50', 'focus:border-red-500'); + field.classList.add('border-border', 'focus:ring-primary/50', 'focus:border-primary'); + field.removeAttribute('aria-invalid'); + } + + if (errorEl) { + errorEl.textContent = ''; + errorEl.classList.add('hidden'); + } + + this.errors[fieldName] = false; + this.updateSubmitButton(); + } + + clearAllErrors() { + Object.keys(this.rules).forEach(fieldName => { + this.clearError(fieldName); + }); + } + + updateSubmitButton() { + const hasErrors = Object.values(this.errors).some(err => err); + + if (this.submitBtn) { + this.submitBtn.disabled = hasErrors; + } + } + + updateCharCount() { + const messageField = this.fields.message; + const countEl = document.getElementById('message-count'); + + if (messageField && countEl) { + countEl.textContent = messageField.value.length; + } + } + + setLoading(isLoading) { + if (this.submitBtn) { + this.submitBtn.disabled = isLoading; + } + if (this.submitText) { + this.submitText.classList.toggle('hidden', isLoading); + } + if (this.submitLoading) { + this.submitLoading.classList.toggle('hidden', !isLoading); + if (isLoading) { + this.submitLoading.classList.add('flex'); + } else { + this.submitLoading.classList.remove('flex'); + } + } + } + + handleSubmit(e) { + e.preventDefault(); + + if (!this.validateAll()) { + // Focus sur le premier champ en erreur + const firstError = Object.keys(this.errors).find(key => this.errors[key]); + if (firstError && this.fields[firstError]) { + this.fields[firstError].focus(); + } + return; + } + + // Si valide, déclencher l'envoi + this.form.dispatchEvent(new CustomEvent('validSubmit', { + detail: this.getFormData() + })); + } + + getFormData() { + const formData = {}; + Object.keys(this.fields).forEach(fieldName => { + if (this.fields[fieldName]) { + formData[fieldName] = this.fields[fieldName].value.trim(); + } + }); + // Ajouter le champ entreprise (optionnel) + const entreprise = this.form.querySelector('[name="entreprise"]'); + if (entreprise) { + formData.entreprise = entreprise.value.trim(); + } + // Ajouter le token CSRF + const csrfToken = this.form.querySelector('[name="csrf_token"]'); + if (csrfToken) { + formData.csrf_token = csrfToken.value; + } + return formData; + } +} + +/** + * Gestionnaire de persistance du formulaire + * Sauvegarde/restaure les données via localStorage + */ +class ContactFormPersistence { + constructor(formId) { + this.form = document.getElementById(formId); + if (!this.form) return; + + this.debounceTimer = null; + this.init(); + } + + init() { + this.loadSavedData(); + this.bindEvents(); + } + + bindEvents() { + // Sauvegarder à chaque modification (avec debounce de 500ms) + this.form.addEventListener('input', () => { + clearTimeout(this.debounceTimer); + this.debounceTimer = setTimeout(() => this.saveData(), 500); + }); + + // Bouton effacer + const clearBtn = document.getElementById('clear-form-btn'); + if (clearBtn) { + clearBtn.addEventListener('click', (e) => { + e.preventDefault(); + this.clearForm(); + }); + } + + // Écouter l'envoi réussi (dispatché par le handler AJAX) + this.form.addEventListener('formSuccess', () => { + AppState.clearFormData(); + }); + } + + saveData() { + const formData = new FormData(this.form); + const data = {}; + + formData.forEach((value, key) => { + data[key] = value; + }); + + AppState.saveFormData(data); + } + + loadSavedData() { + const savedData = AppState.getFormData(); + if (!savedData) return; + + Object.keys(savedData).forEach(key => { + const field = this.form.querySelector(`[name="${key}"]`); + if (field && savedData[key]) { + field.value = savedData[key]; + } + }); + + // Mettre à jour le compteur de caractères + const messageField = this.form.querySelector('[name="message"]'); + const countEl = document.getElementById('message-count'); + if (messageField && countEl) { + countEl.textContent = messageField.value.length; + } + } + + clearForm() { + // Confirmation + if (!confirm('Êtes-vous sûr de vouloir effacer le formulaire ?')) { + return; + } + + // Vider le localStorage + AppState.clearFormData(); + + // Réinitialiser le formulaire + this.form.reset(); + + // Réinitialiser le compteur + const countEl = document.getElementById('message-count'); + if (countEl) { + countEl.textContent = '0'; + } + + // Effacer les erreurs visuelles + this.form.querySelectorAll('.border-red-500').forEach(el => { + el.classList.remove('border-red-500', 'focus:ring-red-500/50', 'focus:border-red-500'); + el.classList.add('border-border', 'focus:ring-primary/50', 'focus:border-primary'); + el.removeAttribute('aria-invalid'); + }); + this.form.querySelectorAll('[data-error]').forEach(el => { + el.classList.add('hidden'); + el.textContent = ''; + }); + + // Réactiver le bouton submit + const submitBtn = document.getElementById('submit-btn'); + if (submitBtn) { + submitBtn.disabled = false; + } + } +} + +/** + * Gestionnaire d'envoi AJAX du formulaire + * Gère le loading, succès et erreurs + */ +class ContactFormSubmit { + constructor(formId) { + this.form = document.getElementById(formId); + if (!this.form) return; + + this.submitBtn = document.getElementById('submit-btn'); + this.submitText = document.getElementById('submit-text'); + this.submitLoading = document.getElementById('submit-loading'); + this.successMessage = document.getElementById('success-message'); + this.errorMessage = document.getElementById('error-message'); + this.errorText = document.getElementById('error-text'); + + this.isSubmitting = false; + + this.init(); + } + + init() { + // Écouter l'événement de validation réussie + this.form.addEventListener('validSubmit', (e) => this.handleSubmit(e.detail)); + } + + async handleSubmit(formData) { + if (this.isSubmitting) return; + + this.setLoadingState(true); + this.hideMessages(); + + try { + // Ajouter le token CSRF + const csrfInput = this.form.querySelector('[name="csrf_token"]'); + if (csrfInput) { + formData.csrf_token = csrfInput.value; + } + + // Obtenir le token reCAPTCHA + formData.recaptcha_token = await RecaptchaService.getToken('contact'); + + // Envoyer la requête + const response = await fetch('/api/contact.php', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(formData) + }); + + const result = await response.json(); + + if (result.success) { + this.handleSuccess(result.message); + } else { + this.handleError(result.error || 'Une erreur est survenue'); + } + + } catch (error) { + console.error('Erreur envoi formulaire:', error); + this.handleError('Impossible de contacter le serveur. Vérifiez votre connexion.'); + } finally { + this.setLoadingState(false); + } + } + + setLoadingState(loading) { + this.isSubmitting = loading; + + if (this.submitBtn) { + this.submitBtn.disabled = loading; + } + + if (this.submitText && this.submitLoading) { + if (loading) { + this.submitText.classList.add('hidden'); + this.submitLoading.classList.remove('hidden'); + this.submitLoading.classList.add('flex'); + } else { + this.submitText.classList.remove('hidden'); + this.submitLoading.classList.add('hidden'); + this.submitLoading.classList.remove('flex'); + } + } + } + + handleSuccess(message) { + // Masquer le formulaire + this.form.classList.add('hidden'); + + // Afficher le message de succès + if (this.successMessage) { + this.successMessage.classList.remove('hidden'); + + // Scroll vers le message + this.successMessage.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + + // Vider le localStorage + AppState.clearFormData(); + + // Réinitialiser le formulaire (pour un éventuel nouvel envoi) + this.form.reset(); + + // Déclencher l'événement de succès + this.form.dispatchEvent(new CustomEvent('formSuccess')); + } + + handleError(message) { + // Afficher le message d'erreur + if (this.errorMessage && this.errorText) { + this.errorText.textContent = message; + this.errorMessage.classList.remove('hidden'); + + // Scroll vers le message + this.errorMessage.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + + // Les données sont conservées dans le formulaire pour réessayer + } + + hideMessages() { + if (this.successMessage) { + this.successMessage.classList.add('hidden'); + } + if (this.errorMessage) { + this.errorMessage.classList.add('hidden'); + } + } +} + +// Initialisation +document.addEventListener('DOMContentLoaded', () => { + RecaptchaService.init(); + window.contactFormValidator = new FormValidator('contact-form'); + window.contactFormPersistence = new ContactFormPersistence('contact-form'); + window.contactFormSubmit = new ContactFormSubmit('contact-form'); +}); diff --git a/assets/js/main.js b/assets/js/main.js index 052d614..5865f83 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -2,9 +2,9 @@ // Script principal du portfolio document.addEventListener('DOMContentLoaded', () => { - console.log('Portfolio chargé'); initMobileMenu(); initNavbarScroll(); + initEmailProtection(); }); /** @@ -85,3 +85,21 @@ function initNavbarScroll() { } }, { passive: true }); } + +/** + * Protection de l'email contre le scraping + * Reconstruit l'adresse email à partir de data-attributes + */ +function initEmailProtection() { + const emailLink = document.getElementById('email-link'); + if (!emailLink) return; + + const user = emailLink.dataset.user; + const domain = emailLink.dataset.domain; + + if (user && domain) { + const email = `${user}@${domain}`; + emailLink.href = `mailto:${email}`; + emailLink.title = email; + } +} diff --git a/assets/js/state.js b/assets/js/state.js new file mode 100644 index 0000000..adddc9d --- /dev/null +++ b/assets/js/state.js @@ -0,0 +1,76 @@ +/** + * Gestionnaire d'état pour le localStorage + * Persiste les données du formulaire de contact + */ + +const AppState = { + STORAGE_KEY: 'portfolio_contact_form', + + // Champs à ne jamais stocker (sécurité) + EXCLUDED_FIELDS: ['csrf_token', 'password', 'recaptcha_token'], + + /** + * Vérifie si localStorage est disponible + */ + isStorageAvailable() { + try { + const test = '__storage_test__'; + localStorage.setItem(test, test); + localStorage.removeItem(test); + return true; + } catch (e) { + return false; + } + }, + + /** + * Sauvegarde les données du formulaire + * @param {Object} data - Données du formulaire + */ + saveFormData(data) { + if (!this.isStorageAvailable()) return; + + try { + // Filtrer les champs exclus + const filteredData = {}; + Object.keys(data).forEach(key => { + if (!this.EXCLUDED_FIELDS.includes(key)) { + filteredData[key] = data[key]; + } + }); + + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filteredData)); + } catch (e) { + console.warn('Impossible de sauvegarder dans localStorage:', e); + } + }, + + /** + * Charge les données sauvegardées + * @returns {Object|null} Données ou null si absentes + */ + getFormData() { + if (!this.isStorageAvailable()) return null; + + try { + const data = localStorage.getItem(this.STORAGE_KEY); + return data ? JSON.parse(data) : null; + } catch (e) { + console.warn('Impossible de charger depuis localStorage:', e); + return null; + } + }, + + /** + * Efface les données sauvegardées + */ + clearFormData() { + if (!this.isStorageAvailable()) return; + + try { + localStorage.removeItem(this.STORAGE_KEY); + } catch (e) { + // Silencieux + } + } +}; diff --git a/data/projects.json b/data/projects.json index 9757228..8df27b6 100644 --- a/data/projects.json +++ b/data/projects.json @@ -2,17 +2,17 @@ "projects": [ { "id": 1, - "title": "Site E-commerce XYZ", - "slug": "ecommerce-xyz", + "title": "Aéroclub de Saint-Dié-des-Vosges - Site association d'aéronotique", + "slug": "acsaintdie", "category": "vedette", - "thumbnail": "ecommerce-xyz-thumb.webp", - "url": "https://example.com", - "github": "https://github.com/user/project", - "technologies": ["PHP", "JavaScript", "Tailwind CSS", "MySQL"], - "context": "Client souhaitant moderniser sa boutique en ligne pour améliorer l'expérience utilisateur et augmenter les conversions.", - "solution": "Développement d'une solution e-commerce sur mesure avec panier persistant, paiement sécurisé Stripe, et interface d'administration.", + "thumbnail": "acsaintdie-thumb.webp", + "url": "https://acsaintdie.fr", + "github": "", + "technologies": ["PHP", "JavaScript", "MySQL", "Wordpress"], + "context": "Dans le cadre de mon alternance chez kiwi-studio, un client a souhaité une refonte complète de son site internet. Le tout en ayant la possibilité d'ajouter du contenu facilement, pour ce faire, il a choisi Wordpress.", + "solution": "Développement d'un thème personnalisé pour Wordpress, intégrant différents plugin custom pour la gestion de la galerie ou des différents type de post.", "teamwork": "Projet réalisé en collaboration avec un designer UI/UX. J'ai pris en charge l'intégration et le développement backend.", - "duration": "3 mois", + "duration": "5 mois", "screenshots": [ "ecommerce-xyz-screen-1.webp", "ecommerce-xyz-screen-2.webp", diff --git a/includes/config.php b/includes/config.php new file mode 100644 index 0000000..e32e2ec --- /dev/null +++ b/includes/config.php @@ -0,0 +1,59 @@ + $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; +} diff --git a/index.php b/index.php index 00feacd..5077093 100644 --- a/index.php +++ b/index.php @@ -3,6 +3,7 @@ * Front Controller - Point d'entrée unique */ +require_once __DIR__ . '/includes/config.php'; require_once __DIR__ . '/includes/functions.php'; require_once __DIR__ . '/includes/router.php'; diff --git a/pages/contact.php b/pages/contact.php index 18fad88..20bff8e 100644 --- a/pages/contact.php +++ b/pages/contact.php @@ -1,12 +1,15 @@ @@ -14,18 +17,260 @@ include_template('navbar', compact('currentPage'));
-
-

Contact

-

- Discutons de votre projet -

-
+
+ +
+

Me Contacter

+

+ Une question, un projet ? Parlons-en ! +

+
-

- Page en construction - Epic 5 -

+ +
+ + + + +
+ +
+ + + +
+ + +
+ + + +
+
+ + +
+ +
+ + + +
+ + +
+ + +
+
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +

+ 0 / 5000 caractères +

+
+ + +
+ + +
+
+ + + + + + + + +
+

Retrouvez-moi aussi sur

+ + +
+
+ + + diff --git a/pages/home.php b/pages/home.php index e57fb3a..af2edb3 100644 --- a/pages/home.php +++ b/pages/home.php @@ -22,7 +22,7 @@ include_template('navbar', compact('currentPage'));

- Prénom NOM + Célian Burst

diff --git a/templates/footer.php b/templates/footer.php index e083f81..d065a3b 100644 --- a/templates/footer.php +++ b/templates/footer.php @@ -14,6 +14,10 @@ $currentYear = date('Y'); + + + +