$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 <<
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;
}