360 lines
11 KiB
PHP
360 lines
11 KiB
PHP
<?php
|
|
/**
|
|
* Inclut un template avec des données
|
|
* @param string $name Nom du template (sans .php)
|
|
* @param array $data Variables à passer au template
|
|
*/
|
|
function include_template(string $name, array $data = []): void
|
|
{
|
|
extract($data, EXTR_SKIP);
|
|
include __DIR__ . "/../templates/{$name}.php";
|
|
}
|
|
|
|
/**
|
|
* Charge et parse un fichier JSON
|
|
*/
|
|
function loadJsonData(string $filename): array
|
|
{
|
|
$path = __DIR__ . "/../data/{$filename}";
|
|
|
|
if (!file_exists($path)) {
|
|
error_log("JSON file not found: {$filename}");
|
|
return [];
|
|
}
|
|
|
|
$content = file_get_contents($path);
|
|
$data = json_decode($content, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
error_log("JSON parse error in {$filename}: " . json_last_error_msg());
|
|
return [];
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Récupère tous les projets
|
|
*/
|
|
function getProjects(): array
|
|
{
|
|
$data = loadJsonData('projects.json');
|
|
return $data['projects'] ?? [];
|
|
}
|
|
|
|
/**
|
|
* Récupère les projets par catégorie
|
|
*/
|
|
function getProjectsByCategory(string $category): array
|
|
{
|
|
return array_values(array_filter(getProjects(), fn($p) => ($p['category'] ?? '') === $category));
|
|
}
|
|
|
|
/**
|
|
* Récupère un projet par son slug
|
|
*/
|
|
function getProjectBySlug(string $slug): ?array
|
|
{
|
|
foreach (getProjects() as $project) {
|
|
if (($project['slug'] ?? '') === $slug) {
|
|
return $project;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Récupère les technologies uniques de tous les projets
|
|
*/
|
|
function getAllTechnologies(): array
|
|
{
|
|
$technologies = [];
|
|
foreach (getProjects() as $project) {
|
|
foreach ($project['technologies'] ?? [] as $tech) {
|
|
if (!in_array($tech, $technologies, true)) {
|
|
$technologies[] = $tech;
|
|
}
|
|
}
|
|
}
|
|
sort($technologies);
|
|
return $technologies;
|
|
}
|
|
|
|
/**
|
|
* Helper pour afficher une image projet optimisée
|
|
*/
|
|
function projectImage(string $filename, string $alt, int $width, int $height, bool $lazy = true): string
|
|
{
|
|
$webp = $filename;
|
|
$fallback = str_replace('.webp', '.jpg', $filename);
|
|
$lazyAttr = $lazy ? 'loading="lazy"' : '';
|
|
|
|
$altEsc = htmlspecialchars($alt, ENT_QUOTES, 'UTF-8');
|
|
$webpEsc = htmlspecialchars($webp, ENT_QUOTES, 'UTF-8');
|
|
$fallbackEsc = htmlspecialchars($fallback, ENT_QUOTES, 'UTF-8');
|
|
|
|
return <<<HTML
|
|
<picture>
|
|
<source srcset="/assets/img/projects/{$webpEsc}" type="image/webp">
|
|
<img
|
|
src="/assets/img/projects/{$fallbackEsc}"
|
|
alt="{$altEsc}"
|
|
width="{$width}"
|
|
height="{$height}"
|
|
{$lazyAttr}
|
|
class="w-full h-full object-cover"
|
|
onerror="this.onerror=null;this.src='/assets/img/projects/default-project.svg';"
|
|
>
|
|
</picture>
|
|
HTML;
|
|
}
|
|
|
|
/**
|
|
* Compte les projets par technologie
|
|
*/
|
|
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
|
|
*/
|
|
function getProjectsByTech(string $tech): array
|
|
{
|
|
return array_values(array_filter(getProjects(), function ($project) use ($tech) {
|
|
return in_array($tech, $project['technologies'] ?? [], true);
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Icônes d'outils
|
|
*/
|
|
function getToolIcon(string $icon): string
|
|
{
|
|
$icons = [
|
|
'github' => '<svg viewBox="0 0 24 24" class="w-6 h-6" fill="currentColor"><path d="M12 .5a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2.1c-3.3.7-4-1.6-4-1.6-.6-1.5-1.4-1.9-1.4-1.9-1.1-.8.1-.8.1-.8 1.2.1 1.8 1.3 1.8 1.3 1.1 1.8 2.9 1.3 3.6 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.9 0-1.3.5-2.3 1.2-3.2-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2a11 11 0 015.8 0C16.6 5 17.6 5.3 17.6 5.3c.6 1.6.2 2.8.1 3.1.8.9 1.2 1.9 1.2 3.2 0 4.6-2.7 5.6-5.3 5.9.4.3.8 1 .8 2.1v3.1c0 .3.2.7.8.6A12 12 0 0012 .5z"/></svg>',
|
|
'vscode' => '<svg viewBox="0 0 24 24" class="w-6 h-6" fill="currentColor"><path d="M3 9.5l4.5-4.4 10.7 9.9 2.8-2.1V3.1L12.3 6 7.5 1.6 3 6.1l4.5 4.2L3 14.5l4.5 4.4 3.9-3.6 6 5.6 3.1-2.1v-6.7l-2.8-2.1-10.7 9.9L3 14.5l4.5-4.2L3 9.5z"/></svg>',
|
|
'figma' => '<svg viewBox="0 0 24 24" class="w-6 h-6" fill="currentColor"><path d="M8 24a4 4 0 004-4v-4H8a4 4 0 100 8zm0-12h4v-4H8a4 4 0 100 8zm0-12h4V0H8a4 4 0 100 8zm8 0a4 4 0 110 8h-4V0h4zm0 12a4 4 0 110 8h-4v-8h4z"/></svg>',
|
|
'notion' => '<svg viewBox="0 0 24 24" class="w-6 h-6" fill="currentColor"><path d="M4 3h16v18H4V3zm3.5 4.5v9h2V9.2l4.1 7.3h2.4V7.5h-2v7.3L10 7.5H7.5z"/></svg>',
|
|
'docker' => '<svg viewBox="0 0 24 24" class="w-6 h-6" fill="currentColor"><path d="M7 6h2v2H7V6zm3 0h2v2h-2V6zm3 0h2v2h-2V6zm-6 3h2v2H7V9zm3 0h2v2h-2V9zm3 0h2v2h-2V9zm3 0h2v2h-2V9zm-9 3h2v2H7v-2zm3 0h2v2h-2v-2zm3 0h2v2h-2v-2zm3 0h2v2h-2v-2zm7-2.5c-.5-.3-1.4-.6-2.4-.4-.2-.9-.8-1.7-1.7-2.1l-.3-.1-.2.3c-.3.5-.4 1.2-.2 1.8.1.3.3.6.5.9H4.5c0 3.1 1.9 5.4 5.1 5.4h4.4c3.2 0 5.8-1.5 7-4.1.4 0 .8-.1 1.2-.2.9-.3 1.5-.9 1.6-1l.2-.3-.3-.2z"/></svg>',
|
|
];
|
|
|
|
return $icons[$icon] ?? '🛠';
|
|
}
|
|
|
|
/**
|
|
* Récupère tous les témoignages
|
|
*/
|
|
function getTestimonials(): array
|
|
{
|
|
$data = loadJsonData('testimonials.json');
|
|
return $data['testimonials'] ?? [];
|
|
}
|
|
|
|
/**
|
|
* Récupère les témoignages mis en avant
|
|
*/
|
|
function getFeaturedTestimonials(): array
|
|
{
|
|
return array_values(array_filter(getTestimonials(), fn($t) => ($t['featured'] ?? false) === true));
|
|
}
|
|
|
|
/**
|
|
* Récupère le témoignage lié à un projet
|
|
*/
|
|
function getTestimonialByProject(string $projectSlug): ?array
|
|
{
|
|
foreach (getTestimonials() as $testimonial) {
|
|
if (($testimonial['project_slug'] ?? '') === $projectSlug) {
|
|
return $testimonial;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* CSRF token helpers
|
|
*/
|
|
function generateCsrfToken(): string
|
|
{
|
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
|
session_start();
|
|
}
|
|
if (empty($_SESSION['csrf_token'])) {
|
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
|
}
|
|
return $_SESSION['csrf_token'];
|
|
}
|
|
|
|
function verifyCsrfToken(?string $token): bool
|
|
{
|
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
|
session_start();
|
|
}
|
|
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;
|
|
}
|