$p['category'] === $category); } /** * Récupère un projet par son slug * @param string $slug Slug du projet * @return array|null Projet ou null si non trouvé */ function getProjectBySlug(string $slug): ?array { $projects = getProjects(); foreach ($projects as $project) { if ($project['slug'] === $slug) { return $project; } } return null; } /** * Récupère les technologies uniques de tous les projets * @return array Liste triée des technologies */ function getAllTechnologies(): array { $technologies = []; foreach (getProjects() as $project) { foreach ($project['technologies'] ?? [] as $tech) { if (!in_array($tech, $technologies)) { $technologies[] = $tech; } } } sort($technologies); return $technologies; } /** * Génère le HTML pour une image projet optimisée * Utilise pour WebP avec fallback JPG * * @param string $filename Nom du fichier image (ex: project-thumb.webp) * @param string $alt Texte alternatif * @param int $width Largeur en pixels * @param int $height Hauteur en pixels * @param bool $lazy Activer le lazy loading (défaut: true) * @param string $class Classes CSS additionnelles * @return string HTML de l'image */ function projectImage(string $filename, string $alt, int $width, int $height, bool $lazy = true, string $class = ''): string { $alt = htmlspecialchars($alt, ENT_QUOTES, 'UTF-8'); $class = htmlspecialchars($class, ENT_QUOTES, 'UTF-8'); $lazyAttr = $lazy ? 'loading="lazy"' : ''; // Détermine les chemins WebP et fallback $basePath = '/assets/img/projects/'; $webpFile = $filename; // Si le fichier n'est pas .webp, on essaie de trouver la version .webp if (!str_ends_with($filename, '.webp')) { $webpFile = preg_replace('/\.(jpg|jpeg|png)$/i', '.webp', $filename); } // Fallback: remplace .webp par .jpg $fallbackFile = str_replace('.webp', '.jpg', $webpFile); // Image par défaut si fichier manquant $defaultImage = $basePath . 'default-project.svg'; return << {$alt} HTML; } /** * Compte les projets par technologie * @return array Tableau associatif [technologie => nombre] */ function getProjectCountByTech(): array { $projects = getProjects(); $count = []; foreach ($projects as $project) { foreach ($project['technologies'] ?? [] as $tech) { $count[$tech] = ($count[$tech] ?? 0) + 1; } } return $count; } /** * Récupère les projets utilisant une technologie * @param string $tech Nom de la technologie * @return array Projets filtrés */ function getProjectsByTech(string $tech): array { return array_filter(getProjects(), function($project) use ($tech) { return in_array($tech, $project['technologies'] ?? []); }); } /** * Retourne l'icône SVG d'un outil * @param string $icon Identifiant de l'outil * @return string SVG de l'icône */ function getToolIcon(string $icon): string { $icons = [ 'github' => '', 'vscode' => '', 'figma' => '', 'notion' => '', 'docker' => '', 'linux' => '', ]; return $icons[$icon] ?? ''; } /** * Récupère tous les témoignages * @return array Liste des témoignages */ function getTestimonials(): array { $data = loadJsonData('testimonials.json'); return $data['testimonials'] ?? []; } /** * Récupère les témoignages mis en avant * @return array Témoignages avec featured = true */ function getFeaturedTestimonials(): array { return array_filter(getTestimonials(), fn($t) => ($t['featured'] ?? false) === true); } /** * Récupère le témoignage lié à un projet * @param string $projectSlug Slug du projet * @return array|null Témoignage ou null si non trouvé */ function getTestimonialByProject(string $projectSlug): ?array { foreach (getTestimonials() as $testimonial) { if (($testimonial['project_slug'] ?? '') === $projectSlug) { return $testimonial; } } 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; }