Compare commits

...
34 Commits
Author SHA1 Message Date
Esteban 0c5ce36608 Patch gitignore 2026-08-08 19:36:29 +02:00
Esteban 376a20966f Remove html file 2026-07-11 04:59:37 +02:00
Esteban d47258fad8 Changement d'adresse dynamique 2026-07-09 14:40:26 +02:00
Esteban d7dc48454c Suppression easteregg + fix captcha 2026-07-09 11:54:37 +02:00
Esteban 6164f7eaae Ajout d'un easteregg 2026-07-03 10:56:54 +02:00
Esteban 542910a15d Patch php 2026-07-03 02:05:18 +02:00
Esteban a19c5c8381 Fix lag 2026-07-03 02:02:13 +02:00
Esteban 88c10ee5b0 Patch 2026-07-03 00:23:54 +02:00
Esteban c2400d27b7 Sauvegarde 2026-07-03 00:23:37 +02:00
EstebanandClaude Sonnet 5 b5bacf04a9 Retire le fond/bordure des tuiles de logos dans les bandeaux de la home
Les logos flottent maintenant directement sur le fond de la page au lieu
d'être encadrés dans une carte, pour un rendu "trusted by" plus épuré.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 20:12:02 +02:00
EstebanandClaude Sonnet 5 87cc4e5e9c Ajoute le style des bandeaux de logos sur la home (outils / partenaires)
Complète la fonctionnalité logo_banners (déjà en place côté PHP/BDD) : bandeau
défilant en boucle infinie avec bords en dégradé, logos en niveaux de gris qui
reprennent leur couleur au survol, sens de défilement inversé entre les deux
bandeaux, et grille dédiée dans le panel admin.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 19:20:21 +02:00
Esteban 97caf2275c Sauvegarde 2026-07-02 17:44:40 +02:00
Esteban 481fbc9498 Patch captcha 2026-07-02 15:58:39 +02:00
EstebanandClaude Sonnet 5 6fb78e0bbb Corrige le double sélecteur de fichier sur les zones de drop PDF (panel admin)
L'input file étant imbriqué dans la zone cliquable, input.click() déclenchait
un évènement click qui remontait (bubble) jusqu'à la zone et relançait
input.click() une seconde fois. Ajout d'un garde-fou sur e.target.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 15:42:52 +02:00
EstebanandClaude Sonnet 5 ba9bb21953 Ajoute la config PHP Docker pour l'upload de PDF (post_max_size/upload_max_filesize)
Le conteneur (php:8.2-apache) utilisait les limites par défaut de PHP (8 Mo),
ce qui rejetait silencieusement les requêtes contenant un PDF plus lourd et
provoquait en cascade des erreurs "headers already sent". Le Dockerfile généré
par scriptsite.sh doit copier ce fichier dans /usr/local/etc/php/conf.d/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 15:21:48 +02:00
Esteban 07bae3eff3 Dofus 2026-07-02 15:13:54 +02:00
Esteban a019dcb00d Ajout des écoles + changement CSS 2026-07-02 10:20:47 +02:00
EstebanandClaude Fable 5 e50eeacaeb Refonte du design : moderne + responsive, thème clair/sombre, maj compétences
- Vraie structure HTML5 (doctype, head, meta viewport) via le header commun
- CSS réécrit avec variables, police Inter, header sticky, menu burger mobile
- Thème clair/sombre avec bouton dans la navbar (mémorisé en localStorage)
- Hero sur l'accueil, page compétences en cartes alignée sur le CV (+ Ansible)
- Correction des balises body/main dupliquées sur contact, login et cgu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 09:24:33 +02:00
Esteban 9bbc05bcac Sauvegarde 2026-07-02 09:02:10 +02:00
Esteban 612933f238 Sauvegarde 2026-07-02 09:01:16 +02:00
Esteban 14f97564bf fix 2026-06-30 09:26:58 +02:00
Esteban ac292fbcc4 Fix captcha 2026-06-29 16:34:35 +02:00
Esteban 4bab6548f7 Ajout d'un captcha + Réactualisation du css + ajout cgu 2026-06-29 16:20:59 +02:00
Esteban 57a34967bb Adaptation aux mobiles 2026-06-13 12:33:38 +02:00
Esteban 12bba69908 Ajout d'une selection a la page home.php + Système de badge (non fini mais fonctionnel) 2026-06-12 19:13:40 +02:00
Esteban 3015c56583 Changement de Domain name 2026-06-12 16:31:37 +02:00
Esteban 5eef0d093c sauvegarde 2026-06-12 16:01:23 +02:00
Esteban f043d8b611 Patch d'une faille de sécurité => Webhook en clair 2026-06-12 15:57:18 +02:00
Esteban 965f537317 Modif CSS 2026-06-10 08:59:50 +02:00
Esteban d67a479d10 Sauvegarde 2026-06-08 13:06:58 +02:00
Esteban b2aefe3af5 Hashage et sallage des MDPs (fonctionnel) 2026-06-08 13:04:54 +02:00
Esteban ad4424571d Sauvegarde 2026-06-08 11:10:45 +02:00
Esteban f58d981ddc Ajout de création de user (fonctionnel), de suppression d'user (fonctionnel), et envoi de log (fonctionnel mais pas implémenté) 2026-06-08 11:07:19 +02:00
Esteban 9a1fbf1252 Sauvegarde 2026-06-08 10:17:34 +02:00
56 changed files with 6989 additions and 408 deletions
+15
View File
@@ -0,0 +1,15 @@
# Config Claude Code
.claude/
# macOS
.DS_Store
**/.DS_Store
# Éditeurs
.vscode/
.idea/
*.iml
# Logs
*.log
logs/
+2 -8
View File
@@ -2,11 +2,11 @@
Bienvenue sur ce portfolio pro et perso.
Le projet est accessible à l'adresse suivante : https://esteban.eldorianet.work et est hébergé en France grâce à TerraTech Hebergement.
Le projet est accessible à l'adresse suivante : https://estebanadmin.fr et est hébergé en France grâce à TerraTech Hebergement.
## Réutilisation du projet
La réutilisation du projet est autorisée, sous réserve qu'un fichier `idConnection.php` soit placé avant le dossier principal du site.
La réutilisation du projet est autorisée, sous réserve que les fichiers `idConnection.php` et `idDiscord.php` soient placés avant le dossier principal du site.
## Informations techniques
@@ -14,12 +14,6 @@ La réutilisation du projet est autorisée, sous réserve qu'un fichier `idConne
- Le CSS a été généré avec l'aide d'une intelligence artificielle.
- Le code PHP a été réalisé en grande partie par l'auteur.
## Sécurité
- Les mots de passe ne sont à ce stade ni hachés ni salés.
- Des failles de sécurité sont probablement encore présentes dans le système de connexion.
- Une amélioration de la sécurité du module de login est prévue prochainement.
## Signalement de vulnérabilité
Si vous identifiez une faille de sécurité ou un comportement suspect, merci de ne pas l'exploiter et de me contacter directement sur Discord : `@ours1`.
+560
View File
@@ -0,0 +1,560 @@
<?php
require_once(__DIR__ . '/functions/security.php');
// Ajout des logs Discord (chemin via __DIR__ car captcha.php est à la racine du site)
require_once(__DIR__ . '/functions/sendLogs.php');
// Recharger une nouvelle série si demandé
if (isset($_GET['reload']) && $_GET['reload'] === '1') {
unset($_SESSION['captcha_round']);
header('Location: ./captcha.php');
exit;
}
// Si le captcha est déjà validé, rediriger vers l'accueil
if (isset($_SESSION['captcha_valid']) && $_SESSION['captcha_valid'] === true) {
header('Location: ./index.php');
exit;
}
// Nombre maximum d'essais
$maxAttempts = 3;
// Durée minimale (en secondes) entre l'affichage de la série et la soumission du formulaire.
// Un humain doit lire plusieurs messages avant de répondre : une soumission plus rapide
// que ça trahit presque toujours un robot qui remplit le formulaire instantanément.
$minSolveTime = 1.2;
// Liste des sujets normaux
$normalSubjects = [
'Votre rendez-vous est confirmé',
'Photo de famille',
'Invitation à déjeuner',
'Votre facture est disponible',
'Message de votre médecin',
'Votre colis arrivera demain',
'Reçu de paiement',
'Informations sur votre abonnement',
'Rappel de rendez-vous',
'Bulletin dinformation',
'Horaire modifié',
'Demande de nouvelles',
'Anniversaire ce week-end',
'Confirmation de réservation',
'Mise à jour de votre compte',
'Message du voisin',
'Résultats danalyse disponibles',
'Programme de la semaine',
'Votre commande a bien été envoyée',
'Merci pour votre visite',
'Document joint',
'Nouvelles de la famille',
'Convocation à la réunion',
'Changement dhoraire',
'Votre facture deau',
'Votre relevé est prêt',
'Confirmation de présence',
'Infos importantes pour demain',
'Rappel amical',
'Livraison prévue aujourdhui',
'Votre réservation est confirmée',
'Message de la mairie',
'Avis de passage',
'Votre ticket est traité',
'Mise à jour de votre dossier',
'Message de l’école',
'Facture mensuelle',
'Invitation à la réunion',
'Confirmation de paiement',
'Votre commande est en route',
'Rappel de consultation',
'Nouvelle réponse reçue',
'Votre dossier a été mis à jour',
'Planning de la semaine',
'Reçu de réservation',
'Informations utiles',
'Votre assurance vous écrit',
'Message de votre pharmacien',
'Document disponible en ligne',
'Petit rappel important',
'Votre livraison est confirmée',
'Message du service client',
'Votre demande a été reçue',
'Lettre dinformation',
'Nouvelles du jour',
'Confirmation de votre présence',
'Votre justificatif est prêt',
'Reçu de transaction',
'Votre compte a été mis à jour',
'Avis dinformation',
'Votre dossier avance',
'Information concernant votre contrat',
'Message de votre banque',
'Votre échéance approche',
'Réponse à votre demande',
'Merci pour votre confiance',
'Votre pharmacie vous informe',
'Rappel de paiement',
'Votre colis a quitté lentrepôt',
'Votre abonnement continue',
'Message de votre mutuelle',
'Invitation familiale',
'Nouveau message reçu',
'Votre place est réservée',
'Votre facture du mois',
'Compte-rendu envoyé',
'Votre demande est en cours',
'Rappel pour demain',
'Réception confirmée',
'Votre transport est réservé',
'Bonne réception',
'Votre document est validé',
'Votre suivi est disponible',
'Petit message amical',
'Confirmation de livraison',
'Votre note est disponible',
'Mise à jour dinformations',
'Votre service est activé',
'Votre message a bien été transmis',
'Reçu officiel',
'Votre planning a changé',
'Votre passage est confirmé',
'Information de dernière minute',
'Votre compte-rendu médical',
'Votre inscription est validée',
'Réponse automatique',
'Votre dossier est complet',
'Nouveau document reçu',
'Votre reçu est disponible',
'Votre commande est confirmée',
'Votre rendez-vous approche',
'Dernières informations'
];
// Liste des expéditeurs normaux
$normalSenders = [
'Docteur Martin',
'Julie',
'Paul',
'Service client',
'École primaire',
'Mairie',
'Famille Dupont',
'Pharmacie du Centre',
'EDF Service',
'Voisinage',
'Transport local',
'Maison de santé',
'Claire Bernard',
'Assurance Habitation',
'Bibliothèque municipale',
'Banque familiale',
'Centre médical',
'La Poste',
'Mutuelle Santé',
'Service facturation'
];
// Liste des sujets spam, chacun associé à un indice (raison) qui sera révélé après succès :
// une petite touche pédagogique sur les techniques de phishing les plus courantes
$spamSubjects = [
['text' => 'URGENT !!! VOTRE COMPTE SERA SUPPRIMÉ AUJOURDHUI', 'reason' => 'Urgence exagérée et abus des majuscules'],
['text' => 'FÉLICITATIONS !!! VOUS AVEZ GAGNÉ 5000 € MAINTENANT', 'reason' => 'Gain surprise, trop beau pour être vrai'],
['text' => 'CLIQUEZ ICI IMMÉDIATEMENT POUR ÉVITER LE BLOCAGE', 'reason' => 'Appel à laction pressant et menaçant'],
['text' => 'VOTRE COLIS EST BLOQUÉ, PAYEZ TOUT DE SUITE', 'reason' => 'Demande de paiement immédiat inhabituelle'],
['text' => 'DERNIÈRE ALERTE : CONFIRMEZ VOTRE MOT DE PASSE', 'reason' => 'Un service légitime ne redemande jamais un mot de passe par email'],
['text' => 'ACTION OBLIGATOIRE DANS LES 5 MINUTES', 'reason' => 'Délai artificiellement très court'],
['text' => 'VOTRE BANQUE EXIGE UNE VÉRIFICATION IMMÉDIATE', 'reason' => 'Usurpation dun organisme bancaire'],
['text' => 'RÉCUPÉREZ VOTRE CADEAU GRATUIT MAINTENANT', 'reason' => 'Cadeau non sollicité'],
['text' => 'ALERTE SÉCURITÉ : VOTRE IDENTITÉ EST EN DANGER', 'reason' => 'Peur infondée utilisée pour pousser à agir vite'],
['text' => 'VOUS ÊTES LE GRAND GAGNANT DU JOUR', 'reason' => 'Gain surprise non sollicité'],
['text' => 'PAIEMENT REFUSÉ !!! METTEZ À JOUR VOTRE CARTE', 'reason' => 'Fausse alerte de paiement pour voler des données bancaires'],
['text' => 'CONFIRMEZ VOS INFORMATIONS SINON FERMETURE', 'reason' => 'Menace de fermeture de compte'],
['text' => 'OFFRE SECRÈTE EXCEPTIONNELLE À RÉCUPÉRER', 'reason' => 'Offre “secrète” artificiellement exclusive'],
['text' => 'VOTRE SESSION EXPIRE, CLIQUEZ MAINTENANT', 'reason' => 'Fausse urgence de session'],
['text' => 'REMBOURSEMENT IMMÉDIAT DISPONIBLE ICI', 'reason' => 'Remboursement non sollicité'],
['text' => 'ATTENTION !!! COMPTE BLOQUÉ DANS QUELQUES MINUTES', 'reason' => 'Compte à rebours anxiogène'],
['text' => 'DERNIÈRE CHANCE AVANT SUPPRESSION DÉFINITIVE', 'reason' => 'Menace de suppression définitive'],
['text' => 'VOTRE MOT DE PASSE A ÉTÉ PIRATÉ, AGISSEZ', 'reason' => 'Fausse alerte de piratage'],
['text' => 'VÉRIFICATION URGENTE DE VOTRE CARTE BANCAIRE', 'reason' => 'Demande dinformations bancaires par email'],
['text' => 'CONFIRMEZ VOTRE IDENTITÉ POUR RECEVOIR VOTRE ARGENT', 'reason' => 'Promesse dargent contre des données personnelles'],
];
// Liste des expéditeurs spam : mêmes formats d'affichage (nom, pas d'adresse brute) que les
// expéditeurs normaux ci-dessus, pour qu'il ne soit pas possible de repérer les mails
// frauduleux d'un simple coup d'œil sur l'expéditeur. La seule façon de les distinguer doit
// être de lire le contenu (sujet), comme un vrai email de phishing usurpant une identité connue.
$spamSenders = [
'Sécurité du compte',
'Service client',
'Banque familiale',
'Livraison Colis',
'Support technique',
'Service facturation',
'Assurance Habitation',
'Mutuelle Santé'
];
// Liste des aperçus normaux
$normalPreviews = [
'Bonjour, nous vous confirmons votre rendez-vous prévu cette semaine.',
'Je vous envoie les informations demandées, bonne journée.',
'Votre document est prêt, vous pouvez le consulter.',
'Merci pour votre message, voici les détails utiles.',
'Votre commande a bien été prise en compte.',
'Petit rappel pour ne rien oublier demain.',
'Nous restons à votre disposition pour toute question.',
'Voici un message dinformation concernant votre dossier.',
'Le service vous remercie et vous souhaite une bonne journée.',
'Vous trouverez ci-joint les éléments utiles.'
];
// Liste des aperçus spam
$spamPreviews = [
'Cliquez tout de suite sinon votre compte sera fermé dans quelques minutes.',
'Paiement obligatoire immédiat pour débloquer la situation.',
'Confirmez vos données personnelles maintenant pour éviter la suppression.',
'Votre cadeau gratuit vous attend, cliquez ici sans attendre.',
'Action urgente requise, réponse dans les 5 minutes demandée.',
'Dernier avertissement officiel avant blocage définitif.'
];
function firstLetterSafe(string $text): string
{
if (function_exists('mb_substr') && function_exists('mb_strtoupper')) {
return mb_strtoupper(mb_substr($text, 0, 1, 'UTF-8'), 'UTF-8');
}
return strtoupper(substr($text, 0, 1));
}
// Construit une nouvelle série de mails pour une manche de captcha.
// Le nombre de mails frauduleux varie entre 1 et 3 : un robot ne peut donc pas se
// contenter de "toujours cocher une seule case au hasard", ce qui réduit fortement
// les chances de réussir par pure chance (voir le calcul dans la doc du projet).
function buildInboxRound(
array $normalSubjects,
array $normalSenders,
array $normalPreviews,
array $spamSubjects,
array $spamSenders,
array $spamPreviews,
int $maxAttempts
): array {
$normalPool = [];
foreach ($normalSubjects as $index => $subject) {
$normalPool[] = [
'id' => 'normal_' . $index,
'sender' => $normalSenders[array_rand($normalSenders)],
'subject' => $subject,
'preview' => $normalPreviews[array_rand($normalPreviews)],
'is_spam' => false,
'reason' => null,
'time' => sprintf('%02d:%02d', random_int(8, 19), random_int(0, 59))
];
}
$spamPool = [];
foreach ($spamSubjects as $index => $spam) {
$spamPool[] = [
'id' => 'spam_' . $index,
'sender' => $spamSenders[array_rand($spamSenders)],
'subject' => $spam['text'],
'preview' => $spamPreviews[array_rand($spamPreviews)],
'is_spam' => true,
'reason' => $spam['reason'],
'time' => sprintf('%02d:%02d', random_int(0, 23), random_int(0, 59))
];
}
shuffle($normalPool);
shuffle($spamPool);
// Entre 1 et 3 mails frauduleux dans les 10 affichés (voir commentaire de la fonction)
$spamCount = random_int(1, 3);
$selectedSpam = array_slice($spamPool, 0, $spamCount);
$selectedNormal = array_slice($normalPool, 0, 10 - $spamCount);
$selected = array_merge($selectedSpam, $selectedNormal);
shuffle($selected);
$correctIds = array_map(function ($mail) {
return $mail['id'];
}, $selectedSpam);
sort($correctIds);
return [
'token' => bin2hex(random_bytes(16)),
'correct_ids' => $correctIds,
'emails' => $selected,
'attempts_left' => $maxAttempts,
'started_at' => microtime(true)
];
}
if (!isset($_SESSION['captcha_round']) || !is_array($_SESSION['captcha_round'])) {
$_SESSION['captcha_round'] = buildInboxRound(
$normalSubjects,
$normalSenders,
$normalPreviews,
$spamSubjects,
$spamSenders,
$spamPreviews,
$maxAttempts
);
}
$round = $_SESSION['captcha_round'];
$error = '';
$success = false;
$locked = false;
$foundReasons = [];
// Regénère une série si la structure en session est invalide ou date d'avant cette mise à
// jour (ancienne session sans correct_ids/started_at) : évite tout crash après déploiement
if (
!isset($round['emails']) || !is_array($round['emails']) ||
!isset($round['correct_ids']) || !is_array($round['correct_ids']) ||
!isset($round['started_at'])
) {
$_SESSION['captcha_round'] = buildInboxRound(
$normalSubjects,
$normalSenders,
$normalPreviews,
$spamSubjects,
$spamSenders,
$spamPreviews,
$maxAttempts
);
$round = $_SESSION['captcha_round'];
}
// Gestion de la soumission du formulaire
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$postedToken = $_POST['captcha_token'] ?? '';
$selectedMails = (isset($_POST['selected_mails']) && is_array($_POST['selected_mails']))
? array_map('strval', $_POST['selected_mails'])
: [];
sort($selectedMails);
// Honeypot : champ invisible pour un humain (masqué en CSS), que les robots qui
// remplissent tous les champs d'un formulaire ont tendance à compléter malgré tout
$honeypot = trim($_POST['website'] ?? '');
if (!hash_equals($round['token'], $postedToken)) {
$error = 'Session invalide, recharge la page.';
} elseif (($round['attempts_left'] ?? 0) <= 0) {
$locked = true;
$error = 'Tu nas plus dessais. Recharge une nouvelle série.';
} elseif ($honeypot !== '') {
// On fait échouer la tentative sans révéler la vraie raison, pour ne pas
// donner d'indice à un robot sur la façon dont il a été détecté
$_SESSION['captcha_round']['attempts_left']--;
$round = $_SESSION['captcha_round'];
$locked = ($round['attempts_left'] ?? 0) <= 0;
$error = $locked
? 'Perdu. Tu nas plus dessais. Recharge une nouvelle série.'
: 'Ce nest pas la bonne sélection. Regarde mieux les messages les plus agressifs ou trop urgents.';
sendLog('Captcha : champ piège rempli (robot probable)');
} elseif ((microtime(true) - (float) $round['started_at']) < $minSolveTime) {
// Une réponse aussi rapide n'est pas plausible pour un humain qui doit lire 10 mails
$_SESSION['captcha_round']['attempts_left']--;
$round = $_SESSION['captcha_round'];
$locked = ($round['attempts_left'] ?? 0) <= 0;
$error = $locked
? 'Perdu. Tu nas plus dessais. Recharge une nouvelle série.'
: 'Ce nest pas la bonne sélection. Prends le temps de lire les messages.';
sendLog('Captcha : soumission trop rapide (robot probable)');
} elseif ($selectedMails !== $round['correct_ids']) {
$_SESSION['captcha_round']['attempts_left']--;
$round = $_SESSION['captcha_round'];
if (($round['attempts_left'] ?? 0) <= 0) {
$locked = true;
$error = 'Perdu. Tu nas plus dessais. Recharge une nouvelle série.';
sendLog('Captcha raté : plus aucun essai restant (possible robot)');
} else {
$error = 'Ce nest pas la bonne sélection. Regarde mieux les messages les plus agressifs ou trop urgents.';
sendLog('Captcha raté : mauvaise sélection (' . $round['attempts_left'] . ' essai(s) restant(s))');
}
} else {
$_SESSION['captcha_valid'] = true;
// On récupère les explications des mails trouvés pour les afficher sur
// l'écran de succès (petit bonus pédagogique) avant de vider la manche
foreach ($round['emails'] as $mail) {
if ($mail['is_spam'] && !empty($mail['reason'])) {
$foundReasons[] = $mail['reason'];
}
}
unset($_SESSION['captcha_round']);
$success = true;
sendLog('Captcha réussi : nouveau visiteur sur le site');
}
}
$attemptsLeft = $round['attempts_left'] ?? 0;
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test anti-robot</title>
<link rel="icon" type="image/png" href="./memoji.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
<link rel="stylesheet" href="./styles/captcha.css">
</head>
<body class="<?php echo $success ? 'success-mode' : ''; ?>">
<main class="captcha-shell">
<?php if ($success): ?>
<section class="success-screen">
<div class="success-burst"></div>
<div class="success-icon">✅</div>
<h1>Vérification réussie</h1>
<p>Bravo, tu as repéré le(s) mail(s) frauduleux. Redirection en cours...</p>
<?php if (!empty($foundReasons)): ?>
<div class="success-reasons">
<p class="success-reasons-title">Ce que tu as repéré :</p>
<ul>
<?php foreach ($foundReasons as $reason): ?>
<li><i class="reason-dot"></i><?php echo htmlspecialchars($reason, ENT_QUOTES, 'UTF-8'); ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<div class="success-loader">
<span></span>
</div>
</section>
<script>
setTimeout(function () {
window.location.href = './index.php';
}, 2400);
</script>
<?php else: ?>
<form class="captcha-card" method="post" action="">
<div class="captcha-badge">🛡️ Test anti-robot</div>
<div class="captcha-top">
<div>
<h1 class="captcha-title">Vérification humaine</h1>
<p class="captcha-subtitle">
Sélectionne <strong>tous les emails qui te semblent frauduleux</strong> parmi les messages
ci-dessous. Certains sont plus dangereux que d'autres.
</p>
</div>
<div class="captcha-target">
<span>Vérification demandée</span>
<strong>Repérer le phishing</strong>
</div>
</div>
<div class="captcha-stats">
<div class="captcha-attempts">
<span>Essais restants</span>
<strong><?php echo htmlspecialchars((string) $attemptsLeft, ENT_QUOTES, 'UTF-8'); ?> / <?php echo htmlspecialchars((string) $maxAttempts, ENT_QUOTES, 'UTF-8'); ?></strong>
<div class="attempts-dots">
<?php for ($i = 0; $i < $maxAttempts; $i++): ?>
<span class="<?php echo $i < $attemptsLeft ? 'dot-filled' : 'dot-empty'; ?>"></span>
<?php endfor; ?>
</div>
</div>
<?php if ($locked): ?>
<a class="captcha-reset" href="./captcha.php?reload=1">Recharger une nouvelle série</a>
<?php endif; ?>
</div>
<?php if ($error !== ''): ?>
<div class="captcha-error"><?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?></div>
<?php endif; ?>
<div class="mailbox">
<div class="mailbox-toolbar">
<div class="mailbox-dots">
<span></span>
<span></span>
<span></span>
</div>
<div class="mailbox-label">Test anti-robot : cochez les mails qui ressemblent à une arnaque</div>
</div>
<div class="mail-list">
<?php foreach ($round['emails'] as $index => $mail): ?>
<label class="mail-option <?php echo $locked ? 'mail-option-disabled' : ''; ?>" style="--mail-index: <?php echo (int) $index; ?>">
<input
type="checkbox"
name="selected_mails[]"
value="<?php echo htmlspecialchars($mail['id'], ENT_QUOTES, 'UTF-8'); ?>"
<?php echo $locked ? 'disabled' : ''; ?>
>
<span class="mail-row">
<span class="mail-check"><i class="fa-solid fa-check"></i></span>
<span class="mail-avatar">
<?php echo htmlspecialchars(firstLetterSafe($mail['sender']), ENT_QUOTES, 'UTF-8'); ?>
</span>
<span class="mail-sender">
<?php echo htmlspecialchars($mail['sender'], ENT_QUOTES, 'UTF-8'); ?>
</span>
<span class="mail-content">
<span class="mail-subject">
<?php echo htmlspecialchars($mail['subject'], ENT_QUOTES, 'UTF-8'); ?>
</span>
<span class="mail-preview">
<?php echo htmlspecialchars($mail['preview'], ENT_QUOTES, 'UTF-8'); ?>
</span>
</span>
<span class="mail-time">
<?php echo htmlspecialchars($mail['time'], ENT_QUOTES, 'UTF-8'); ?>
</span>
</span>
</label>
<?php endforeach; ?>
</div>
</div>
<input
type="hidden"
name="captcha_token"
value="<?php echo htmlspecialchars($round['token'], ENT_QUOTES, 'UTF-8'); ?>"
>
<!-- Champ piège (honeypot) : invisible et exclu du tabulateur pour un humain,
mais souvent rempli automatiquement par les robots qui remplissent tout -->
<div class="captcha-honeypot" aria-hidden="true">
<label for="website">Laisse ce champ vide</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
</div>
<div class="captcha-footer">
<p class="captcha-note">
Tu as plusieurs essais. Les faux mails utilisent souvent lurgence, les cadeaux improbables, les menaces ou les demandes de mot de passe.
</p>
<div class="captcha-actions">
<?php if ($locked): ?>
<a class="captcha-secondary-button" href="./captcha.php?reload=1">Nouvelle série</a>
<?php else: ?>
<button type="submit" class="captcha-submit">Valider ma sélection</button>
<?php endif; ?>
</div>
</div>
</form>
<?php endif; ?>
</main>
</body>
</html>
+109 -19
View File
@@ -1,52 +1,142 @@
-- On créer la base de donnée si elle n'existe déjà aps
CREATE DATABASE IF NOT EXISTS portfolio
CREATE DATABASE IF NOT EXISTS portfolio;
USE portfolio;
-- On créer un utilisateur avec un mot de passe fort qui est à changer après execution dus script (voir bitwarden)
CREATE USER 'srv_portfolio'@'localhost' IDENTIFIED BY 'mdp';
-- On met les droits au compte sur la base portfolio à l'utilisateur
GRANT ALL ON portfolio.* TO 'srv_portfolio'@'localhost';
-- On actualise les permissions
-- Mot de passe à changer après exécution du script (voir Bitwarden)
CREATE USER IF NOT EXISTS 'srv_portfolio'@'%' IDENTIFIED BY 'mdp';
GRANT ALL ON portfolio.* TO 'srv_portfolio'@'%';
FLUSH PRIVILEGES;
-- Création de la table user : (Généré par Beekeeper Studio : équivalent à dbGate)
CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(50) NOT NULL,
password VARCHAR(255) NOT NULL, -- Je laisse ici de la place pour prévoir un futur hashage et sallage
password VARCHAR(255) NOT NULL,
role ENUM('admin', 'user') NOT NULL DEFAULT 'user',
created_at DATETIME NULL DEFAULT NULL,
last_login DATETIME NULL DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE INDEX idx_username (username)
);
-- Création de la table projects : (Généré par Beekeeper Studio)
INSERT IGNORE INTO users (id, created_at, last_login, password, role, username)
VALUES (11, '2026-06-08 11:10:21', '2026-06-08 13:10:41', '$2y$12$x01fDqrHQEYIyj7MDHa72eRyl2j/n5cNq9sv3.FIiQCaRsB50Q4iK', 'admin', 'admin');
CREATE TABLE IF NOT EXISTS projects (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
title VARCHAR(100) NOT NULL,
status VARCHAR(100) NULL,
tags VARCHAR(255) NULL,
description TEXT NOT NULL,
image_url VARCHAR(255) NOT NULL, -- Stockage de l'image par URL (exemple : par CDN)
image_url VARCHAR(255) NOT NULL,
markdown LONGTEXT NULL,
link_url VARCHAR(255) NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
-- Création de la table projects : (Généré par Beekeeper Studio)
CREATE TABLE IF NOT EXISTS experiences (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
title VARCHAR(100) NOT NULL,
status VARCHAR(100) NULL,
xptype VARCHAR(100) NOT NULL,
tags VARCHAR(255) NULL,
description TEXT NOT NULL,
duration VARCHAR(100) NOT NULL,
image_url VARCHAR(255) NOT NULL, -- Stockage de l'image par URL (exemple : par CDN)
image_url VARCHAR(255) NOT NULL,
markdown LONGTEXT NULL,
link_url VARCHAR(255) NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
-- Compte de test
INSERT INTO users (username, password, role)
VALUES ('admin', 'admin', 'admin');
CREATE TABLE IF NOT EXISTS schools (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
title VARCHAR(100) NOT NULL,
status VARCHAR(100) NULL,
tags VARCHAR(255) NULL,
description TEXT NOT NULL,
duration VARCHAR(100) NOT NULL,
image_url VARCHAR(255) NOT NULL,
markdown LONGTEXT NULL,
link_url VARCHAR(255) NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
-- Réglages du site (clé / valeur), utilisés pour la notification de la page d'accueil et le captcha
CREATE TABLE IF NOT EXISTS settings (
name VARCHAR(50) NOT NULL,
value TEXT NULL,
PRIMARY KEY (name)
);
INSERT INTO settings (name, value) VALUES
('site_notice_title', 'Des bugs peuvent encore se produire.'),
('site_notice_text', 'Si le CSS ou les images s''affichent mal, pensez à vider le cache de votre navigateur (Ctrl + F5 ou Cmd + Shift + R) pour voir les dernières modifications.'),
('site_notice_enabled', '1'),
('captcha_enabled', '1'),
('contact_email', 'esteban.moussu@ik.me'),
('contact_email_enabled', '1'),
('contact_address_line1', '20 Grande rue'),
('contact_address_line2', '52500 Broncourt'),
('contact_address_enabled', '1')
ON DUPLICATE KEY UPDATE value = VALUES(value);
-- items : un item par ligne, au format "classes-icone|Texte" (icône optionnelle)
CREATE TABLE IF NOT EXISTS skills (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
title VARCHAR(100) NOT NULL,
icon VARCHAR(100) NULL,
items TEXT NOT NULL,
position INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
INSERT INTO skills (title, icon, items, position) VALUES
('Systèmes d''exploitation', 'fa-solid fa-computer', 'fa-brands fa-microsoft|Windows (Serveur/XP/8/10/11)\nfa-brands fa-linux|Linux (Debian/Ubuntu)\nfa-brands fa-apple|macOS', 1),
('Réseaux', 'fa-solid fa-network-wired', 'fa-solid fa-location-dot|Adressage IP\nfa-solid fa-diagram-project|VLAN\nfa-solid fa-shield-halved|Pare-feu (iptables/ufw)\nfa-solid fa-globe|DNS (Bind9)\nfa-solid fa-server|DHCP (ISC DHCP)\nfa-solid fa-arrows-left-right|Proxy (Squid)', 2),
('Scripting', 'fa-solid fa-terminal', 'fa-solid fa-terminal|Bash\nfa-brands fa-python|Python\nfa-brands fa-java|Java\nfa-solid fa-gears|Ansible', 3),
('Applications', 'fa-solid fa-cubes', 'fa-solid fa-address-book|LDAP (Windows AD)\nfa-solid fa-headset|GLPI\nfa-solid fa-compact-disc|FOG\nfa-solid fa-server|Nginx / Apache\nfa-brands fa-docker|Docker\nfa-solid fa-display|Guacamole\nfa-solid fa-database|MySQL / MariaDB', 4),
('Développement web', 'fa-solid fa-code', 'fa-brands fa-html5|HTML\nfa-brands fa-css3-alt|CSS\nfa-brands fa-js|JavaScript\nfa-brands fa-php|PHP', 5),
('Virtualisation & Outils', 'fa-solid fa-layer-group', 'fa-solid fa-server|Proxmox\nfa-solid fa-box|VirtualBox\nfa-brands fa-git-alt|Git (GitHub, Gitea)', 6);
-- category : 'tool' (bandeau "Outils que je sais manipuler") ou 'partner' (bandeau "Ils m'ont fait confiance")
-- icon : classes CSS Devicon, prioritaire sur logo_url si les deux sont renseignés (au moins un des deux requis)
CREATE TABLE IF NOT EXISTS logo_banners (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
category ENUM('tool', 'partner') NOT NULL,
name VARCHAR(100) NOT NULL,
icon VARCHAR(100) NULL,
logo_url VARCHAR(255) NULL,
link_url VARCHAR(255) NULL,
position INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
-- PDF de projects/experiences/schools : tables associées plutôt qu'une colonne sur la table
-- parente, pour que les pages qui LISTENT ces éléments n'aient jamais à charger le contenu
-- binaire des PDF en mémoire (seul le nom de fichier y est utilisé)
CREATE TABLE IF NOT EXISTS project_pdfs (
project_id INT UNSIGNED NOT NULL,
pdf_filename VARCHAR(255) NOT NULL,
pdf_data LONGBLOB NOT NULL,
PRIMARY KEY (project_id),
CONSTRAINT fk_project_pdfs_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS experience_pdfs (
experience_id INT UNSIGNED NOT NULL,
pdf_filename VARCHAR(255) NOT NULL,
pdf_data LONGBLOB NOT NULL,
PRIMARY KEY (experience_id),
CONSTRAINT fk_experience_pdfs_experience FOREIGN KEY (experience_id) REFERENCES experiences(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS school_pdfs (
school_id INT UNSIGNED NOT NULL,
pdf_filename VARCHAR(255) NOT NULL,
pdf_data LONGBLOB NOT NULL,
PRIMARY KEY (school_id),
CONSTRAINT fk_school_pdfs_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE
);
+14
View File
@@ -0,0 +1,14 @@
; Config PHP pour le conteneur Docker (image php:8.2-apache)
; Copiée dans /usr/local/etc/php/conf.d/ par le Dockerfile généré par scriptsite.sh
; Augmente les limites d'upload pour permettre le dépôt de PDF depuis le panel admin
; post_max_size doit toujours être >= upload_max_filesize
upload_max_filesize = 20M
post_max_size = 20M
; Coupe l'affichage des erreurs PHP dans le HTML en production (sécurité : ne pas exposer
; les chemins serveur aux visiteurs) et les envoie dans les logs Apache/Docker à la place.
; Bonus : évite les warnings HTML qui provoquaient "headers already sent" une fois affichés
; avant le session_start()/header() des scripts.
display_errors = Off
log_errors = On
+60 -2
View File
@@ -1,20 +1,78 @@
<?php
// Ce Footer sera inseré sur des pages grâce a la fonction require_once('./../elements/footer.php')
// Rappel des différentes pages : Acceuil, Projets, Contact
// Notifications "toast" : affichées selon le paramètre présent dans l'URL
// (?success=1 après un message de contact, ?login=1 après connexion, ?logout=1 après déconnexion, ?import=1 après un import)
$toast = null;
if (isset($_GET['success']) && $_GET['success'] === '1') {
$toast = 'Votre message a bien été envoyé !';
} elseif (isset($_GET['login']) && $_GET['login'] === '1') {
$toast = 'Connexion réussie' . (isset($_SESSION['username']) ? ', bonjour ' . htmlspecialchars($_SESSION['username']) . ' !' : ' !');
} elseif (isset($_GET['logout']) && $_GET['logout'] === '1') {
$toast = 'Vous avez bien été déconnecté.';
} elseif (isset($_GET['import']) && $_GET['import'] === '1') {
$toast = 'Les données ont bien été importées.';
}
?>
</main>
<?php if ($toast !== null): ?>
<!-- Notification toast : disparaît toute seule, clic pour fermer -->
<div class="toast" onclick="this.remove()" role="status">
<i class="fa-solid fa-circle-check"></i>
<span><?php echo $toast; ?></span>
</div>
<?php endif; ?>
<footer class="site-footer">
<div class="footer-content">
<p class="footer-copyright">
<!-- Logo droit d'auteur + année -->
&copy; <?php echo date('Y'); ?> - Portfolio développé par Moussu Esteban
&copy; <?php echo date('Y'); ?> - Portfolio développé par Esteban MOUSSU
</p>
<div class="footer-links">
<a href="https://www.linkedin.com/in/estebanmoussu/" target="_blank" rel="noopener noreferrer">LinkedIn</a>
<a href="https://www.instagram.com/ours_one_" target="_blank" rel="noopener noreferrer">Instagram</a>
<a href="https://git.eldorianet.work/esteban" target="_blank" rel="noopener noreferrer">Git</a>
<span class="discord-handle" title="@ours1" onclick="alert('Discord : @ours1')">Discord</span>
<a href="../pages/cgu.php">Conditions générales / Utilisations</a>
<a href="../pages/cgu.php">CGU</a>
</div>
</div>
</footer>
<!-- Apparition des cartes au défilement (IntersectionObserver) -->
<script>
(function () {
// On respecte la préférence "réduire les animations" du système
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
if (!('IntersectionObserver' in window)) return;
// Éléments qui apparaissent en glissant vers le haut quand ils entrent dans l'écran
var cibles = document.querySelectorAll(
'.project-card, .quick-link-card, .skill-category, .contact-card, ' +
'.login-card, .project-form-card, .cgu-card, .logo-admin-item, ' +
'.page-header, .quick-links-header, .skills-header, .projects-title, .logo-banner-header, ' +
'.stat-item'
);
var observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.1, rootMargin: '0px 0px -40px 0px' });
cibles.forEach(function (el, i) {
el.classList.add('reveal');
// Petit décalage en cascade pour les éléments d'une même grille
el.style.setProperty('--reveal-delay', ((i % 4) * 0.08) + 's');
observer.observe(el);
});
})();
</script>
</body>
</html>
+98 -13
View File
@@ -6,26 +6,97 @@
// Ajout ici d'un démarrage de session : header étant dans toutes les pages
session_start();
require_once(__DIR__ . '/../functions/security.php');
// Ajout ici la vérification du captcha
require_once('../scripts/requireCaptcha.php');
// Ajout du CSS Primaire (fait par IA)
// Ajout des logs sur toutes les pages
require_once('../functions/sendLogs.php');
// Log de la visite de la page sur Discord (anti-spam : 1 log max par page / minute / visiteur)
sendPageLog();
// Titre de la page : chaque page peut définir $pageTitle avant d'inclure le header
$pageTitle = isset($pageTitle) ? $pageTitle . ' — Esteban MOUSSU' : 'Esteban MOUSSU — Portfolio';
// Description : chaque page peut définir $pageDescription avant d'inclure le header
// (sinon description générique, utilisée pour le SEO et les aperçus de partage)
$pageDescription = isset($pageDescription)
? $pageDescription
: 'Portfolio d\'Esteban MOUSSU, étudiant en informatique passionné par les systèmes, les réseaux et le développement web. Projets, expériences et compétences.';
// URL absolue de la page courante (pour og:url et le lien canonique)
$pageUrl = 'https://estebanadmin.fr' . strtok($_SERVER['REQUEST_URI'], '?');
// Page courante pour souligner le lien actif dans la navigation
$currentPage = basename($_SERVER['PHP_SELF']);
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<!-- Meta viewport : indispensable pour que le site soit responsive sur mobile -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php echo htmlspecialchars($pageTitle); ?></title>
<link rel="stylesheet" href="../styles/primary_css.css">
<!-- SEO : description + lien canonique -->
<meta name="description" content="<?php echo htmlspecialchars($pageDescription); ?>">
<link rel="canonical" href="<?php echo htmlspecialchars($pageUrl); ?>">
<!-- Aperçu de partage (réseaux sociaux, Discord, etc.) -->
<meta property="og:type" content="website">
<meta property="og:title" content="<?php echo htmlspecialchars($pageTitle); ?>">
<meta property="og:description" content="<?php echo htmlspecialchars($pageDescription); ?>">
<meta property="og:url" content="<?php echo htmlspecialchars($pageUrl); ?>">
<meta property="og:image" content="https://estebanadmin.fr/memoji.png">
<meta property="og:locale" content="fr_FR">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="<?php echo htmlspecialchars($pageTitle); ?>">
<meta name="twitter:description" content="<?php echo htmlspecialchars($pageDescription); ?>">
<meta name="twitter:image" content="https://estebanadmin.fr/memoji.png">
<!-- Favicon du site -->
<link rel="icon" type="image/png" href="../memoji.png">
<!-- Applique le thème sauvegardé AVANT le chargement du CSS pour éviter un flash sombre/clair -->
<script>
(function () {
if (localStorage.getItem('theme') === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
}
})();
</script>
<!-- Police moderne Inter (Google Fonts) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<!-- Ajout du CSS Primaire (fait par IA) -->
<link rel="stylesheet" href="../styles/primary_css.css">
<!-- Ajout de Font Awesome pour les icones de home.php-->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
<!-- Devicon : équivalent gratuit de Font Awesome dédié aux logos d'outils/langages/frameworks,
utilisé pour le bandeau "Outils que je sais manipuler" de la page d'accueil -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/devicon@2.15.1/devicon.min.css">
</head>
<body>
<!-- Header + nav -->
<header>
<header class="site-header">
<!-- Texte qui sera dans le header -->
<a href="./../index.php" class="titre-de-page">Esteban MOUSSU - PortFolio</a>
<div class="header-right">
<!-- Module de navigation -->
<nav>
<a href="./../index.php">Accueil</a>
<a href="./projects.php">Projets</a>
<a href="./xp.php">Expériences</a>
<a href="./contact.php">Contact</a>
<nav class="site-nav">
<a href="./../pages/home.php" class="<?php echo ($currentPage === 'home.php') ? 'active' : ''; ?>">Accueil</a>
<a href="./../pages/projects.php" class="<?php echo ($currentPage === 'projects.php') ? 'active' : ''; ?>">Projets</a>
<a href="./../pages/xp.php" class="<?php echo ($currentPage === 'xp.php') ? 'active' : ''; ?>">Expériences</a>
<a href="./../pages/skills.php" class="<?php echo ($currentPage === 'skills.php') ? 'active' : ''; ?>">Compétences</a>
<a href="./../pages/contact.php" class="<?php echo ($currentPage === 'contact.php') ? 'active' : ''; ?>">Contact</a>
<?php
// Vérification que l'utilisateur est admin, s'il est admin, il aura accès au panel admin
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
@@ -33,7 +104,8 @@ session_start();
} else {
?>
<a href="./admin.php">Administration</a>
<!-- Lien vers le panneau admin -->
<a href="./../pages/admin.php" class="<?php echo ($currentPage === 'admin.php') ? 'active' : ''; ?>">Administration</a>
<?php
}
@@ -44,18 +116,31 @@ session_start();
if (isset($_SESSION['user_id'])){
?>
<a href="../scripts/disconnect.php">Deconnexion</a>
<a href="../scripts/disconnect.php" class="nav-cta">Deconnexion</a>
<?php
} else {
?>
<a href="./login.php">Connexion</a>
<a href="./../pages/login.php" class="nav-cta <?php echo ($currentPage === 'login.php') ? 'active' : ''; ?>">Connexion</a>
<?php
}
?>
<!-- Fonctionnalité future changement de thème dark / light et panneau admin -->
</nav>
<!-- Bouton de changement de thème dark / light : le choix est mémorisé dans le navigateur (localStorage) -->
<button class="theme-toggle" aria-label="Changer de thème"
onclick="var h=document.documentElement;var l=h.getAttribute('data-theme')==='light';if(l){h.removeAttribute('data-theme');localStorage.setItem('theme','dark');}else{h.setAttribute('data-theme','light');localStorage.setItem('theme','light');}">
<i class="fa-solid fa-sun"></i><i class="fa-solid fa-moon"></i>
</button>
<!-- Bouton burger : visible uniquement sur mobile, ouvre/ferme la navigation -->
<button class="nav-toggle" aria-label="Ouvrir le menu" aria-expanded="false"
onclick="var h=document.querySelector('.site-header');h.classList.toggle('nav-open');this.setAttribute('aria-expanded',h.classList.contains('nav-open'));">
<span></span><span></span><span></span>
</button>
</div>
</header>
<main>
+127
View File
@@ -0,0 +1,127 @@
<?php
// Ajout de connexion a la bdd
require_once('../functions/databaseConnection.php');
// Fonction qui va faire une requête SQL afin de collecter les logos
// $category vaut 'tool', 'partner', ou null pour récupérer les deux catégories
// (toutes catégories confondues, utilisé par le panel admin)
// Retourne un tableau vide si la table n'existe pas encore (pas de die() pour que la page
// d'accueil continue de fonctionner tant que le script SQL n'a pas été exécuté)
function listeLogos($category = null) {
global $dbh;
try {
// SELECT * : la colonne icon est optionnelle (le bandeau continue de fonctionner
// avec les logos déjà en place même si la migration SQL n'a pas encore été exécutée)
if ($category !== null) {
$stmt = $dbh->prepare('SELECT * FROM logo_banners WHERE category = :category ORDER BY position ASC, id ASC');
$stmt->bindValue(':category', $category, PDO::PARAM_STR);
} else {
$stmt = $dbh->prepare('SELECT * FROM logo_banners ORDER BY category ASC, position ASC, id ASC');
}
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
return [];
}
}
// Fonction qui permet de récupérer un logo par id (pour la modification dans le panel Admin)
function recupererLogoParId($id) {
global $dbh;
try {
$stmt = $dbh->prepare('SELECT * FROM logo_banners WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetch(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
return false;
}
}
// Fonction interne qui affiche le contenu visuel d'une tuile : une icône Devicon si elle est
// renseignée (prioritaire), sinon l'image. Ne retourne rien si aucun des deux n'est présent.
function afficherLogoVisuel($logo) {
$icone = trim($logo['icon'] ?? '');
$image = trim($logo['logo_url'] ?? '');
$nom = htmlspecialchars($logo['name']);
if ($icone !== '') {
echo '<i class="' . htmlspecialchars($icone) . '"></i>';
} elseif ($image !== '') {
echo '<img src="' . htmlspecialchars($image) . '" alt="' . $nom . '" loading="lazy">';
}
}
// Fonction qui affiche le bandeau défilant d'une catégorie de logos sur la page publique
// N'affiche rien si la catégorie est vide (la section entière est masquée côté page d'accueil)
function afficherLogos($category) {
$logos = listeLogos($category);
if (empty($logos)) {
return;
}
$sens = $category === 'partner' ? ' logo-marquee-reverse' : '';
echo '<div class="logo-marquee' . $sens . '">';
echo '<div class="logo-track">';
// On répète deux fois la liste : le défilement CSS boucle sur -50%, ce qui donne
// un effet de scroll infini fluide et sans coupure (voir styles/primary_css.css)
for ($repetition = 0; $repetition < 2; $repetition++) {
foreach ($logos as $logo) {
$nom = htmlspecialchars($logo['name']);
$lien = trim($logo['link_url'] ?? '');
if ($lien !== '') {
echo '<a class="logo-tile" href="' . htmlspecialchars($lien) . '" target="_blank" rel="noopener noreferrer" title="' . $nom . '">';
} else {
echo '<div class="logo-tile" title="' . $nom . '">';
}
afficherLogoVisuel($logo);
echo $lien !== '' ? '</a>' : '</div>';
}
}
echo '</div>';
echo '</div>';
}
// Fonction qui affiche la liste des logos (toutes catégories) avec boutons de
// modification / suppression, pour le panel admin
function afficherLogosAdmin() {
$logos = listeLogos();
if (empty($logos)) {
echo '<p class="admin-notice">Aucun logo pour le moment.</p>';
return;
}
echo '<div class="logo-admin-grid">';
foreach ($logos as $logo) {
$id = (int) $logo['id'];
$nom = htmlspecialchars($logo['name']);
$badge = $logo['category'] === 'partner' ? 'Entreprise' : 'Outil';
echo '<div class="logo-admin-item">';
echo '<div class="logo-admin-preview">';
afficherLogoVisuel($logo);
echo '</div>';
echo '<div class="logo-admin-info">';
echo '<span class="tag">' . $badge . '</span>';
echo '<p>' . $nom . '</p>';
echo '</div>';
echo '<div class="admin-actions">';
echo '<a class="btn-submit btn-edit" href="./admin.php?edit_logo=' . $id . '#admin-logos">Modifier</a>';
echo '<a class="btn-submit btn-delete" href="../scripts/removeLogo.php?id=' . $id . '&csrf_token=' . urlencode(csrfToken()) . '" onclick="return confirm(\'Voulez-vous vraiment supprimer ce logo ?\');">Supprimer</a>';
echo '</div>';
echo '</div>';
}
echo '</div>';
}
+51 -5
View File
@@ -10,8 +10,17 @@ function listeProjets() {
// Essayer
try {
// Requête SQL qui récupère titre, tags, description, image_url
$stmt = $dbh->prepare('SELECT id, title, tags, description, image_url FROM projects ORDER BY id DESC');
// On liste les colonnes explicitement (pas de SELECT *) : le PDF (pdf_data) est
// stocké à part dans project_pdfs (voir migration Juillet 2026 (6)), on ne récupère
// ici que son nom de fichier via LEFT JOIN, sans jamais charger son contenu binaire
// (jusqu'à 15 Mo par PDF) en mémoire pour une simple liste de projets
$stmt = $dbh->prepare('
SELECT p.id, p.title, p.status, p.tags, p.description, p.image_url, p.markdown, p.link_url, p.created_at,
pp.pdf_filename
FROM projects p
LEFT JOIN project_pdfs pp ON pp.project_id = p.id
ORDER BY p.id DESC
');
// Execution de la requête SQL
$stmt->execute();
// Retourne toutes les données collectés dans un ARRAY
@@ -27,7 +36,15 @@ function recupererProjetParId($id) {
global $dbh;
try {
$stmt = $dbh->prepare('SELECT id, title, tags, description, image_url FROM projects WHERE id = :id');
// Même principe que listeProjets() : le PDF est joint pour son nom de fichier
// uniquement, jamais son contenu binaire
$stmt = $dbh->prepare('
SELECT p.id, p.title, p.status, p.tags, p.description, p.image_url, p.markdown, p.link_url, p.created_at,
pp.pdf_filename
FROM projects p
LEFT JOIN project_pdfs pp ON pp.project_id = p.id
WHERE p.id = :id
');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetch(PDO::FETCH_ASSOC);
@@ -50,9 +67,14 @@ function afficherProjets() {
// Pour chaque projet dans le ARRAY
foreach ($projets as $projet) {
// On récupère le titre, la description et l'image et le tableau avec les tags
$id = (int) $projet['id'];
$titre = htmlspecialchars($projet['title']);
$description = nl2br(htmlspecialchars($projet['description']));
$image = htmlspecialchars($projet['image_url']);
$status = htmlspecialchars($projet['status'] ?? '');
$markdown = trim($projet['markdown'] ?? '');
$lien = trim($projet['link_url'] ?? '');
$pdf = trim($projet['pdf_filename'] ?? '');
$tags = [];
// S'il y a des tags alors on les sépares grâce à la virgule que l'user à ajouté dans le champs (tags1,tags2,etcc...)
@@ -66,6 +88,11 @@ function afficherProjets() {
if (!empty($image)) {
echo '<div class="project-banner">';
echo '<img src="' . $image . '" alt="Image du projet ' . $titre . '">';
if (!empty($status)) {
echo '<div class="project-state">' . $status . '</div>';
}
echo '</div>';
}
@@ -84,6 +111,19 @@ function afficherProjets() {
// Ajout de la description et fin des classes
echo '<p>' . $description . '</p>';
// Indicateur de page détaillée : markdown > PDF > lien externe > rien
// Le lien rend toute la carte cliquable (voir CSS .card-link)
if ($markdown !== '') {
echo '<a class="card-link" href="./details.php?type=projet&id=' . $id . '"><i class="fa-solid fa-file-lines"></i> Voir la page détaillée</a>';
} elseif ($pdf !== '') {
echo '<a class="card-link" href="./details.php?type=projet&id=' . $id . '"><i class="fa-solid fa-file-pdf"></i> Voir le PDF</a>';
} elseif ($lien !== '') {
echo '<a class="card-link" href="' . $lien . '" target="_blank" rel="noopener noreferrer"><i class="fa-solid fa-arrow-up-right-from-square"></i> Voir le lien externe</a>';
} else {
echo '<span class="card-link card-link-none"><i class="fa-solid fa-eye-slash"></i> Pas de page détaillée</span>';
}
echo '</div>';
echo '</article>';
@@ -103,6 +143,7 @@ function afficherProjetsAdmin() {
$titre = htmlspecialchars($projet['title']);
$description = nl2br(htmlspecialchars($projet['description']));
$image = htmlspecialchars($projet['image_url']);
$status = htmlspecialchars($projet['status'] ?? '');
$tags = [];
if (!empty($projet['tags'])) {
@@ -114,6 +155,11 @@ function afficherProjetsAdmin() {
if (!empty($image)) {
echo '<div class="project-banner">';
echo '<img src="' . $image . '" alt="Image du projet ' . $titre . '">';
if (!empty($status)) {
echo '<div class="project-state">' . $status . '</div>';
}
echo '</div>';
}
@@ -132,8 +178,8 @@ function afficherProjetsAdmin() {
// Boutons qui vont permettrent la modification / Suppresion
echo '<div class="admin-actions">';
echo '<a class="btn-submit btn-edit" href="./admin.php?edit_project=' . $id . '">Modifier</a>';
echo '<a class="btn-submit btn-delete" href="../scripts/removeProject.php?id=' . $id . '" onclick="return confirm(\'Voulez-vous vraiment supprimer ce projet ?\');">Supprimer</a>';
echo '<a class="btn-submit btn-edit" href="./admin.php?edit_project=' . $id . '#admin-projets">Modifier</a>';
echo '<a class="btn-submit btn-delete" href="../scripts/removeProject.php?id=' . $id . '&csrf_token=' . urlencode(csrfToken()) . '" onclick="return confirm(\'Voulez-vous vraiment supprimer ce projet ?\');">Supprimer</a>';
echo '</div>';
echo '</div>';
+150
View File
@@ -0,0 +1,150 @@
<?php
// Ajout de connexion a la bdd
require_once('../functions/databaseConnection.php');
// Fonction qui va faire une requête SQL afin de collecter toutes les écoles / formations
// Retourne un tableau vide si la table n'existe pas encore (pas de die() pour que la page
// Expériences continue de fonctionner tant que le script SQL n'a pas été exécuté)
function listeEcoles() {
global $dbh;
try {
// On liste les colonnes explicitement (pas de SELECT *) : le PDF (pdf_data) est
// stocké à part dans school_pdfs (voir migration Juillet 2026 (6)), on ne récupère
// ici que son nom de fichier via LEFT JOIN, sans jamais charger son contenu binaire
// (jusqu'à 15 Mo par PDF) en mémoire pour une simple liste d'écoles
$stmt = $dbh->prepare('
SELECT s.id, s.title, s.status, s.tags, s.description, s.duration, s.image_url, s.markdown, s.link_url, s.created_at,
sp.pdf_filename
FROM schools s
LEFT JOIN school_pdfs sp ON sp.school_id = s.id
ORDER BY s.id DESC
');
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
return [];
}
}
// Fonction qui permet de récupérer une école par id (pour la modification dans le panel Admin)
function recupererEcoleParId($id) {
global $dbh;
try {
// Même principe que listeEcoles() : le PDF est joint pour son nom de fichier
// uniquement, jamais son contenu binaire
$stmt = $dbh->prepare('
SELECT s.id, s.title, s.status, s.tags, s.description, s.duration, s.image_url, s.markdown, s.link_url, s.created_at,
sp.pdf_filename
FROM schools s
LEFT JOIN school_pdfs sp ON sp.school_id = s.id
WHERE s.id = :id
');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetch(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
return false;
}
}
// Fonction interne qui affiche une carte école (utilisée par la page publique et le panel admin)
function afficherEcoleCard($ecole, $admin = false) {
$id = (int) $ecole['id'];
$titre = htmlspecialchars($ecole['title']);
$description = nl2br(htmlspecialchars($ecole['description']));
$duration = htmlspecialchars($ecole['duration']);
$image = htmlspecialchars($ecole['image_url']);
$status = htmlspecialchars($ecole['status'] ?? '');
$markdown = trim($ecole['markdown'] ?? '');
$lien = trim($ecole['link_url'] ?? '');
$pdf = trim($ecole['pdf_filename'] ?? '');
$tags = [];
// S'il y a des tags alors on les sépares grâce à la virgule (tags1,tags2,etc...)
if (!empty($ecole['tags'])) {
$tags = array_filter(array_map('trim', explode(',', $ecole['tags'])));
}
echo '<article class="project-card">';
// Si la donnée avec l'image n'est pas null alors on l'affiche
if (!empty($image)) {
echo '<div class="project-banner">';
echo '<img src="' . $image . '" alt="Image de l ecole ' . $titre . '">';
if (!empty($status)) {
echo '<div class="project-state">' . $status . '</div>';
}
echo '</div>';
}
echo '<div class="project-content">';
echo '<h2>' . $titre . '</h2>';
echo '<p><strong>Période :</strong> ' . $duration . '</p>';
if (!empty($tags)) {
echo '<div class="project-tags">';
foreach ($tags as $tag) {
echo '<span class="tag">' . htmlspecialchars($tag) . '</span>';
}
echo '</div>';
}
echo '<p>' . $description . '</p>';
// Indicateur de page détaillée (uniquement sur la page publique) : markdown > PDF > lien externe > rien
if (!$admin) {
if ($markdown !== '') {
echo '<a class="card-link" href="./details.php?type=ecole&id=' . $id . '"><i class="fa-solid fa-file-lines"></i> Voir la page détaillée</a>';
} elseif ($pdf !== '') {
echo '<a class="card-link" href="./details.php?type=ecole&id=' . $id . '"><i class="fa-solid fa-file-pdf"></i> Voir le PDF</a>';
} elseif ($lien !== '') {
echo '<a class="card-link" href="' . $lien . '" target="_blank" rel="noopener noreferrer"><i class="fa-solid fa-arrow-up-right-from-square"></i> Voir le lien externe</a>';
} else {
echo '<span class="card-link card-link-none"><i class="fa-solid fa-eye-slash"></i> Pas de page détaillée</span>';
}
}
// Boutons de modification / suppression pour le panel admin
if ($admin) {
echo '<div class="admin-actions">';
echo '<a class="btn-submit btn-edit" href="./admin.php?edit_school=' . $id . '#admin-ecoles">Modifier</a>';
echo '<a class="btn-submit btn-delete" href="../scripts/removeSchool.php?id=' . $id . '&csrf_token=' . urlencode(csrfToken()) . '" onclick="return confirm(\'Voulez-vous vraiment supprimer cette école ?\');">Supprimer</a>';
echo '</div>';
}
echo '</div>';
echo '</article>';
}
// Fonction qui va afficher les écoles sur la page publique
function afficherEcoles() {
$ecoles = listeEcoles();
// Si la table est vide ou n'existe pas encore on n'affiche rien
if (empty($ecoles)) {
return;
}
echo '<div class="projects-list">';
foreach ($ecoles as $ecole) {
afficherEcoleCard($ecole, false);
}
echo '</div>';
}
// Fonction identique à celle en haut mais ajout de boutons pour le panel admin
function afficherEcolesAdmin() {
$ecoles = listeEcoles();
echo '<div class="projects-list">';
foreach ($ecoles as $ecole) {
afficherEcoleCard($ecole, true);
}
echo '</div>';
}
+48
View File
@@ -0,0 +1,48 @@
<?php
// Ajout de connexion a la bdd
require_once('../functions/databaseConnection.php');
// Fonction qui récupère un réglage dans la table settings (clé / valeur)
// Retourne null si le réglage n'existe pas OU si la table n'a pas encore été créée :
// contrairement aux autres fonctions on ne fait pas de die() ici pour que le site
// continue de fonctionner tant que le script SQL n'a pas été exécuté
function recupererSetting($name) {
global $dbh;
try {
$stmt = $dbh->prepare('SELECT value FROM settings WHERE name = :name');
$stmt->bindValue(':name', $name, PDO::PARAM_STR);
$stmt->execute();
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? $row['value'] : null;
} catch (PDOException $e) {
return null;
}
}
// Fonction qui affiche la notification de la page d'accueil (si elle est activée)
function afficherNotice() {
$enabled = recupererSetting('site_notice_enabled');
$titre = recupererSetting('site_notice_title');
$texte = recupererSetting('site_notice_text');
// Valeurs par défaut tant que la table settings n'existe pas encore
if ($titre === null && $texte === null) {
$enabled = '1';
$titre = 'Des bugs peuvent encore se produire.';
$texte = "Si le CSS ou les images s'affichent mal, pensez à vider le cache de votre navigateur (Ctrl + F5 ou Cmd + Shift + R) pour voir les dernières modifications.";
}
// Si la notification est désactivée ou vide on n'affiche rien
if ($enabled !== '1' || (empty($titre) && empty($texte))) {
return;
}
echo '<p class="dev-cache-notice">';
if (!empty($titre)) {
echo '<strong>' . htmlspecialchars($titre) . '</strong> ';
}
echo nl2br(htmlspecialchars($texte));
echo '</p>';
}
+212
View File
@@ -0,0 +1,212 @@
<?php
// Ajout de connexion a la bdd
require_once('../functions/databaseConnection.php');
// Fonction qui va faire une requête SQL afin de collecter toutes les catégories de compétences
// Retourne un tableau vide si la table n'existe pas encore (pas de die() pour que la page
// Compétences continue de fonctionner tant que le script SQL n'a pas été exécuté)
function listeSkills() {
global $dbh;
try {
$stmt = $dbh->prepare('SELECT id, title, icon, items, position FROM skills ORDER BY position ASC, id ASC');
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
return [];
}
}
// Fonction qui permet de récupérer une catégorie de compétences par id (pour la modification dans le panel Admin)
function recupererSkillParId($id) {
global $dbh;
try {
$stmt = $dbh->prepare('SELECT id, title, icon, items, position FROM skills WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetch(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
return false;
}
}
// Icônes Devicon dont la couleur de marque "colored" est noire ou quasi-noire (vérifié dans
// devicon.min.css : linux #000, apple #000, bash #293138, ansible #1a1918) : invisibles sur
// la tuile sombre du bandeau. On les affiche dans la couleur claire par défaut à la place.
const DEVICON_COLORED_TROP_SOMBRE = ['devicon-linux-plain', 'devicon-apple-plain', 'devicon-bash-plain', 'devicon-ansible-plain'];
// Fonction interne qui affiche le visuel (icône ou image) d'un item de compétence.
// Format accepté avant le "|" : soit des classes d'icône (Font Awesome ou Devicon), soit
// "img:URL" pour utiliser une vraie image de logo (pour les outils absents des polices
// d'icônes, ex : GLPI, FOG, Proxmox, VirtualBox, Guacamole).
// $colorise active les vraies couleurs de marque des icônes Devicon (utilisé uniquement
// pour le bandeau de la home : sur la page Compétences les icônes restent en orange
// uniforme, cohérent avec le reste de la liste)
function afficherVisuelItem($itemIcone, $classe = '', $colorise = false) {
if (strpos($itemIcone, 'img:') === 0) {
$image = htmlspecialchars(substr($itemIcone, 4));
echo '<img class="' . $classe . '" src="' . $image . '" alt="" loading="lazy">';
return;
}
if ($colorise && strpos($itemIcone, 'devicon-') === 0 && strpos($itemIcone, 'colored') === false) {
$estTropSombre = false;
foreach (DEVICON_COLORED_TROP_SOMBRE as $slug) {
if (strpos($itemIcone, $slug) === 0) {
$estTropSombre = true;
break;
}
}
if (!$estTropSombre) {
$itemIcone .= ' colored';
}
}
echo '<i class="' . $classe . ' ' . htmlspecialchars($itemIcone) . '"></i>';
}
// Fonction interne qui affiche une carte de compétences (utilisée par la page publique et le panel admin)
// Les items sont stockés un par ligne, au format "classes-icone|Texte" (icône optionnelle)
function afficherSkillCard($skill, $admin = false) {
$id = (int) $skill['id'];
$titre = htmlspecialchars($skill['title']);
$icone = htmlspecialchars(!empty($skill['icon']) ? $skill['icon'] : 'fa-solid fa-star');
echo '<div class="skill-category">';
echo '<div class="skill-category-icon"><i class="' . $icone . '"></i></div>';
echo '<h2>' . $titre . '</h2>';
echo '<ul class="skill-list">';
// Un item par ligne
$items = array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $skill['items'])));
foreach ($items as $item) {
// Icône optionnelle avant le | sinon icône par défaut
$itemIcone = 'fa-solid fa-check';
$label = $item;
if (strpos($item, '|') !== false) {
list($itemIcone, $label) = array_map('trim', explode('|', $item, 2));
}
echo '<li>';
afficherVisuelItem($itemIcone, 'skill-item-visual');
echo ' ' . htmlspecialchars($label) . '</li>';
}
echo '</ul>';
// Boutons de modification / suppression pour le panel admin
if ($admin) {
echo '<div class="admin-actions">';
echo '<a class="btn-submit btn-edit" href="./admin.php?edit_skill=' . $id . '#admin-competences">Modifier</a>';
echo '<a class="btn-submit btn-delete" href="../scripts/removeSkill.php?id=' . $id . '&csrf_token=' . urlencode(csrfToken()) . '" onclick="return confirm(\'Voulez-vous vraiment supprimer cette catégorie de compétences ?\');">Supprimer</a>';
echo '</div>';
}
echo '</div>';
}
// Fonction qui va afficher les compétences sur la page publique
function afficherSkills() {
$skills = listeSkills();
// Si la table est vide ou n'existe pas encore
if (empty($skills)) {
echo '<p class="admin-notice">Les compétences seront bientôt disponibles.</p>';
return;
}
echo '<div class="skills-grid">';
foreach ($skills as $skill) {
afficherSkillCard($skill, false);
}
echo '</div>';
}
// Fonction identique à celle en haut mais ajout de boutons pour le panel admin
function afficherSkillsAdmin() {
$skills = listeSkills();
echo '<div class="skills-grid">';
foreach ($skills as $skill) {
afficherSkillCard($skill, true);
}
echo '</div>';
}
// Items exclus du bandeau de la home au cas par cas, en plus du filtre automatique
// (icône Devicon "réelle" mais jugée pas assez reconnaissable seule, sans le texte à côté)
const OUTILS_EXCLUS_DU_BANDEAU = ['Bash'];
// Fonction qui récupère tous les items de compétences représentant un vrai outil identifiable
// (icône Devicon ou image "img:"), toutes catégories confondues. Les items en texte seul
// SONT EXCLUS, ainsi que les icônes Font Awesome génériques (fa-solid/fa-brands) : dans un
// bandeau qui n'affiche pas le texte à côté (juste une info-bulle au survol), une icône
// générique comme un pictogramme "carte" pour "Adressage IP" ou "carnet de contact" pour
// "LDAP" ne représente aucun outil reconnaissable — ça reste illisible pour un visiteur qui
// ne survole pas. Ces items restent affichés normalement sur la page Compétences (avec leur
// texte à côté, donc sans ambiguïté), seul le bandeau de la home les filtre.
function listeIconesCompetences() {
$categories = listeSkills();
$resultats = [];
foreach ($categories as $categorie) {
$items = array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $categorie['items'])));
foreach ($items as $item) {
if (strpos($item, '|') === false) {
continue;
}
list($icone, $label) = array_map('trim', explode('|', $item, 2));
if ($icone === '' || $label === '') {
continue;
}
// Seuls les vrais logos (Devicon ou image) sont assez reconnaissables sans texte
$estUnVraiLogo = strpos($icone, 'devicon-') === 0 || strpos($icone, 'img:') === 0;
if (!$estUnVraiLogo) {
continue;
}
if (in_array($label, OUTILS_EXCLUS_DU_BANDEAU, true)) {
continue;
}
$resultats[] = ['icon' => $icone, 'label' => $label];
}
}
return $resultats;
}
// Fonction qui affiche le bandeau défilant des outils, directement à partir des icônes
// de la page Compétences (voir listeIconesCompetences). Réutilise les mêmes classes CSS
// que les bandeaux de logo_banners pour un rendu visuel identique (voir primary_css.css).
// "logo-marquee-tools" désactive le filtre grayscale/survol : contrairement aux logos
// d'entreprises, on veut que les vrais outils soient reconnaissables immédiatement.
function afficherOutilsCompetences() {
$outils = listeIconesCompetences();
if (empty($outils)) {
return;
}
echo '<div class="logo-marquee logo-marquee-tools">';
echo '<div class="logo-track">';
// On répète deux fois la liste : le défilement CSS boucle sur -50%, ce qui donne
// un effet de scroll infini fluide et sans coupure
for ($repetition = 0; $repetition < 2; $repetition++) {
foreach ($outils as $outil) {
$label = htmlspecialchars($outil['label']);
echo '<div class="logo-tile" title="' . $label . '">';
afficherVisuelItem($outil['icon'], '', true);
echo '</div>';
}
}
echo '</div>';
echo '</div>';
}
+27
View File
@@ -0,0 +1,27 @@
<?php
// Ajout de connexion a la bdd
require_once('../functions/databaseConnection.php');
// Fonction qui récupère les chiffres clés affichés sur la home (bandeau de stats)
// Chaque compteur retombe sur 0 en cas d'erreur (table pas encore créée...) plutôt
// que de bloquer la page d'accueil
function recupererStatsHome() {
global $dbh;
$stats = ['projets' => 0, 'experiences' => 0];
try {
$stats['projets'] = (int) $dbh->query('SELECT COUNT(*) FROM projects')->fetchColumn();
} catch (PDOException $e) {
// Table pas encore créée : on laisse le compteur à 0
}
try {
$stats['experiences'] = (int) $dbh->query('SELECT COUNT(*) FROM experiences')->fetchColumn();
} catch (PDOException $e) {
// Table pas encore créée : on laisse le compteur à 0
}
return $stats;
}
+35
View File
@@ -0,0 +1,35 @@
<?php
// Fonction qui va AFFICHER les utilisateurs présent dans la bdd
function afficherUsersAdmin()
{
require("../functions/databaseConnection.php");
// Requête pour recuperer tous les utilisateurs
$sql = "SELECT * FROM users ORDER BY id DESC";
$requete = $dbh->query($sql);
$users = $requete->fetchAll();
// Affichage
echo '<div class="projects-list">';
if ($users) {
foreach ($users as $user) {
echo '<div class="project-card">';
echo '<div class="project-content">';
echo '<h2>' . htmlspecialchars($user["username"]) . '</h2>';
echo '<p><strong>Rôle :</strong> ' . htmlspecialchars($user["role"]) . '</p>';
echo '<p><strong>Créé le :</strong> ' . htmlspecialchars($user["created_at"]) . '</p>';
echo '<div class="admin-actions">';
echo '<a href="../scripts/removeUser.php?id=' . $user["id"] . '&csrf_token=' . urlencode(csrfToken()) . '" class="btn-submit btn-delete" onclick="return confirm(\'Supprimer cet utilisateur ?\')">Supprimer</a>';
echo '</div>';
echo '</div>';
echo '</div>';
}
// Cas improbable mais on sait jamais (vu que il faut être admin et donc avoir un compte pour accéder à ici), si aucun utilisateur est trouvé, alors l'afficher
} else {
echo '<p style="padding: 0 40px 40px 40px; color: #aaaaaa;">Aucun utilisateur trouvé.</p>';
}
echo '</div>';
}
?>
+59 -6
View File
@@ -13,8 +13,17 @@ function listeXP() {
// Essayer
try {
// Requête SQL qui récupère titre, type, tags, description, durée, image_url
$stmt = $dbh->prepare('SELECT id, title, xptype, tags, description, duration, image_url FROM experiences ORDER BY id DESC');
// On liste les colonnes explicitement (pas de SELECT *) : le PDF (pdf_data) est
// stocké à part dans experience_pdfs (voir migration Juillet 2026 (6)), on ne
// récupère ici que son nom de fichier via LEFT JOIN, sans jamais charger son contenu
// binaire (jusqu'à 15 Mo par PDF) en mémoire pour une simple liste d'expériences
$stmt = $dbh->prepare('
SELECT e.id, e.title, e.status, e.xptype, e.tags, e.description, e.duration, e.image_url, e.markdown, e.link_url, e.created_at,
ep.pdf_filename
FROM experiences e
LEFT JOIN experience_pdfs ep ON ep.experience_id = e.id
ORDER BY e.id DESC
');
// Execution de la requête SQL
$stmt->execute();
// Retourne toutes les données collectés dans un ARRAY
@@ -30,8 +39,16 @@ function recupererXPParId($id) {
global $dbh;
try {
// Préparation de la requête préparée
$stmt = $dbh->prepare('SELECT id, title, xptype, tags, description, duration, image_url FROM experiences WHERE id = :id');
// Même principe que listeXP() : le PDF est joint pour son nom de fichier
// uniquement, jamais son contenu binaire
$stmt = $dbh->prepare('
SELECT e.id, e.title, e.status, e.xptype, e.tags, e.description, e.duration, e.image_url, e.markdown, e.link_url, e.created_at,
ep.pdf_filename
FROM experiences e
LEFT JOIN experience_pdfs ep ON ep.experience_id = e.id
WHERE e.id = :id
');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetch(PDO::FETCH_ASSOC);
@@ -57,11 +74,16 @@ function afficherXP() {
// Pour chaque experience dans le ARRAY
foreach ($experiences as $experience) {
// On récupère le titre, le type, la description, la durée et l'image et le tableau avec les tags
$id = (int) $experience['id'];
$titre = htmlspecialchars($experience['title']);
$xptype = htmlspecialchars($experience['xptype']);
$description = nl2br(htmlspecialchars($experience['description']));
$duration = htmlspecialchars($experience['duration']);
$image = htmlspecialchars($experience['image_url']);
$status = htmlspecialchars($experience['status'] ?? '');
$markdown = trim($experience['markdown'] ?? '');
$lien = trim($experience['link_url'] ?? '');
$pdf = trim($experience['pdf_filename'] ?? '');
$tags = [];
@@ -78,6 +100,11 @@ function afficherXP() {
if (!empty($image)) {
echo '<div class="project-banner">';
echo '<img src="' . $image . '" alt="Image de l experience ' . $titre . '">';
if (!empty($status)) {
echo '<div class="project-state">' . $status . '</div>';
}
echo '</div>';
}
@@ -85,6 +112,7 @@ function afficherXP() {
// Ajout de la class project-content et du titre
echo '<div class="project-content">';
echo '<h2>' . $titre . '</h2>';
echo '<p><strong>Type :</strong> ' . $xptype . '</p>';
echo '<p><strong>Durée :</strong> ' . $duration . '</p>';
@@ -101,6 +129,19 @@ function afficherXP() {
// Ajout de la description et fin des classes
echo '<p>' . $description . '</p>';
// Indicateur de page détaillée : markdown > PDF > lien externe > rien
// Le lien rend toute la carte cliquable (voir CSS .card-link)
if ($markdown !== '') {
echo '<a class="card-link" href="./details.php?type=experience&id=' . $id . '"><i class="fa-solid fa-file-lines"></i> Voir la page détaillée</a>';
} elseif ($pdf !== '') {
echo '<a class="card-link" href="./details.php?type=experience&id=' . $id . '"><i class="fa-solid fa-file-pdf"></i> Voir le PDF</a>';
} elseif ($lien !== '') {
echo '<a class="card-link" href="' . $lien . '" target="_blank" rel="noopener noreferrer"><i class="fa-solid fa-arrow-up-right-from-square"></i> Voir le lien externe</a>';
} else {
echo '<span class="card-link card-link-none"><i class="fa-solid fa-eye-slash"></i> Pas de page détaillée</span>';
}
echo '</div>';
@@ -123,6 +164,7 @@ function afficherXPAdmin() {
$xptype = htmlspecialchars($experience['xptype']);
$description = nl2br(htmlspecialchars($experience['description']));
$duration = htmlspecialchars($experience['duration']);
$status = htmlspecialchars($experience['status'] ?? '');
$image = htmlspecialchars($experience['image_url']);
$tags = [];
@@ -135,11 +177,22 @@ function afficherXPAdmin() {
if (!empty($image)) {
echo '<div class="project-banner">';
echo '<img src="' . $image . '" alt="Image de l experience ' . $titre . '">';
if (!empty($status)) {
echo '<div class="project-state">' . $status . '</div>';
}
echo '</div>';
}
echo '<div class="project-content">';
echo '<h2>' . $titre . '</h2>';
// Si le status n'est pas vide alors on l'affiche
if (!empty($status)) {
echo '<div class="project-state">' . $status . '</div>';
}
echo '<p><strong>Type :</strong> ' . $xptype . '</p>';
echo '<p><strong>Durée :</strong> ' . $duration . '</p>';
@@ -155,8 +208,8 @@ function afficherXPAdmin() {
// Boutons qui vont permettrent la modification / Suppresion
echo '<div class="admin-actions">';
echo '<a class="btn-submit btn-edit" href="./admin.php?edit_xp=' . $id . '">Modifier</a>';
echo '<a class="btn-submit btn-delete" href="../scripts/removeXP.php?id=' . $id . '" onclick="return confirm(\'Voulez-vous vraiment supprimer cette expérience ?\');">Supprimer</a>';
echo '<a class="btn-submit btn-edit" href="./admin.php?edit_xp=' . $id . '#admin-experiences">Modifier</a>';
echo '<a class="btn-submit btn-delete" href="../scripts/removeXP.php?id=' . $id . '&csrf_token=' . urlencode(csrfToken()) . '" onclick="return confirm(\'Voulez-vous vraiment supprimer cette expérience ?\');">Supprimer</a>';
echo '</div>';
echo '</div>';
+68
View File
@@ -0,0 +1,68 @@
<?php
// Fonction utilitaire de validation/lecture d'un PDF envoyé par formulaire (drag & drop ou clic)
// Utilisée par les scripts d'ajout/modification de projets, expériences et écoles
// Taille max acceptée par l'application : à ajuster avec upload_max_filesize / post_max_size
// (php.ini) et max_allowed_packet (MySQL) si besoin d'accepter des PDF plus lourds
define('PDF_MAX_SIZE', 15 * 1024 * 1024); // 15 Mo
// Détecte le cas où le fichier envoyé dépasse post_max_size (php.ini)
// Dans ce cas PHP vide $_POST et $_FILES AVANT même d'exécuter le script : sans cette
// détection, le formulaire échoue silencieusement (aucun champ, aucune erreur visible)
function postDepasseLimite() {
$contentLength = isset($_SERVER['CONTENT_LENGTH']) ? (int) $_SERVER['CONTENT_LENGTH'] : 0;
return $_SERVER['REQUEST_METHOD'] === 'POST' && $contentLength > 0 && empty($_POST) && empty($_FILES);
}
// Message d'erreur explicite à afficher quand postDepasseLimite() est vrai
function messageDepassementPost() {
return "Le fichier envoyé est trop volumineux pour le serveur (limite actuelle : "
. ini_get('post_max_size') . "). Réduis la taille du PDF, ou augmente "
. "post_max_size et upload_max_filesize côté serveur.";
}
// Vérifie et lit un fichier PDF envoyé en upload ($_FILES)
// Retourne :
// - null si aucun fichier n'a été envoyé (ce n'est pas une erreur)
// - une chaîne de texte si le fichier est invalide (message d'erreur à afficher)
// - ['filename' => ..., 'data' => ...] si le PDF est valide
function lireUploadPdf($champ = 'pdf') {
if (!isset($_FILES[$champ]) || $_FILES[$champ]['error'] === UPLOAD_ERR_NO_FILE) {
return null;
}
if ($_FILES[$champ]['error'] !== UPLOAD_ERR_OK) {
return "Erreur lors de l'envoi du PDF (code " . $_FILES[$champ]['error'] . ").";
}
if ($_FILES[$champ]['size'] > PDF_MAX_SIZE) {
return "Le PDF dépasse la taille maximale autorisée (15 Mo).";
}
// Vérification de l'extension
$extension = strtolower(pathinfo($_FILES[$champ]['name'], PATHINFO_EXTENSION));
if ($extension !== 'pdf') {
return "Le fichier doit être un PDF (extension .pdf).";
}
// Vérification du type réel du fichier (pas seulement l'extension, pour éviter
// qu'un fichier malveillant renommé en .pdf soit accepté)
// (pas de finfo_close : déprécié depuis PHP 8.5, la ressource se libère toute seule)
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $_FILES[$champ]['tmp_name']);
if ($mime !== 'application/pdf') {
return "Le fichier envoyé n'est pas un PDF valide.";
}
$contenu = file_get_contents($_FILES[$champ]['tmp_name']);
if ($contenu === false) {
return "Impossible de lire le fichier envoyé.";
}
return [
'filename' => basename($_FILES[$champ]['name']),
'data' => $contenu
];
}
+48
View File
@@ -0,0 +1,48 @@
<?php
// Sécurité transverse : cookie de session durci, en-têtes anti-clickjacking et
// protection CSRF. A inclure à la place d'un simple session_start() sur toute
// page ou script qui utilise une session (remplace l'appel direct partout).
// Règle les paramètres du cookie de session AVANT de démarrer la session (ils
// n'ont aucun effet une fois la session déjà démarrée) :
// - httponly : empêche le JavaScript de lire le cookie (limite le vol de session via une XSS)
// - samesite=Lax : bloque une partie des requêtes intersites (CSRF)
// - secure : n'est activé que si la connexion est déjà en HTTPS (sinon le cookie ne partirait jamais en HTTP)
function configureSecureSessionCookies() {
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'httponly' => true,
'samesite' => 'Lax',
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
]);
}
// Anti-clickjacking : interdit d'afficher le site dans une <iframe> d'un autre site
header('X-Frame-Options: SAMEORIGIN');
header('X-Content-Type-Options: nosniff');
if (session_status() === PHP_SESSION_NONE) {
configureSecureSessionCookies();
session_start();
}
// Génère (si besoin) et retourne le jeton CSRF de la session en cours, à
// placer dans un champ caché de chaque formulaire admin ou dans les liens
// GET qui déclenchent une action (suppression, export...)
function csrfToken() {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
// Vérifie le jeton CSRF reçu (POST ou GET) ; arrête le script si invalide.
// A appeler juste après la vérification du rôle admin dans chaque script d'action.
function requireCsrfToken() {
$recu = $_POST['csrf_token'] ?? $_GET['csrf_token'] ?? '';
if (empty($_SESSION['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $recu)) {
http_response_code(403);
die("Requête refusée : jeton de sécurité invalide ou expiré. Recharge la page et réessaie.");
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
// On cache les webhooks discord => Correction de faille de sécurité
// __DIR__ = chemin absolu du dossier functions : le require fonctionne ainsi quel que soit
// le dossier depuis lequel le script est appelé (pages/, scripts/, racine...)
require_once(__DIR__ . '/../../idDiscord.php');
// EXEMPLE de a quoi ressemble idDiscord :
// define('ID_DISCORD1', 'https://discord.com/api/webhooks/URL_WEBHOOK1');
// define('ID_DISCORD2', 'https://discord.com/api/webhooks/URL_WEBHOOK2');
// Fonction qui va ENVOYER des logs sur le webhook discord
function sendLog($action, $user = "") {
// Si le webhook n'est pas configuré on ne bloque pas le site
if (!defined('ID_DISCORD1') || ID_DISCORD1 == '') {
return false;
}
$webhook = ID_DISCORD1; // URL du webhook Discord
// Si l'utilisateur n'est pas précisé on prend celui de la session (connecté)
if ($user == "" && isset($_SESSION['username'])) {
$user = $_SESSION['username'];
}
$contenuLog = "**Nouvelle log du portfolio**\n\n";
$contenuLog .= "**Action :** " . $action . "\n";
if ($user != "") {
$contenuLog .= "**Utilisateur :** " . $user . "\n";
} else {
$contenuLog .= "**Utilisateur :** non défini" . "\n";
}
$contenuLog .= "**IP :** " . ($_SERVER['REMOTE_ADDR'] ?? 'inconnue') . "\n";
$contenuLog .= "**Date :** " . date('d/m/Y H:i:s');
$data = [
"content" => $contenuLog
];
$dataJson = json_encode($data);
$ch = curl_init($webhook); // On démarre une ressource curl
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $dataJson);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
// Timeouts courts : si Discord est lent ou down, le site ne doit pas être ralenti
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT_MS, 800);
curl_setopt($ch, CURLOPT_TIMEOUT_MS, 1500);
// CORRECTION DU BUG : curl_exec() n'était jamais appelé, la requête n'était
// donc jamais envoyée à Discord. C'est pour ça que les logs n'ont jamais marché.
// (pas de curl_close : déprécié depuis PHP 8.5, la ressource se libère toute seule)
curl_exec($ch);
$codeHttp = curl_getinfo($ch, CURLINFO_HTTP_CODE);
// Discord renvoie 204 quand le message est bien envoyé
return $codeHttp == 204;
}
// Log de visite de page : appelée par le header sur toutes les pages
// Anti-spam : Discord limite à ~30 messages/minute par webhook, donc une même page
// n'est loguée qu'une fois par minute et par visiteur (stocké dans sa session)
function sendPageLog() {
$page = basename($_SERVER['PHP_SELF'] ?? 'inconnue');
if (!isset($_SESSION['pages_log'])) {
$_SESSION['pages_log'] = [];
}
$maintenant = time();
if (isset($_SESSION['pages_log'][$page]) && ($maintenant - $_SESSION['pages_log'][$page]) < 60) {
return; // Page déjà loguée il y a moins d'une minute pour ce visiteur
}
$_SESSION['pages_log'][$page] = $maintenant;
sendLog("Visite de la page " . $page);
}
+5 -3
View File
@@ -1,12 +1,14 @@
<?php
// Ce projet est entièrement développé par Esteban MOUSSU
// 2026 - Projet Hébergé par l'entreprise TerraTech Hebergement en collaboration avec l'association Eldoria Network
// Le CSS est **probablement** généré par intelligence artificielle ou récupéré d'autre projet, cependant le code PHP / HTML / JS est globalement fait par un humain
// Le CSS est généré par intelligence artificielle ou récupéré d'autre projet.
// Note 1 : Pour lancer un serveur local PHP Depuis VsCode : 'php -v' pour vérifier que php est bien installé et 'php -S localhost:8000' pour lancer le serveur
// Note 2 : Je m'aide désormais de l'autocompletion de vsCode pour accélerer le développement avec Copilot (GitHub) ou Devin mais le reste du code est entièrement développé par moi même mais copilot
// reste utile pour la génération de commentaires, de code répétitif ou de code basique (exemple : les formulaires de contact, les requêtes SQL basiques etc...)
// Note : Pour lancer un serveur local PHP Depuis VsCode : 'php -v' pour vérifier que php est bien installé et 'php -S localhost:8000' pour lancer le serveur
// Le header n'y étant pas je rajoute ici un démarrage de session
session_start();
require_once(__DIR__ . '/functions/security.php');
// Redirection de l'utilisateur vers une page Home lorsqu'il arrive sur index.php
header('location: ./pages/home.php');
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+519 -28
View File
@@ -1,12 +1,19 @@
<?php
// Titre de l'onglet
$pageTitle = 'Administration';
// On ajoute ici le header
require_once('./../elements/header.php');
// Ajout de la base de donnée
require_once '../functions/databaseConnection.php';
require_once("../functions/listUser.php");
require_once('../functions/listProject.php');
require_once('../functions/listXP.php');
require_once('../functions/listSchool.php');
require_once('../functions/listSkills.php');
require_once('../functions/listSettings.php');
require_once('../functions/listLogos.php');
// L'utilisateur doit être administrateur
@@ -18,31 +25,81 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
// On met les variables à null ici pour éviter qu'il garde les options en premier chargement
$projetAModifier = null;
$xpAModifier = null;
$ecoleAModifier = null;
$skillAModifier = null;
$logoAModifier = null;
// Si on modifie XP ou Projets
// Si on modifie XP, Projets, École ou Compétence
if (isset($_GET['edit_project']) && is_numeric($_GET['edit_project'])) {
$projetAModifier = recupererProjetParId((int) $_GET['edit_project']);
}
if (isset($_GET['edit_xp']) && is_numeric($_GET['edit_xp'])) {
$xpAModifier = recupererXPParId((int) $_GET['edit_xp']);
}
if (isset($_GET['edit_school']) && is_numeric($_GET['edit_school'])) {
$ecoleAModifier = recupererEcoleParId((int) $_GET['edit_school']);
}
if (isset($_GET['edit_skill']) && is_numeric($_GET['edit_skill'])) {
$skillAModifier = recupererSkillParId((int) $_GET['edit_skill']);
}
if (isset($_GET['edit_logo']) && is_numeric($_GET['edit_logo'])) {
$logoAModifier = recupererLogoParId((int) $_GET['edit_logo']);
}
// Valeurs actuelles de la notification de la page d'accueil
$noticeTitle = recupererSetting('site_notice_title') ?? '';
$noticeText = recupererSetting('site_notice_text') ?? '';
$noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
// Valeur actuelle du captcha anti-robot (activé par défaut)
$captchaEnabled = recupererSetting('captcha_enabled') ?? '1';
// Valeurs actuelles des coordonnées de la page contact
$contactEmail = recupererSetting('contact_email') ?? 'esteban.moussu@ik.me';
$contactEmailEnabled = recupererSetting('contact_email_enabled') ?? '1';
$contactAddressLine1 = recupererSetting('contact_address_line1') ?? '20 Grande rue';
$contactAddressLine2 = recupererSetting('contact_address_line2') ?? '52500 Broncourt';
$contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
// Page du panneau d'administration
?>
<div class="admin-forms">
<div class="admin-page">
<!-- Formulaire de création de projet -->
<div class="project-container">
<div class="project-form-card">
<div class="project-header">
<h2><?php echo $projetAModifier ? 'Modifier le projet' : 'Nouveau projet'; ?></h2>
<!-- Titre du panneau -->
<div class="page-header">
<h1>Panneau d'administration</h1>
<p>Gère le contenu du site : projets, expériences, écoles, compétences, logos, notification et utilisateurs.</p>
</div>
<!-- Sous-navigation : accès rapide à chaque catégorie -->
<nav class="admin-nav">
<a href="#admin-projets"><i class="fa-solid fa-folder-open"></i> Projets</a>
<a href="#admin-experiences"><i class="fa-solid fa-briefcase"></i> Expériences</a>
<a href="#admin-ecoles"><i class="fa-solid fa-graduation-cap"></i> Écoles</a>
<a href="#admin-competences"><i class="fa-solid fa-code"></i> Compétences</a>
<a href="#admin-logos"><i class="fa-solid fa-images"></i> Logos</a>
<a href="#admin-contact"><i class="fa-solid fa-address-card"></i> Contact</a>
<a href="#admin-notification"><i class="fa-solid fa-bell"></i> Notification</a>
<a href="#admin-securite"><i class="fa-solid fa-shield-halved"></i> Sécurité</a>
<a href="#admin-utilisateurs"><i class="fa-solid fa-users"></i> Utilisateurs</a>
<a href="#admin-donnees"><i class="fa-solid fa-database"></i> Données</a>
</nav>
<!-- ========== SECTION PROJETS ========== -->
<section id="admin-projets" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-folder-open"></i> Projets</h2>
<!-- Formulaire (replié par défaut, ouvert automatiquement en mode modification) -->
<details class="admin-block" <?php echo $projetAModifier ? 'open' : ''; ?>>
<summary><?php echo $projetAModifier ? 'Modifier le projet : ' . htmlspecialchars($projetAModifier['title']) : '+ Nouveau projet'; ?></summary>
<div class="project-form-card">
<!-- enctype multipart/form-data : nécessaire pour l'upload du PDF -->
<!-- en envoie les données à addProject.php -->
<form method="POST" action="<?php echo $projetAModifier ? '../scripts/updateProject.php' : '../scripts/addProject.php'; ?>">
<form method="POST" enctype="multipart/form-data" action="<?php echo $projetAModifier ? '../scripts/updateProject.php' : '../scripts/addProject.php'; ?>">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<?php if ($projetAModifier): ?>
<input type="hidden" name="id" value="<?php echo (int) $projetAModifier['id']; ?>">
@@ -51,6 +108,9 @@ if (isset($_GET['edit_xp']) && is_numeric($_GET['edit_xp'])) {
<label>Titre *</label>
<input type="text" name="title" required value="<?php echo $projetAModifier ? htmlspecialchars($projetAModifier['title']) : ''; ?>">
<label>Status (optionnel, affiché sur l'image : En cours, Terminé...)</label>
<input type="text" name="status" placeholder="En cours..." value="<?php echo $projetAModifier ? htmlspecialchars($projetAModifier['status'] ?? '') : ''; ?>">
<label>Tags (virgules)</label>
<input type="text" name="tags" value="<?php echo $projetAModifier ? htmlspecialchars($projetAModifier['tags']) : ''; ?>">
@@ -60,22 +120,51 @@ if (isset($_GET['edit_xp']) && is_numeric($_GET['edit_xp'])) {
<label>Lien de l'image *</label>
<input type="url" name="imageurl" placeholder="https://..." required value="<?php echo $projetAModifier ? htmlspecialchars($projetAModifier['image_url']) : ''; ?>">
<label>Page détaillée en markdown (optionnel, prioritaire sur le PDF et le lien externe)</label>
<textarea name="markdown" class="markdown-textarea" placeholder="# Mon projet&#10;&#10;Description complète en **markdown**..."><?php echo $projetAModifier ? htmlspecialchars($projetAModifier['markdown'] ?? '') : ''; ?></textarea>
<label>Document PDF (optionnel, utilisé si pas de markdown)</label>
<?php if ($projetAModifier && !empty($projetAModifier['pdf_filename'])): ?>
<div class="pdf-current">
<i class="fa-solid fa-file-pdf"></i>
<span><?php echo htmlspecialchars($projetAModifier['pdf_filename']); ?></span>
<a href="../scripts/viewPdf.php?type=projet&id=<?php echo (int) $projetAModifier['id']; ?>" target="_blank" rel="noopener noreferrer">Voir</a>
<label class="checkbox-row pdf-remove-row">
<input type="checkbox" name="remove_pdf"> Supprimer ce PDF
</label>
</div>
<?php endif; ?>
<div class="pdf-dropzone" data-input="pdf-project">
<i class="fa-solid fa-cloud-arrow-up"></i>
<p>Glisse un PDF ici, ou <span class="pdf-dropzone-browse">clique pour choisir un fichier</span></p>
<span class="pdf-dropzone-filename"></span>
<input type="file" name="pdf" id="pdf-project" accept="application/pdf,.pdf" hidden>
</div>
<label>Lien externe (optionnel, utilisé si pas de markdown ni de PDF)</label>
<input type="url" name="linkurl" placeholder="https://..." value="<?php echo $projetAModifier ? htmlspecialchars($projetAModifier['link_url'] ?? '') : ''; ?>">
<input type="submit" name="submitproject" value="<?php echo $projetAModifier ? 'Modifier le projet' : '+ Ajouter le projet'; ?>" class="btn-submit">
</form>
</div>
</div>
</details>
<!-- Liste des projets avec modification / suppression -->
<?php afficherProjetsAdmin(); ?>
</section>
<!-- ========== SECTION EXPÉRIENCES ========== -->
<section id="admin-experiences" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-briefcase"></i> Expériences</h2>
<details class="admin-block" <?php echo $xpAModifier ? 'open' : ''; ?>>
<summary><?php echo $xpAModifier ? 'Modifier l\'expérience : ' . htmlspecialchars($xpAModifier['title']) : '+ Nouvelle expérience'; ?></summary>
<!-- Formulaire de création d'expérience -->
<div class="project-container">
<div class="project-form-card">
<div class="project-header">
<h2><?php echo $xpAModifier ? 'Modifier l\'experience' : 'Nouvelle expérience'; ?></h2>
</div>
<!-- en envoie les données à addXP.php -->
<form method="POST" action="<?php echo $xpAModifier ? '../scripts/updateXP.php' : '../scripts/addXP.php'; ?>">
<form method="POST" enctype="multipart/form-data" action="<?php echo $xpAModifier ? '../scripts/updateXP.php' : '../scripts/addXP.php'; ?>">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<?php if ($xpAModifier): ?>
<input type="hidden" name="id" value="<?php echo (int) $xpAModifier['id']; ?>">
@@ -84,8 +173,11 @@ if (isset($_GET['edit_xp']) && is_numeric($_GET['edit_xp'])) {
<label>Titre *</label>
<input type="text" name="title" required value="<?php echo $xpAModifier ? htmlspecialchars($xpAModifier['title']) : ''; ?>">
<label>Status (optionnel, affiché sur l'image : En cours, Terminé...)</label>
<input type="text" name="status" placeholder="En cours..." value="<?php echo $xpAModifier ? htmlspecialchars($xpAModifier['status'] ?? '') : ''; ?>">
<label>Type *</label>
<textarea name="xptype" required><?php echo $xpAModifier ? htmlspecialchars($xpAModifier['xptype']) : ''; ?></textarea>
<input type="text" name="xptype" required value="<?php echo $xpAModifier ? htmlspecialchars($xpAModifier['xptype']) : ''; ?>">
<label>Tags (virgules)</label>
<input type="text" name="tags" value="<?php echo $xpAModifier ? htmlspecialchars($xpAModifier['tags']) : ''; ?>">
@@ -94,25 +186,424 @@ if (isset($_GET['edit_xp']) && is_numeric($_GET['edit_xp'])) {
<textarea name="description" required><?php echo $xpAModifier ? htmlspecialchars($xpAModifier['description']) : ''; ?></textarea>
<label>Durée *</label>
<textarea name="duration" required><?php echo $xpAModifier ? htmlspecialchars($xpAModifier['duration']) : ''; ?></textarea>
<input type="text" name="duration" required value="<?php echo $xpAModifier ? htmlspecialchars($xpAModifier['duration']) : ''; ?>">
<label>Lien de l'image *</label>
<input type="url" name="imageurl" placeholder="https://..." required value="<?php echo $xpAModifier ? htmlspecialchars($xpAModifier['image_url']) : ''; ?>">
<label>Page détaillée en markdown (optionnel, prioritaire sur le PDF et le lien externe)</label>
<textarea name="markdown" class="markdown-textarea" placeholder="# Mon expérience&#10;&#10;Description complète en **markdown**..."><?php echo $xpAModifier ? htmlspecialchars($xpAModifier['markdown'] ?? '') : ''; ?></textarea>
<label>Document PDF (optionnel, utilisé si pas de markdown)</label>
<?php if ($xpAModifier && !empty($xpAModifier['pdf_filename'])): ?>
<div class="pdf-current">
<i class="fa-solid fa-file-pdf"></i>
<span><?php echo htmlspecialchars($xpAModifier['pdf_filename']); ?></span>
<a href="../scripts/viewPdf.php?type=experience&id=<?php echo (int) $xpAModifier['id']; ?>" target="_blank" rel="noopener noreferrer">Voir</a>
<label class="checkbox-row pdf-remove-row">
<input type="checkbox" name="remove_pdf"> Supprimer ce PDF
</label>
</div>
<?php endif; ?>
<div class="pdf-dropzone" data-input="pdf-xp">
<i class="fa-solid fa-cloud-arrow-up"></i>
<p>Glisse un PDF ici, ou <span class="pdf-dropzone-browse">clique pour choisir un fichier</span></p>
<span class="pdf-dropzone-filename"></span>
<input type="file" name="pdf" id="pdf-xp" accept="application/pdf,.pdf" hidden>
</div>
<label>Lien externe (optionnel, utilisé si pas de markdown ni de PDF)</label>
<input type="url" name="linkurl" placeholder="https://..." value="<?php echo $xpAModifier ? htmlspecialchars($xpAModifier['link_url'] ?? '') : ''; ?>">
<input type="submit" name="submitxp" value="<?php echo $xpAModifier ? 'Modifier l\'experience' : '+ Ajouter l\'experience'; ?>" class="btn-submit">
</form>
</div>
</details>
<!-- Liste des expériences avec modification / suppression -->
<?php afficherXPAdmin(); ?>
</section>
<!-- ========== SECTION ÉCOLES ========== -->
<section id="admin-ecoles" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-graduation-cap"></i> Écoles</h2>
<details class="admin-block" <?php echo $ecoleAModifier ? 'open' : ''; ?>>
<summary><?php echo $ecoleAModifier ? 'Modifier l\'école : ' . htmlspecialchars($ecoleAModifier['title']) : '+ Nouvelle école'; ?></summary>
<div class="project-form-card">
<!-- on envoie les données à addSchool.php -->
<form method="POST" enctype="multipart/form-data" action="<?php echo $ecoleAModifier ? '../scripts/updateSchool.php' : '../scripts/addSchool.php'; ?>">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<?php if ($ecoleAModifier): ?>
<input type="hidden" name="id" value="<?php echo (int) $ecoleAModifier['id']; ?>">
<?php endif; ?>
<label>Nom de l'école *</label>
<input type="text" name="title" required value="<?php echo $ecoleAModifier ? htmlspecialchars($ecoleAModifier['title']) : ''; ?>">
<label>Status (optionnel, affiché sur l'image : En cours, Diplômé...)</label>
<input type="text" name="status" placeholder="En cours..." value="<?php echo $ecoleAModifier ? htmlspecialchars($ecoleAModifier['status'] ?? '') : ''; ?>">
<label>Tags (virgules)</label>
<input type="text" name="tags" value="<?php echo $ecoleAModifier ? htmlspecialchars($ecoleAModifier['tags']) : ''; ?>">
<label>Description (diplôme, spécialité...) *</label>
<textarea name="description" required><?php echo $ecoleAModifier ? htmlspecialchars($ecoleAModifier['description']) : ''; ?></textarea>
<label>Période (ex : 2023 - 2025) *</label>
<input type="text" name="duration" required value="<?php echo $ecoleAModifier ? htmlspecialchars($ecoleAModifier['duration']) : ''; ?>">
<label>Lien de l'image *</label>
<input type="url" name="imageurl" placeholder="https://..." required value="<?php echo $ecoleAModifier ? htmlspecialchars($ecoleAModifier['image_url']) : ''; ?>">
<label>Page détaillée en markdown (optionnel, prioritaire sur le PDF et le lien externe)</label>
<textarea name="markdown" class="markdown-textarea" placeholder="# Mon école&#10;&#10;Description complète en **markdown**..."><?php echo $ecoleAModifier ? htmlspecialchars($ecoleAModifier['markdown'] ?? '') : ''; ?></textarea>
<label>Document PDF (optionnel, utilisé si pas de markdown)</label>
<?php if ($ecoleAModifier && !empty($ecoleAModifier['pdf_filename'])): ?>
<div class="pdf-current">
<i class="fa-solid fa-file-pdf"></i>
<span><?php echo htmlspecialchars($ecoleAModifier['pdf_filename']); ?></span>
<a href="../scripts/viewPdf.php?type=ecole&id=<?php echo (int) $ecoleAModifier['id']; ?>" target="_blank" rel="noopener noreferrer">Voir</a>
<label class="checkbox-row pdf-remove-row">
<input type="checkbox" name="remove_pdf"> Supprimer ce PDF
</label>
</div>
<?php endif; ?>
<div class="pdf-dropzone" data-input="pdf-school">
<i class="fa-solid fa-cloud-arrow-up"></i>
<p>Glisse un PDF ici, ou <span class="pdf-dropzone-browse">clique pour choisir un fichier</span></p>
<span class="pdf-dropzone-filename"></span>
<input type="file" name="pdf" id="pdf-school" accept="application/pdf,.pdf" hidden>
</div>
<label>Lien externe (optionnel, utilisé si pas de markdown ni de PDF)</label>
<input type="url" name="linkurl" placeholder="https://..." value="<?php echo $ecoleAModifier ? htmlspecialchars($ecoleAModifier['link_url'] ?? '') : ''; ?>">
<input type="submit" name="submitschool" value="<?php echo $ecoleAModifier ? 'Modifier l\'école' : '+ Ajouter l\'école'; ?>" class="btn-submit">
</form>
</div>
</details>
<!-- Liste des écoles avec modification / suppression -->
<?php afficherEcolesAdmin(); ?>
</section>
<!-- ========== SECTION COMPÉTENCES ========== -->
<section id="admin-competences" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-code"></i> Compétences</h2>
<details class="admin-block" <?php echo $skillAModifier ? 'open' : ''; ?>>
<summary><?php echo $skillAModifier ? 'Modifier la catégorie : ' . htmlspecialchars($skillAModifier['title']) : '+ Nouvelle catégorie de compétences'; ?></summary>
<div class="project-form-card">
<!-- on envoie les données à addSkill.php -->
<form method="POST" action="<?php echo $skillAModifier ? '../scripts/updateSkill.php' : '../scripts/addSkill.php'; ?>">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<?php if ($skillAModifier): ?>
<input type="hidden" name="id" value="<?php echo (int) $skillAModifier['id']; ?>">
<?php endif; ?>
<label>Titre de la catégorie *</label>
<input type="text" name="title" required value="<?php echo $skillAModifier ? htmlspecialchars($skillAModifier['title']) : ''; ?>">
<label>Icône de la catégorie (classes Font Awesome, ex : fa-solid fa-terminal)</label>
<input type="text" name="icon" placeholder="fa-solid fa-star" value="<?php echo $skillAModifier ? htmlspecialchars($skillAModifier['icon']) : ''; ?>">
<label>Compétences (une par ligne, format : "icone|texte" ou juste "texte") *</label>
<textarea name="items" placeholder="fa-brands fa-python|Python&#10;fa-solid fa-terminal|Bash&#10;Ansible" required><?php echo $skillAModifier ? htmlspecialchars($skillAModifier['items']) : ''; ?></textarea>
<label>Position (ordre d'affichage, du plus petit au plus grand)</label>
<input type="text" name="position" value="<?php echo $skillAModifier ? (int) $skillAModifier['position'] : '0'; ?>">
<input type="submit" name="submitskill" value="<?php echo $skillAModifier ? 'Modifier la compétence' : '+ Ajouter la compétence'; ?>" class="btn-submit">
</form>
</div>
</details>
<!-- Liste des compétences avec modification / suppression -->
<div class="skills-section skills-section-admin">
<?php afficherSkillsAdmin(); ?>
</div>
</section>
<!-- ========== SECTION LOGOS (bandeaux page d'accueil) ========== -->
<section id="admin-logos" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-images"></i> Logos partenaires (page d'accueil)</h2>
<p class="admin-data-text admin-section-intro">
Le bandeau "Outils que je sais manipuler" est désormais dérivé automatiquement de la
page <a href="#admin-competences">Compétences</a> (icône dans le champ "icone|texte" de chaque item).
Cette section ne gère plus que le bandeau "Ils m'ont fait confiance".
</p>
<details class="admin-block" <?php echo $logoAModifier ? 'open' : ''; ?>>
<summary><?php echo $logoAModifier ? 'Modifier le logo : ' . htmlspecialchars($logoAModifier['name']) : '+ Nouvelle entreprise'; ?></summary>
<div class="project-form-card">
<!-- on envoie les données à addLogo.php -->
<form method="POST" action="<?php echo $logoAModifier ? '../scripts/updateLogo.php' : '../scripts/addLogo.php'; ?>">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<?php if ($logoAModifier): ?>
<input type="hidden" name="id" value="<?php echo (int) $logoAModifier['id']; ?>">
<?php endif; ?>
<!-- Seul le bandeau "Ils m'ont fait confiance" est géré ici -->
<input type="hidden" name="category" value="partner">
<label>Nom de l'entreprise *</label>
<input type="text" name="name" required placeholder="Nom de l'entreprise..." value="<?php echo $logoAModifier ? htmlspecialchars($logoAModifier['name']) : ''; ?>">
<label>
Icône Devicon (facultatif, rarement utile pour une entreprise — parcourir sur
<a href="https://devicon.dev" target="_blank" rel="noopener noreferrer">devicon.dev</a>)
</label>
<input type="text" name="icon" placeholder="devicon-docker-plain colored" value="<?php echo $logoAModifier ? htmlspecialchars($logoAModifier['icon'] ?? '') : ''; ?>">
<label>Lien de l'image (utilisé si pas d'icône)</label>
<input type="url" name="logourl" placeholder="https://..." value="<?php echo $logoAModifier ? htmlspecialchars($logoAModifier['logo_url'] ?? '') : ''; ?>">
<label>Lien externe (optionnel, rend le logo cliquable)</label>
<input type="url" name="linkurl" placeholder="https://..." value="<?php echo $logoAModifier ? htmlspecialchars($logoAModifier['link_url'] ?? '') : ''; ?>">
<label>Position (ordre d'affichage, du plus petit au plus grand)</label>
<input type="text" name="position" value="<?php echo $logoAModifier ? (int) $logoAModifier['position'] : '0'; ?>">
<input type="submit" name="submitlogo" value="<?php echo $logoAModifier ? 'Modifier le logo' : '+ Ajouter le logo'; ?>" class="btn-submit">
</form>
</div>
</details>
<!-- Liste des logos avec modification / suppression -->
<?php afficherLogosAdmin(); ?>
</section>
<!-- ========== SECTION CONTACT ========== -->
<section id="admin-contact" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-address-card"></i> Coordonnées de contact</h2>
<div class="project-container">
<div class="project-form-card">
<!-- on envoie les données à updateSettings.php -->
<form method="POST" action="../scripts/updateSettings.php">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<input type="hidden" name="settings_group" value="contact">
<label>Adresse email</label>
<input type="email" name="contact_email" value="<?php echo htmlspecialchars($contactEmail); ?>">
<!-- Checkbox pour afficher / masquer l'email sur la page contact -->
<label class="checkbox-row">
<input type="checkbox" name="contact_email_enabled" value="1" <?php echo $contactEmailEnabled === '1' ? 'checked' : ''; ?>>
Afficher l'email sur la page contact
</label>
<label>Adresse postale - ligne 1 (rue)</label>
<input type="text" name="contact_address_line1" value="<?php echo htmlspecialchars($contactAddressLine1); ?>">
<label>Adresse postale - ligne 2 (code postal / ville)</label>
<input type="text" name="contact_address_line2" value="<?php echo htmlspecialchars($contactAddressLine2); ?>">
<!-- Checkbox pour afficher / masquer l'adresse postale sur la page contact -->
<label class="checkbox-row">
<input type="checkbox" name="contact_address_enabled" value="1" <?php echo $contactAddressEnabled === '1' ? 'checked' : ''; ?>>
Afficher l'adresse postale sur la page contact
</label>
<input type="submit" name="submitsettings" value="Enregistrer les coordonnées" class="btn-submit">
</form>
</div>
</div>
</section>
<!-- ========== SECTION NOTIFICATION ========== -->
<section id="admin-notification" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-bell"></i> Notification de la page d'accueil</h2>
<div class="project-container">
<div class="project-form-card">
<!-- on envoie les données à updateSettings.php -->
<form method="POST" action="../scripts/updateSettings.php">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<input type="hidden" name="settings_group" value="notice">
<label>Titre (affiché en gras)</label>
<input type="text" name="notice_title" value="<?php echo htmlspecialchars($noticeTitle); ?>">
<label>Texte</label>
<textarea name="notice_text"><?php echo htmlspecialchars($noticeText); ?></textarea>
<!-- Checkbox pour activer / désactiver la notification sans la supprimer -->
<label class="checkbox-row">
<input type="checkbox" name="notice_enabled" value="1" <?php echo $noticeEnabled === '1' ? 'checked' : ''; ?>>
Afficher la notification sur la page d'accueil
</label>
<input type="submit" name="submitsettings" value="Enregistrer la notification" class="btn-submit">
</form>
</div>
</div>
</section>
<!-- ========== SECTION SÉCURITÉ ========== -->
<section id="admin-securite" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-shield-halved"></i> Sécurité</h2>
<div class="project-container">
<div class="project-form-card">
<!-- on envoie les données à updateSettings.php -->
<form method="POST" action="../scripts/updateSettings.php">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<input type="hidden" name="settings_group" value="captcha">
<!-- Checkbox pour activer / désactiver le test anti-robot à l'entrée du site -->
<label class="checkbox-row">
<input type="checkbox" name="captcha_enabled" value="1" <?php echo $captchaEnabled === '1' ? 'checked' : ''; ?>>
Activer le test anti-robot (captcha) à l'entrée du site
</label>
<input type="submit" name="submitsettings" value="Enregistrer" class="btn-submit">
</form>
</div>
</div>
</section>
<!-- ========== SECTION UTILISATEURS ========== -->
<section id="admin-utilisateurs" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-users"></i> Utilisateurs</h2>
<details class="admin-block">
<summary>+ Nouveau compte</summary>
<div class="project-form-card">
<!-- en envoie les données addUser.php -->
<form method="POST" action="../scripts/addUser.php">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<label>Nom du compte</label>
<input type="text" name="user" required>
<!-- role admin ou user -->
<label>Rôle (admin/user)</label>
<input type="text" name="role" placeholder="user" required>
<!-- mot de passe -->
<label>Mot de passe</label>
<input type="password" name="passwd" required>
<!-- Le bouton qui déclenche le PHP en haut de page -->
<input type="submit" name="submituser" value="Ajouter l'utilisateur" class="btn-submit">
</form>
</div>
</details>
<!-- Affichage de la liste des utilisateurs -->
<?php afficherUsersAdmin(); ?>
</section>
<!-- ========== SECTION DONNÉES (EXPORT / IMPORT) ========== -->
<section id="admin-donnees" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-database"></i> Données du site</h2>
<div class="project-container">
<div class="project-form-card">
<!-- Export : télécharge toutes les données en JSON -->
<h3 class="admin-data-title"><i class="fa-solid fa-download"></i> Exporter</h3>
<p class="admin-data-text">
Télécharge une sauvegarde JSON de toutes les données du site
(projets, expériences, écoles, compétences, notification, utilisateurs).
</p>
<a class="btn-submit admin-export-btn" href="../scripts/exportData.php?csrf_token=<?php echo urlencode(csrfToken()); ?>">
<i class="fa-solid fa-download"></i> Exporter les données (JSON)
</a>
<hr class="admin-data-separator">
<!-- Import : écrase les données actuelles avec celles du fichier -->
<h3 class="admin-data-title"><i class="fa-solid fa-upload"></i> Importer</h3>
<p class="admin-data-text admin-data-warning">
⚠️ L'import <strong>écrase définitivement</strong> les données actuelles des tables
présentes dans le fichier. Fais un export avant, au cas où !
</p>
<form method="POST" action="../scripts/importData.php" enctype="multipart/form-data"
onsubmit="return confirm('ATTENTION : les données actuelles du site vont être écrasées par celles du fichier. Continuer ?');">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<input type="file" name="jsonfile" accept=".json,application/json" required>
<input type="submit" value="Importer les données" class="btn-submit btn-delete">
</form>
</div>
</div>
</section>
</div>
<?php
// Liste des projets / xps => en gros on pourra supprimer, et modifier à la vollée sans aller dans la bdd
?>
<h2 class="projects-title">Gérer les projets</h2>
<?php afficherProjetsAdmin(); ?>
<h2 class="projects-title">Gérer les experiences</h2>
<?php afficherXPAdmin();
<!-- Gestion des zones de drag & drop PDF (projets, expériences, écoles) -->
<script>
(function () {
document.querySelectorAll('.pdf-dropzone').forEach(function (zone) {
var input = document.getElementById(zone.dataset.input);
var filenameEl = zone.querySelector('.pdf-dropzone-filename');
function afficherFichier(file) {
if (!file) return;
if (file.type !== 'application/pdf') {
filenameEl.textContent = 'Ce fichier n\'est pas un PDF.';
zone.classList.remove('has-file');
return;
}
filenameEl.textContent = file.name;
zone.classList.add('has-file');
}
// Clic sur la zone = ouvre le sélecteur de fichier
// Garde-fou : input.click() déclenche lui-même un évènement "click" qui remonte
// (bubble) jusqu'à la zone puisque l'input est à l'intérieur de celle-ci. Sans ce
// test, ce clic remonté relance input.click() une seconde fois => double sélecteur.
zone.addEventListener('click', function (e) {
if (e.target === input) return;
input.click();
});
input.addEventListener('change', function () {
afficherFichier(input.files[0]);
});
['dragenter', 'dragover'].forEach(function (evt) {
zone.addEventListener(evt, function (e) {
e.preventDefault();
zone.classList.add('dragging');
});
});
['dragleave', 'drop'].forEach(function (evt) {
zone.addEventListener(evt, function (e) {
e.preventDefault();
zone.classList.remove('dragging');
});
});
zone.addEventListener('drop', function (e) {
var file = e.dataTransfer.files[0];
if (file) {
input.files = e.dataTransfer.files;
afficherFichier(file);
}
});
});
})();
</script>
<?php
// On ajoute ici le footer
require_once('../elements/footer.php'); ?>
+62 -3
View File
@@ -2,8 +2,67 @@
// CGU / CDU
// On ajoute ici le header
// Titre de l'onglet
$pageTitle = 'Conditions générales d\'utilisation';
require_once('./../elements/header.php');
// Ajout du footer
require_once('../elements/footer.php'); ?>
?>
<div class="cgu-page">
<section class="cgu-card">
<h1> Conditions générales d'utilisation </h1>
<h2> Dernière mise à jour : 29 juin 2026</h2>
<hr>
<p>Les présentes conditions générales d'utilisation (CGU) régissent l'utilisation du site web estebanadmin.fr. En accédant à ce site, vous acceptez les présentes conditions.</p>
<h3> 1. Objet</h3>
<p>Le présent site a pour objectif de présenter le parcours, les compétences, les projets et les réalisations dEsteban. Le site peut également contenir des moyens de contact, des liens externes et des contenus liés à son activité personnelle, associative ou professionnelle.</p>
<h3> 2. Accès au site</h3>
<p>L'accès au site est gratuit et ouvert à tous. L'utilisateur s'engage à utiliser le site de manière responsable et conforme à la législation en vigueur.</p>
<p>L'éditeur se réserve le droit de suspendre, d'interrompre ou de modifier tout ou partie du site sans préavis, notamment pour des raisons techniques, légales, de sécurité, de maintenance ou en cas d'usage abusif.</p>
<h3> 3. Propriété intellectuelle</h3>
<p>Lensemble des éléments présents sur le site, notamment les textes, visuels, illustrations, logos, éléments graphiques, structure, code source et contenus originaux, est protégé par le droit de la propriété intellectuelle, sauf mention contraire.</p>
<p>Toute reproduction, représentation, diffusion, adaptation, modification ou exploitation, totale ou partielle, sans autorisation préalable écrite, est interdite, sauf dans les cas autorisés par la loi.</p>
<h3> 4. Utilisation de lintelligence artificielle</h3>
<p>Le site et certains de ses éléments ont pu être conçus avec l'assistance d'outils dintelligence artificielle. Cela peut notamment concerner la génération ou ladaptation de feuilles de style CSS, laide à la rédaction, lautocomplétion de code, la génération de commentaires, de structures répétitives, de formulaires simples ou de requêtes basiques.</p>
<p>L’éditeur précise cependant que le développement principal du projet, notamment le code PHP, HTML et JavaScript, larchitecture générale, lintégration, les choix techniques et la personnalisation globale du site, ont été réalisés majoritairement par un humain, à savoir Esteban MOUSSU.</p>
<p>L'utilisation d'outils tels que GitHub Copilot, Devin ou d'autres assistants similaires constitue une aide au développement et non une substitution complète au travail humain.</p>
<h3> 5. Hébergement et édition</h3>
<p>Ce projet est entièrement développé par Esteban MOUSSU.</p>
<p>Le site est hébergé en 2026 par lentreprise TerraTech Hebergement, en collaboration avec lassociation Eldoria Network.</p>
<p>Les informations diffusées sur le site le sont sous la responsabilité de son éditeur.</p>
<h3> 6. Exactitude des informations</h3>
<p>L’éditeur sefforce de fournir des informations aussi exactes et à jour que possible. Toutefois, aucune garantie nest donnée quant à lexactitude, lexhaustivité ou lactualité des contenus publiés.</p>
<p>Lutilisateur reconnaît utiliser les informations disponibles sur le site sous sa seule responsabilité.</p>
<h3> 7. Liens externes</h3>
<p>Le site peut contenir des liens vers des sites tiers. Ces liens sont fournis à titre informatif. L’éditeur ne saurait être tenu responsable du contenu, du fonctionnement ou des politiques de confidentialité de ces sites externes.</p>
<h3> 8. Responsabilité</h3>
<p>L’éditeur ne pourra être tenu responsable des dommages directs ou indirects résultant de lutilisation du site, dune interruption temporaire, dun dysfonctionnement technique ou dune impossibilité daccès.</p>
<p>Lutilisateur demeure seul responsable de lusage quil fait du site et des informations quil y consulte.</p>
<h3> 9. Données personnelles</h3>
<p>Le site peut proposer un ou plusieurs formulaires de contact. Les données éventuellement transmises par lutilisateur sont uniquement destinées au traitement de sa demande, sauf mention contraire clairement indiquée.</p>
<p>Conformément à la réglementation applicable, lutilisateur peut demander laccès, la rectification ou la suppression de ses données en contactant l’éditeur du site.</p>
<h3> 10. Sécurité</h3>
<p>Lutilisateur sinterdit de perturber le bon fonctionnement du site, dintroduire des programmes malveillants, de tenter un accès non autorisé ou de porter atteinte à lintégrité technique du service.</p>
<h3> 11. Modification des CGU</h3>
<p>L’éditeur se réserve le droit de modifier à tout moment les présentes CGU. Les modifications prennent effet dès leur publication sur cette page.</p>
<h3> 12. Droit applicable</h3>
<p>Les présentes CGU sont soumises au droit français. En cas de litige, et à défaut de résolution amiable, les juridictions compétentes seront celles déterminées par les règles de droit commun.</p>
</section>
</div>
<?php
require_once('../elements/footer.php');
?>
+24 -8
View File
@@ -1,19 +1,36 @@
<?php
// Formulaire de contact avec WEBHOOK Discord
// Titre de l'onglet
$pageTitle = 'Contact';
// On ajoute ici le header
require_once("../elements/header.php");
// Réglages de l'email et de l'adresse (modifiables et masquables depuis l'admin)
require_once('../functions/listSettings.php');
$contactEmail = recupererSetting('contact_email') ?? 'esteban.moussu@ik.me';
$contactEmailEnabled = (recupererSetting('contact_email_enabled') ?? '1') === '1';
$contactAddressLine1 = recupererSetting('contact_address_line1') ?? '20 Grande rue';
$contactAddressLine2 = recupererSetting('contact_address_line2') ?? '52500 Broncourt';
$contactAddressEnabled = (recupererSetting('contact_address_enabled') ?? '1') === '1';
?>
<body>
<main class="contact-page">
<div class="contact-page">
<section class="contact-wrapper">
<div class="contact-card">
<div class="contact-info">
<p class="contact-title">Contact</p>
<p class="contact-name">Moussu Esteban</p>
<p class="contact-line">esteban.moussu@ik.me</p>
<p class="contact-line">20 Grande rue</p>
<p class="contact-line">52500 Broncourt</p>
<p class="contact-name">Esteban MOUSSU</p>
<?php if ($contactEmailEnabled && !empty($contactEmail)): ?>
<p class="contact-line"><i class="fa-solid fa-envelope"></i> <?php echo htmlspecialchars($contactEmail); ?></p>
<?php endif; ?>
<?php if ($contactAddressEnabled && (!empty($contactAddressLine1) || !empty($contactAddressLine2))): ?>
<p class="contact-line"><i class="fa-solid fa-location-dot"></i> <?php echo htmlspecialchars($contactAddressLine1); ?></p>
<p class="contact-line"><?php echo htmlspecialchars($contactAddressLine2); ?></p>
<?php endif; ?>
</div>
<!-- Formulaire d'envoi -->
@@ -32,8 +49,7 @@ require_once("../elements/header.php");
</form>
</div>
</section>
</main>
</body>
</div>
<?php
// Ajout du footer
+166
View File
@@ -0,0 +1,166 @@
<?php
// Page de détails d'un projet / d'une expérience / d'une école
// Le contenu markdown (stocké en BDD, éditable depuis le panel admin) est rendu
// côté navigateur avec marked.js puis nettoyé avec DOMPurify (sécurité XSS)
// Connexion BDD (avant le header car on doit pouvoir rediriger si l'élément n'existe pas)
require_once('../functions/databaseConnection.php');
// Types autorisés : liste blanche pour empêcher toute injection de nom de table
// (le PDF est stocké dans une table associée depuis la migration Juillet 2026 (6) : on la
// joint juste pour le nom de fichier, jamais pour son contenu binaire)
$types = [
'projet' => ['table' => 'projects', 'pdf' => 'project_pdfs', 'pdf_colonne' => 'project_id', 'retour' => './projects.php', 'label' => 'Projet'],
'experience' => ['table' => 'experiences', 'pdf' => 'experience_pdfs', 'pdf_colonne' => 'experience_id', 'retour' => './xp.php', 'label' => 'Expérience'],
'ecole' => ['table' => 'schools', 'pdf' => 'school_pdfs', 'pdf_colonne' => 'school_id', 'retour' => './xp.php', 'label' => 'École'],
];
$type = $_GET['type'] ?? '';
$id = $_GET['id'] ?? '';
// Type inconnu ou id invalide : retour à l'accueil
if (!isset($types[$type]) || !is_numeric($id)) {
header('Location: ./home.php');
exit;
}
$infos = $types[$type];
// Récupération de l'élément demandé
// (pdf_data n'est volontairement pas sélectionné : le PDF est servi à part par viewPdf.php
// dans une <iframe>, inutile de charger son contenu binaire en mémoire ici)
$colonnesCommunes = 'e.id, e.title, e.status, e.tags, e.description, e.image_url, e.markdown, e.link_url, p.pdf_filename';
$colonnesParType = [
'projet' => $colonnesCommunes,
'experience' => $colonnesCommunes . ', e.xptype, e.duration',
'ecole' => $colonnesCommunes . ', e.duration',
];
try {
$stmt = $dbh->prepare(
'SELECT ' . $colonnesParType[$type] . '
FROM `' . $infos['table'] . '` e
LEFT JOIN `' . $infos['pdf'] . '` p ON p.`' . $infos['pdf_colonne'] . '` = e.id
WHERE e.id = :id'
);
$stmt->bindValue(':id', (int) $id, PDO::PARAM_INT);
$stmt->execute();
$element = $stmt->fetch(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
$element = false;
}
$hasMarkdown = $element && trim($element['markdown'] ?? '') !== '';
$hasPdf = $element && trim($element['pdf_filename'] ?? '') !== '';
// Élément inexistant, ou sans markdown ni PDF : retour à la liste
if (!$element || (!$hasMarkdown && !$hasPdf)) {
header('Location: ' . $infos['retour']);
exit;
}
// Titre de l'onglet
$pageTitle = $element['title'];
// On ajoute le header
require_once('../elements/header.php');
// Préparation des données pour l'affichage
$titre = htmlspecialchars($element['title']);
$image = htmlspecialchars($element['image_url'] ?? '');
$status = htmlspecialchars($element['status'] ?? '');
$duration = htmlspecialchars($element['duration'] ?? '');
$xptype = htmlspecialchars($element['xptype'] ?? '');
$tags = [];
if (!empty($element['tags'])) {
$tags = array_filter(array_map('trim', explode(',', $element['tags'])));
}
?>
<div class="details-page">
<!-- Bouton retour vers la liste -->
<a class="btn btn-ghost details-back" href="<?php echo $infos['retour']; ?>">
<i class="fa-solid fa-arrow-left"></i> Retour
</a>
<article class="details-card">
<?php if (!empty($image)): ?>
<div class="details-banner project-banner">
<img src="<?php echo $image; ?>" alt="Image : <?php echo $titre; ?>">
<?php if (!empty($status)): ?>
<div class="project-state"><?php echo $status; ?></div>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="details-content">
<span class="details-type"><?php echo $infos['label']; ?></span>
<h1><?php echo $titre; ?></h1>
<!-- Métadonnées : type / durée si renseignés -->
<?php if (!empty($xptype) || !empty($duration)): ?>
<p class="details-meta">
<?php if (!empty($xptype)): ?><span><i class="fa-solid fa-tag"></i> <?php echo $xptype; ?></span><?php endif; ?>
<?php if (!empty($duration)): ?><span><i class="fa-solid fa-clock"></i> <?php echo $duration; ?></span><?php endif; ?>
</p>
<?php endif; ?>
<?php if (!empty($tags)): ?>
<div class="project-tags">
<?php foreach ($tags as $tag): ?>
<span class="tag"><?php echo htmlspecialchars($tag); ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($hasMarkdown): ?>
<!-- Le markdown brut est placé ici (échappé), puis rendu en HTML par le script plus bas -->
<div id="markdown-source" hidden><?php echo htmlspecialchars($element['markdown']); ?></div>
<div class="markdown-content" id="markdown-target">
<p>Chargement du contenu...</p>
</div>
<?php elseif ($hasPdf): ?>
<!-- Mini lecteur PDF : le fichier est servi par viewPdf.php depuis la base de données -->
<div class="pdf-viewer-wrap">
<div class="pdf-viewer-toolbar">
<span><i class="fa-solid fa-file-pdf"></i> <?php echo htmlspecialchars($element['pdf_filename']); ?></span>
<a class="btn btn-ghost pdf-download-btn" href="../scripts/viewPdf.php?type=<?php echo $type; ?>&id=<?php echo (int) $id; ?>&download=1" download>
<i class="fa-solid fa-download"></i> Télécharger
</a>
</div>
<iframe
class="pdf-viewer"
src="../scripts/viewPdf.php?type=<?php echo $type; ?>&id=<?php echo (int) $id; ?>"
title="Aperçu PDF : <?php echo $titre; ?>"
></iframe>
</div>
<?php endif; ?>
</div>
</article>
</div>
<?php if ($hasMarkdown): ?>
<!-- marked.js : convertit le markdown en HTML / DOMPurify : nettoie le HTML généré (sécurité) -->
<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<script>
(function () {
var source = document.getElementById('markdown-source');
var cible = document.getElementById('markdown-target');
// textContent décode automatiquement les entités HTML => on récupère le markdown d'origine
var markdown = source.textContent;
cible.innerHTML = DOMPurify.sanitize(marked.parse(markdown, { breaks: true }));
})();
</script>
<?php endif; ?>
<?php
// Ajout du footer
require_once('../elements/footer.php'); ?>
+130 -1
View File
@@ -3,8 +3,137 @@
// Page Home PortFolio Esteban
// Cette page servira de page d'accueil où nous pourrons y retrouver une redirection vers les différents projets, espace de connexion, form de contact, etc...
// Titre de l'onglet
$pageTitle = 'Accueil';
// Insertion du header
require_once('./../elements/header.php');
// La notification est maintenant dynamique : modifiable depuis le panel Admin (table settings)
require_once('../functions/listSettings.php');
// Bandeau "Ils m'ont fait confiance", géré depuis le panel Admin (table logo_banners)
require_once('../functions/listLogos.php');
// Bandeau "Outils que je sais manipuler" : dérivé directement des icônes de la page
// Compétences, pas de saisie séparée (voir listeIconesCompetences)
require_once('../functions/listSkills.php');
// Chiffres clés (bandeau de stats sous le hero) : nombre réel de projets/expériences
// en BDD, et nombre de technologies reconnaissables (réutilise listeIconesCompetences)
require_once('../functions/listStats.php');
$statsHome = recupererStatsHome();
$statsHome['technologies'] = count(listeIconesCompetences());
// Affichage de la notification (si elle est activée dans le panel Admin)
afficherNotice();
?>
<!-- Section hero : présentation rapide -->
<section class="hero">
<span class="hero-badge"><i class="fa-solid fa-graduation-cap"></i> Étudiant en informatique</span>
<h1>Salut, moi c'est <span class="accent">Esteban</span></h1>
<p>
Passionné par les systèmes, les réseaux et le développement web.
Découvre mes projets, mes expériences professionnelles et mes compétences.
</p>
<div class="hero-actions">
<a href="../pages/projects.php" class="btn btn-primary"><i class="fa-solid fa-folder-open"></i> Voir mes projets</a>
<a href="../pages/contact.php" class="btn btn-ghost"><i class="fa-solid fa-paper-plane"></i> Me contacter</a>
</div>
<!-- Flèche animée qui invite à défiler vers le bas -->
<a href="#explorer" class="scroll-indicator" aria-label="Défiler vers le bas">
<i class="fa-solid fa-chevron-down"></i>
</a>
</section>
<?php
// Chiffres clés : n'affiche que les compteurs non nuls (pas de "0 projets" décevant
// si le site vient d'être initialisé)
$statsAffichables = array_filter([
'projets' => ['valeur' => $statsHome['projets'], 'label' => 'Projet' . ($statsHome['projets'] > 1 ? 's' : '') . ' réalisés'],
'experiences' => ['valeur' => $statsHome['experiences'], 'label' => 'Expérience' . ($statsHome['experiences'] > 1 ? 's' : '') . ' pro'],
'technologies' => ['valeur' => $statsHome['technologies'], 'label' => 'Technologies maîtrisées'],
], function ($stat) { return $stat['valeur'] > 0; });
?>
<?php if (!empty($statsAffichables)): ?>
<!-- Bandeau de chiffres clés : compteurs réels, calculés depuis la BDD -->
<section class="stats-strip">
<?php foreach ($statsAffichables as $stat): ?>
<div class="stat-item">
<span class="stat-number"><?php echo (int) $stat['valeur']; ?></span>
<span class="stat-label"><?php echo htmlspecialchars($stat['label']); ?></span>
</div>
<?php endforeach; ?>
</section>
<?php endif; ?>
<?php if (!empty(listeIconesCompetences())): ?>
<!-- Bandeau défilant des outils, dérivé de la page Compétences -->
<section class="logo-banner-section">
<div class="logo-banner-header">
<h2>Outils que je sais manipuler</h2>
</div>
<?php afficherOutilsCompetences(); ?>
</section>
<?php endif; ?>
<!-- Section d'exploration rapide -->
<section class="quick-links-section" id="explorer">
<div class="quick-links-header">
<!-- Titre et description de la section -->
<h2>Explorer</h2>
<p>Accède rapidement aux sections principales de mon portfolio.</p>
</div>
<!-- Grille de cartes de redirection vers les différentes page -->
<div class="quick-links-grid">
<a href="../pages/projects.php" class="quick-link-card">
<div class="quick-link-icon">
<i class="fa-solid fa-folder-open"></i>
</div>
<h3>Projets</h3>
<p>Voir mes réalisations.</p>
</a>
<a href="../pages/xp.php" class="quick-link-card">
<div class="quick-link-icon">
<i class="fa-solid fa-briefcase"></i>
</div>
<h3>Expériences</h3>
<p>Découvrir mes stages et missions réalisées.</p>
</a>
<a href="../pages/skills.php" class="quick-link-card">
<div class="quick-link-icon">
<i class="fa-solid fa-code"></i>
</div>
<h3>Compétences</h3>
<!-- Page où il y aura toutes mes compétences -->
<p>Systèmes, réseaux et développement.</p>
</a>
<a href="../pages/contact.php" class="quick-link-card">
<div class="quick-link-icon">
<i class="fa-solid fa-paper-plane"></i>
</div>
<h3>Contact</h3>
<p>Me contacter rapidement pour échanger.</p>
</a>
</div>
</section>
<?php if (!empty(listeLogos('partner'))): ?>
<!-- Bandeau défilant des entreprises partenaires, géré depuis le panel Admin -->
<section class="logo-banner-section">
<div class="logo-banner-header">
<h2>Ils m'ont fait confiance</h2>
</div>
<?php afficherLogos('partner'); ?>
</section>
<?php endif; ?>
<?php
// Ajout du footer
require_once('../elements/footer.php'); ?>
require_once('../elements/footer.php');
?>
+5 -7
View File
@@ -3,6 +3,8 @@
// Dans l'idée un champ pour un nom d'utilisateur, et un pour le mot de passe puis un bouton de connexion
// Egalement permettre à l'utilisateur de faire retour en arrière s'il s'est trompé (croix ou header)
// Titre de l'onglet
$pageTitle = 'Connexion';
// On ajoute ici le Header
require_once('./../elements/header.php');
@@ -15,8 +17,6 @@ if (isset($_SESSION['user_id'])){
?>
<body>
<!-- CSS Login (fait par Intelligence Arti) -->
<div class="login-container">
<div class="login-card">
@@ -26,9 +26,9 @@ if (isset($_SESSION['user_id'])){
<p>Connexion</p>
<!-- Deux champs de texte, un "id" et un "passwd" -->
<input type="text" name="id" id="id" placeholder="Votre identifiant">
<input type="password" name="passwd" id="passwd" placeholder="Votre mot de passe">
<!-- Deux champs de texte, un "username" et un "password" -->
<input type="text" name="username" id="username" placeholder="Votre identifiant">
<input type="password" name="password" id="password" placeholder="Votre mot de passe">
<!-- Bouton de connexion -->
<input type="submit" value="Se connecter">
@@ -37,8 +37,6 @@ if (isset($_SESSION['user_id'])){
</div>
</div>
</body>
<?php
// Ajout du footer
+8 -2
View File
@@ -3,17 +3,23 @@
// Page affichant les différents projets personnels / scolaires / professionnels
// On pourrait également rajouter des tags comme "En cours" ou même "En reflexion"...
// Titre de l'onglet
$pageTitle = 'Projets';
// On ajoute le header
require_once('../elements/header.php');
// Ajout d'un message indiquant si l'utilisateur est admin qu'il peut modifier ces projets
if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin') {
echo "<p style='color: white; text-align: center;'>Vous êtes administrateur, vous pouvez modifier les projets <a href='../pages/admin.php'>ici</a>.</p>";
echo "<p class='admin-notice'>Vous êtes administrateur, vous pouvez modifier les projets <a href='../pages/admin.php'>ici</a>.</p>";
}
// Affichages des projets
?>
<p class="projects-title">Liste des projets</p>
<div class="page-header">
<h1>Projets</h1>
<p>Mes réalisations personnelles, scolaires et professionnelles.</p>
</div>
<?php
+38
View File
@@ -0,0 +1,38 @@
<?php
// Compétences : le contenu est maintenant dynamique, géré depuis le panel Admin (table skills)
// Titre de l'onglet
$pageTitle = 'Compétences';
// On ajoute ici le header
require_once('./../elements/header.php');
// Ajout d'un message indiquant si l'utilisateur est admin qu'il peut modifier ces compétences
if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin') {
echo "<p class='admin-notice'>Vous êtes administrateur, vous pouvez modifier les compétences <a href='../pages/admin.php'>ici</a>.</p>";
}
?>
<!-- Contenu de la page skills -->
<section class="skills-section">
<div class="skills-header">
<h1>Compétences</h1>
<p>Un aperçu des technologies et des domaines que je maîtrise.</p>
</div>
<?php
require_once('../functions/listSkills.php');
// Appel de ma fonction dans Functions/listSkills.php
afficherSkills();
?>
</section>
<?php
// Ajout du footer
require_once('../elements/footer.php'); ?>
+65 -2
View File
@@ -2,18 +2,24 @@
// Page affichant les différentes expériences pro (stages etc...)
// Titre de l'onglet
$pageTitle = 'Expériences';
// On ajoute le header
require_once('../elements/header.php');
// Ajout d'un message indiquant si l'utilisateur est admin qu'il peut modifier ces expériences
if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin') {
echo "<p style='color: white; text-align: center;'>Vous êtes administrateur, vous pouvez modifier les experiences <a href='../pages/admin.php'>ici</a>.</p>";
echo "<p class='admin-notice'>Vous êtes administrateur, vous pouvez modifier les experiences <a href='../pages/admin.php'>ici</a>.</p>";
}
// Affichages des xp pro
?>
<p class="projects-title">Liste des expériences professionnelles</p>
<div class="page-header" id="experiences">
<h1>Expériences professionnelles</h1>
<p>Mes stages et missions réalisées en entreprise.</p>
</div>
<?php
require_once('../functions/listXP.php');
@@ -21,6 +27,63 @@ require_once('../functions/listXP.php');
// Appel de ma fonction dans Functions/listXP.php
afficherXP();
require_once('../functions/listSchool.php');
// Section écoles / formations, en dessous des expériences professionnelles
// On n'affiche le titre que s'il y a au moins une école (table remplie)
if (!empty(listeEcoles())) {
?>
<div class="page-header" id="ecoles">
<h2>Mes écoles</h2>
<p>Mon parcours scolaire et mes formations.</p>
</div>
<?php
// Appel de ma fonction dans Functions/listSchool.php
afficherEcoles();
?>
<!-- Bouton flottant : icône chapeau de diplômé = descendre vers les écoles,
icône sac de travail = remonter vers les expériences -->
<button class="section-jump" id="section-jump" aria-label="Basculer entre expériences et écoles">
<i class="fa-solid fa-graduation-cap"></i>
<i class="fa-solid fa-briefcase"></i>
</button>
<script>
(function () {
var btn = document.getElementById('section-jump');
var ecoles = document.getElementById('ecoles');
if (!btn || !ecoles) return;
// Vrai quand la moitié de l'écran a dépassé le début de la section écoles
function estDansEcoles() {
return window.scrollY + window.innerHeight / 2 >= ecoles.offsetTop;
}
// Change l'icône du bouton selon la section où on se trouve
function majIcone() {
btn.classList.toggle('mode-ecoles', estDansEcoles());
}
btn.addEventListener('click', function () {
if (estDansEcoles()) {
// On est dans les écoles : remonter aux expériences
window.scrollTo({ top: 0, behavior: 'smooth' });
} else {
// On est dans les expériences : descendre aux écoles
ecoles.scrollIntoView({ behavior: 'smooth' });
}
});
window.addEventListener('scroll', majIcone);
majIcone();
})();
</script>
<?php
}
// Ajout du footer
require_once('../elements/footer.php'); ?>
+65
View File
@@ -0,0 +1,65 @@
<?php
require_once(__DIR__ . '/../functions/security.php');
require_once '../functions/databaseConnection.php';
require_once('../functions/sendLogs.php');
// Pour faire cette fonction l'utilisateur doit être administrateur
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
header('Location: ../pages/home.php');
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// La requête doit être un POST sinon on l'a refuse
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Récupération des données dans la requete POST
// La catégorie est restreinte à 'tool' ou 'partner' (sinon on retombe sur 'tool')
$category = ($_POST['category'] ?? '') === 'partner' ? 'partner' : 'tool';
$name = trim($_POST['name'] ?? '');
$icon = trim($_POST['icon'] ?? '');
$logo_url = trim($_POST['logourl'] ?? '');
$link_url = trim($_POST['linkurl'] ?? '');
$position = (int) ($_POST['position'] ?? 0);
// On vérifie que les champs obligatoires ne sont pas vides : il faut un nom, et
// au moins l'un des deux visuels (icône Devicon ou image)
if (!empty($name) && (!empty($icon) || !empty($logo_url))) {
try {
// On prépare la requête SQL pour insérer le nouveau logo
$stmt = $dbh->prepare('INSERT INTO logo_banners (category, name, icon, logo_url, link_url, position) VALUES (:category, :name, :icon, :logo_url, :link_url, :position)');
// On execute la requete préparée
$stmt->execute([
':category' => $category,
':name' => $name,
':icon' => $icon !== '' ? $icon : null,
':logo_url' => $logo_url !== '' ? $logo_url : null,
':link_url' => $link_url,
':position' => $position
]);
// Log Discord
sendLog("Ajout d'un logo (" . $category . ") : " . $name);
// Redirection vers home.php dès que la requete est executée
header('Location: ../pages/home.php');
exit;
} catch (PDOException $e) {
// En cas d'erreur :
echo "Erreur pendant l'ajout du logo: " . $e->getMessage();
exit;
}
} else {
echo "Veuillez remplir le nom et au moins une icône ou une image.";
exit;
}
} else {
// Si on y accède de façon peu légitime sans requête POST on refuse
header('Location: ../pages/home.php');
exit;
}
?>
+50 -8
View File
@@ -1,6 +1,8 @@
<?php
session_start();
require_once(__DIR__ . '/../functions/security.php');
require_once '../functions/databaseConnection.php';
require_once('../functions/sendLogs.php');
require_once('../functions/pdfUpload.php');
// Pour faire cette fonction l'utilisateur doit être administrateur
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
@@ -8,28 +10,68 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// Le fichier envoyé dépasse post_max_size : $_POST/$_FILES sont vides, on le détecte
// tout de suite pour éviter un échec silencieux
if (postDepasseLimite()) {
die(messageDepassementPost());
}
// La requête doit être un POST sinon on l'a refuse
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Récupération des données dans la requete POST
$title = $_POST['title'] ?? '';
$status = trim($_POST['status'] ?? '');
$tags = $_POST['tags'] ?? '';
$description = $_POST['description'] ?? '';
$image_url = $_POST['imageurl'] ?? '';
$markdown = trim($_POST['markdown'] ?? '');
$link_url = trim($_POST['linkurl'] ?? '');
// Lecture et validation du PDF envoyé (optionnel)
$pdfResultat = lireUploadPdf('pdf');
if (is_string($pdfResultat)) {
// Le fichier envoyé n'est pas valide : on affiche l'erreur et on s'arrête
echo $pdfResultat;
exit;
}
$pdfData = is_array($pdfResultat) ? $pdfResultat['data'] : null;
$pdfFilename = is_array($pdfResultat) ? $pdfResultat['filename'] : null;
// On vérifie que les champs obligatoires ne sont pas vide
if (!empty($title) && !empty($description) && !empty($image_url)) {
try {
// On prépare la requête SQL pour insérer le nouveau projet
$stmt = $dbh->prepare('INSERT INTO projects (title, tags, description, image_url) VALUES (:title, :tags, :description, :image_url)');
// Le PDF (pdf_data) n'est plus stocké ici : il vit dans project_pdfs (table
// associée, voir migration Juillet 2026 (6)), pour que les listes de projets
// n'aient jamais à charger son contenu binaire en mémoire
$stmt = $dbh->prepare('INSERT INTO projects (title, status, tags, description, image_url, markdown, link_url) VALUES (:title, :status, :tags, :description, :image_url, :markdown, :link_url)');
// On execute la requete préparée
$stmt->execute([
':title' => $title,
':tags' => $tags,
':description' => $description,
':image_url' => $image_url
]);
$stmt->bindValue(':title', $title, PDO::PARAM_STR);
$stmt->bindValue(':status', $status, PDO::PARAM_STR);
$stmt->bindValue(':tags', $tags, PDO::PARAM_STR);
$stmt->bindValue(':description', $description, PDO::PARAM_STR);
$stmt->bindValue(':image_url', $image_url, PDO::PARAM_STR);
$stmt->bindValue(':markdown', $markdown, PDO::PARAM_STR);
$stmt->bindValue(':link_url', $link_url, PDO::PARAM_STR);
$stmt->execute();
// Si un PDF a été envoyé, on l'enregistre à part dans project_pdfs
if ($pdfData !== null) {
$nouvelId = (int) $dbh->lastInsertId();
$stmtPdf = $dbh->prepare('INSERT INTO project_pdfs (project_id, pdf_filename, pdf_data) VALUES (:project_id, :pdf_filename, :pdf_data)');
$stmtPdf->bindValue(':project_id', $nouvelId, PDO::PARAM_INT);
$stmtPdf->bindValue(':pdf_filename', $pdfFilename, PDO::PARAM_STR);
$stmtPdf->bindValue(':pdf_data', $pdfData, PDO::PARAM_LOB);
$stmtPdf->execute();
}
// Log Discord
sendLog("Ajout du projet : " . $title);
// Redirection vers projects.php ds que la requete est executée
header('Location: ../pages/projects.php');
+95
View File
@@ -0,0 +1,95 @@
<?php
require_once(__DIR__ . '/../functions/security.php');
require_once '../functions/databaseConnection.php';
require_once('../functions/sendLogs.php');
require_once('../functions/pdfUpload.php');
// Pour faire cette fonction l'utilisateur doit être administrateur
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
header('Location: ../pages/home.php');
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// Le fichier envoyé dépasse post_max_size : $_POST/$_FILES sont vides, on le détecte
// tout de suite pour éviter un échec silencieux
if (postDepasseLimite()) {
die(messageDepassementPost());
}
// La requête doit être un POST sinon on l'a refuse
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Récupération des données dans la requete POST
$title = trim($_POST['title'] ?? '');
$status = trim($_POST['status'] ?? '');
$tags = trim($_POST['tags'] ?? '');
$description = trim($_POST['description'] ?? '');
$duration = trim($_POST['duration'] ?? '');
$image_url = trim($_POST['imageurl'] ?? '');
$markdown = trim($_POST['markdown'] ?? '');
$link_url = trim($_POST['linkurl'] ?? '');
// Lecture et validation du PDF envoyé (optionnel)
$pdfResultat = lireUploadPdf('pdf');
if (is_string($pdfResultat)) {
echo $pdfResultat;
exit;
}
$pdfData = is_array($pdfResultat) ? $pdfResultat['data'] : null;
$pdfFilename = is_array($pdfResultat) ? $pdfResultat['filename'] : null;
// On vérifie que les champs obligatoires ne sont pas vide
if (!empty($title) && !empty($description) && !empty($duration) && !empty($image_url)) {
try {
// On prépare la requête SQL pour insérer la nouvelle école
// Le PDF (pdf_data) n'est plus stocké ici : il vit dans school_pdfs (table
// associée, voir migration Juillet 2026 (6)), pour que les listes d'écoles
// n'aient jamais à charger son contenu binaire en mémoire
$stmt = $dbh->prepare('INSERT INTO schools (title, status, tags, description, duration, image_url, markdown, link_url) VALUES (:title, :status, :tags, :description, :duration, :image_url, :markdown, :link_url)');
// On execute la requete préparée
$stmt->bindValue(':title', $title, PDO::PARAM_STR);
$stmt->bindValue(':status', $status, PDO::PARAM_STR);
$stmt->bindValue(':tags', $tags, PDO::PARAM_STR);
$stmt->bindValue(':description', $description, PDO::PARAM_STR);
$stmt->bindValue(':duration', $duration, PDO::PARAM_STR);
$stmt->bindValue(':image_url', $image_url, PDO::PARAM_STR);
$stmt->bindValue(':markdown', $markdown, PDO::PARAM_STR);
$stmt->bindValue(':link_url', $link_url, PDO::PARAM_STR);
$stmt->execute();
// Si un PDF a été envoyé, on l'enregistre à part dans school_pdfs
if ($pdfData !== null) {
$nouvelId = (int) $dbh->lastInsertId();
$stmtPdf = $dbh->prepare('INSERT INTO school_pdfs (school_id, pdf_filename, pdf_data) VALUES (:school_id, :pdf_filename, :pdf_data)');
$stmtPdf->bindValue(':school_id', $nouvelId, PDO::PARAM_INT);
$stmtPdf->bindValue(':pdf_filename', $pdfFilename, PDO::PARAM_STR);
$stmtPdf->bindValue(':pdf_data', $pdfData, PDO::PARAM_LOB);
$stmtPdf->execute();
}
// Log Discord
sendLog("Ajout de l'école : " . $title);
// Redirection vers xp.php dès que la requete est executée
header('Location: ../pages/xp.php');
exit;
} catch (PDOException $e) {
// En cas d'erreur :
echo "Erreur pendant l'ajout de l'école: " . $e->getMessage();
exit;
}
} else {
echo "Veuillez remplir tous les champs obligatoires.";
exit;
}
} else {
// Si on y accède de façon peu légitime sans requête POST on refuse
header('Location: ../pages/xp.php');
exit;
}
?>
+59
View File
@@ -0,0 +1,59 @@
<?php
require_once(__DIR__ . '/../functions/security.php');
require_once '../functions/databaseConnection.php';
require_once('../functions/sendLogs.php');
// Pour faire cette fonction l'utilisateur doit être administrateur
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
header('Location: ../pages/home.php');
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// La requête doit être un POST sinon on l'a refuse
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Récupération des données dans la requete POST
$title = trim($_POST['title'] ?? '');
$icon = trim($_POST['icon'] ?? '');
$items = trim($_POST['items'] ?? '');
$position = (int) ($_POST['position'] ?? 0);
// On vérifie que les champs obligatoires ne sont pas vide
if (!empty($title) && !empty($items)) {
try {
// On prépare la requête SQL pour insérer la nouvelle catégorie de compétences
$stmt = $dbh->prepare('INSERT INTO skills (title, icon, items, position) VALUES (:title, :icon, :items, :position)');
// On execute la requete préparée
$stmt->execute([
':title' => $title,
':icon' => $icon,
':items' => $items,
':position' => $position
]);
// Log Discord
sendLog("Ajout de la catégorie de compétences : " . $title);
// Redirection vers skills.php dès que la requete est executée
header('Location: ../pages/skills.php');
exit;
} catch (PDOException $e) {
// En cas d'erreur :
echo "Erreur pendant l'ajout de la compétence: " . $e->getMessage();
exit;
}
} else {
echo "Veuillez remplir tous les champs obligatoires.";
exit;
}
} else {
// Si on y accède de façon peu légitime sans requête POST on refuse
header('Location: ../pages/skills.php');
exit;
}
?>
+85
View File
@@ -0,0 +1,85 @@
<?php
// Démarrage de la session pour vérifier les droits de l'utilisateur
require_once(__DIR__ . '/../functions/security.php');
// Ajout de connexion à la BDD
require_once("../functions/databaseConnection.php");
// Ajout des logs Discord
require_once("../functions/sendLogs.php");
// Vérification que l'utilisateur est bien admin
if (!isset($_SESSION["role"]) || $_SESSION["role"] != "admin") {
header("Location: ../pages/home.php");
exit();
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// Si le formulaire a été soumis
if ($_SERVER["REQUEST_METHOD"] == "POST") {
// Récupération des données du formulaire
$user = trim($_POST["user"] ?? "");
$role = $_POST["role"] ?? "";
$passwd = $_POST["passwd"] ?? "";
// Vérification que tous les champs sont remplis
if ($user == "" || $role == "" || $passwd == "") {
echo "Tous les champs doivent être remplis.";
exit();
} else {
// Vérification que le rôle est valide (admin ou user)
if ($role != "admin" && $role != "user") {
echo "Le rôle doit être admin ou user.";
exit();
} else {
try {
// Requête SQL pour vérifier si le nom d'utilisateur existe déjà
$sqlVerification = "SELECT * FROM users WHERE username = :username LIMIT 1";
$requeteVerification = $dbh->prepare($sqlVerification);
$requeteVerification->execute([
":username" => $user
]);
$utilisateurExistant = $requeteVerification->fetch();
// Si l'utilisateur existe déjà, on affiche une erreur
if ($utilisateurExistant) {
echo "Ce nom d'utilisateur existe déjà.";
exit();
} else {
// Génération de la date de création
$dateCreation = date("Y-m-d H:i:s");
// Hashage du mot de passe
$passwordHash = password_hash($passwd, PASSWORD_DEFAULT);
// Requête SQL d'insertion du nouvel utilisateur
$sqlAjout = "INSERT INTO users (username, password, role, created_at) VALUES (:username, :password, :role, :created_at)";
$requeteAjout = $dbh->prepare($sqlAjout);
$requeteAjout->execute([
":username" => $user,
":password" => $passwordHash,
":role" => $role,
":created_at" => $dateCreation
]);
// Log Discord
sendLog("Création du compte : " . $user . " (rôle : " . $role . ")");
// Redirection vers la page admin après succès
header("Location: ../pages/admin.php");
exit();
}
} catch (PDOException $e) {
// En cas d'erreur PDO, affichage du message
echo "Erreur pendant l'ajout de l'utilisateur : " . $e->getMessage();
exit();
}
}
}
} else {
// Si la requête n'est pas de type POST, redirection
header("Location: ../pages/admin.php");
exit();
}
?>
+51 -10
View File
@@ -1,6 +1,8 @@
<?php
session_start();
require_once(__DIR__ . '/../functions/security.php');
require_once '../functions/databaseConnection.php';
require_once('../functions/sendLogs.php');
require_once('../functions/pdfUpload.php');
// Pour faire cette fonction l'utilisateur doit être administrateur
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
@@ -8,32 +10,71 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// Le fichier envoyé dépasse post_max_size : $_POST/$_FILES sont vides, on le détecte
// tout de suite pour éviter un échec silencieux
if (postDepasseLimite()) {
die(messageDepassementPost());
}
// La requête doit être un POST sinon on l'a refuse
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Récupération des données dans la requete POST
$title = $_POST['title'] ?? '';
$status = trim($_POST['status'] ?? '');
$xptype = $_POST['xptype'] ?? '';
$tags = $_POST['tags'] ?? '';
$description = $_POST['description'] ?? '';
$duration = $_POST['duration'] ?? '';
$image_url = $_POST['imageurl'] ?? '';
$markdown = trim($_POST['markdown'] ?? '');
$link_url = trim($_POST['linkurl'] ?? '');
// Lecture et validation du PDF envoyé (optionnel)
$pdfResultat = lireUploadPdf('pdf');
if (is_string($pdfResultat)) {
echo $pdfResultat;
exit;
}
$pdfData = is_array($pdfResultat) ? $pdfResultat['data'] : null;
$pdfFilename = is_array($pdfResultat) ? $pdfResultat['filename'] : null;
// On vérifie que les champs obligatoires ne sont pas vide
if (!empty($title) && !empty($description) && !empty($image_url)) {
try {
// On prépare la requête SQL pour insérer la nouvelle experience
$stmt = $dbh->prepare('INSERT INTO experiences (title, xptype, tags, description, duration, image_url) VALUES (:title, :xptype, :tags, :description, :duration, :image_url)');
// Le PDF (pdf_data) n'est plus stocké ici : il vit dans experience_pdfs (table
// associée, voir migration Juillet 2026 (6)), pour que les listes d'expériences
// n'aient jamais à charger son contenu binaire en mémoire
$stmt = $dbh->prepare('INSERT INTO experiences (title, status, xptype, tags, description, duration, image_url, markdown, link_url) VALUES (:title, :status, :xptype, :tags, :description, :duration, :image_url, :markdown, :link_url)');
// On execute la requete préparée
$stmt->execute([
':title' => $title,
':xptype' => $xptype,
':tags' => $tags,
':description' => $description,
':duration' => $duration,
':image_url' => $image_url
]);
$stmt->bindValue(':title', $title, PDO::PARAM_STR);
$stmt->bindValue(':status', $status, PDO::PARAM_STR);
$stmt->bindValue(':xptype', $xptype, PDO::PARAM_STR);
$stmt->bindValue(':tags', $tags, PDO::PARAM_STR);
$stmt->bindValue(':description', $description, PDO::PARAM_STR);
$stmt->bindValue(':duration', $duration, PDO::PARAM_STR);
$stmt->bindValue(':image_url', $image_url, PDO::PARAM_STR);
$stmt->bindValue(':markdown', $markdown, PDO::PARAM_STR);
$stmt->bindValue(':link_url', $link_url, PDO::PARAM_STR);
$stmt->execute();
// Si un PDF a été envoyé, on l'enregistre à part dans experience_pdfs
if ($pdfData !== null) {
$nouvelId = (int) $dbh->lastInsertId();
$stmtPdf = $dbh->prepare('INSERT INTO experience_pdfs (experience_id, pdf_filename, pdf_data) VALUES (:experience_id, :pdf_filename, :pdf_data)');
$stmtPdf->bindValue(':experience_id', $nouvelId, PDO::PARAM_INT);
$stmtPdf->bindValue(':pdf_filename', $pdfFilename, PDO::PARAM_STR);
$stmtPdf->bindValue(':pdf_data', $pdfData, PDO::PARAM_LOB);
$stmtPdf->execute();
}
// Log Discord
sendLog("Ajout de l'expérience : " . $title);
// Redirection vers xp.php ds que la requete est executée
header('Location: ../pages/xp.php');
+21 -14
View File
@@ -3,33 +3,27 @@
// Verification de l'utilisateur voir si ces ids sont bon ou pas
// On démarre une session au cas où le script soit exécuté directement par URL
session_start();
require_once(__DIR__ . '/../functions/security.php');
// Ajout de connexion a la bdd
require_once('../functions/databaseConnection.php');
// Ajout des logs Discord
require_once('../functions/sendLogs.php');
// La requête doit être un POST sinon on l'a refuse
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ./../pages/login.php');
exit;
}
// On récupère les champs envoyé par le post et on les nettoie
$username = trim($_POST['id'] ?? '');
$password = trim($_POST['passwd'] ?? '');
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
// Si les champs sont vides on renvoie vers Login.php : Prévoir dans le futur une erreur
if (empty($username) || empty($password)) {
header('Location: ./../pages/login.php');
exit;
}
// On regarde si l'utilisateur est dans la base de donnée sinon ça ne sert a rien de traiter
try {
$stmt = $dbh->prepare('SELECT id, username, password, role FROM users WHERE username = :username LIMIT 1');
$stmt->execute([':username' => $username]);
$user = $stmt->fetch();
} catch (PDOException $e) {
error_log('Erreur sql dans la requête : ' . $e->getMessage());
header('Location: ./../pages/login.php');
@@ -37,11 +31,18 @@ try {
}
// On regarde si le mdp correspond à celui en bdd
if (!$user || $user['password'] !== $password) {
if (!$user || !password_verify($password, $user['password'])) {
// Log Discord : tentative de connexion échouée (utile pour repérer le bruteforce)
sendLog("Tentative de connexion échouée avec l'identifiant : " . $username);
header('Location: ./../pages/login.php');
exit;
}
// Régénère l'ID de session à la connexion : empêche la fixation de session
// (un attaquant qui aurait imposé un ID de session à la victime avant sa
// connexion ne peut plus récupérer une session authentifiée avec cet ID)
session_regenerate_id(true);
// Si la connexion est ok, on entregistre l'utilisateur dans $_SESSION
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
@@ -55,5 +56,11 @@ try {
error_log('Le serveur WEB ne peut pas modifier la date de dernière connexion : ' . $e->getMessage());
}
// Log Discord : connexion réussie
sendLog("Connexion réussie", $user['username']);
//Redirection vers home.php puisque que l'utilisateur est forcément connecté
header('Location: ./../pages/home.php');
// (?login=1 déclenche la notification "Connexion réussie" sur la page)
header('Location: ./../pages/home.php?login=1');
exit;
?>
+22 -4
View File
@@ -2,13 +2,31 @@
// Lorsque l'utilisateur est renvoyé à cette page/fonction, il est déconnecté
// Démarrage de session (cookie durci)
require_once(__DIR__ . '/../functions/security.php');
// Démarrage de session
session_start();
// Ajout des logs Discord
require_once('../functions/sendLogs.php');
// Log Discord AVANT la destruction de session (sinon on perd le nom d'utilisateur)
sendLog("Déconnexion");
// On sauvegarde l'état du captcha avant destruction
$captchaAlreadyValid = isset($_SESSION['captcha_valid']) && $_SESSION['captcha_valid'] === true;
// Destruction des variables de session
$_SESSION = array();
session_destroy();
// Redirection de l'utilisateur vers home.php
header('Location: ../index.php');
// Si le captcha avait déjà été validé, on recrée une session propre
// pour conserver uniquement cette information
if ($captchaAlreadyValid) {
configureSecureSessionCookies();
session_start();
$_SESSION['captcha_valid'] = true;
}
// Redirection de l'utilisateur vers l'accueil
// (?logout=1 déclenche la notification "Vous avez bien été déconnecté" sur la page)
header('Location: ../pages/home.php?logout=1');
exit;
+87
View File
@@ -0,0 +1,87 @@
<?php
// Export de toutes les données du site en JSON (comme un dbdump mais en JSON)
// Le fichier téléchargé peut ensuite être ré-importé via importData.php
// On démarre la session
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.php');
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
header('Location: ../pages/home.php');
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// Liste blanche des tables et colonnes exportées (empêche toute injection via noms de colonnes)
// Depuis la migration Juillet 2026 (6), les PDF vivent dans des tables associées
// (project_pdfs, experience_pdfs, school_pdfs) plutôt que dans projects/experiences/schools :
// elles sont placées juste après leur table parente (l'ordre compte pour l'import, à cause
// des clés étrangères ON DELETE CASCADE)
$tables = [
'settings' => ['name', 'value'],
'skills' => ['id', 'title', 'icon', 'items', 'position', 'created_at'],
'projects' => ['id', 'title', 'status', 'tags', 'description', 'image_url', 'markdown', 'link_url', 'created_at'],
'project_pdfs' => ['project_id', 'pdf_filename', 'pdf_data'],
'experiences' => ['id', 'title', 'status', 'xptype', 'tags', 'description', 'duration', 'image_url', 'markdown', 'link_url', 'created_at'],
'experience_pdfs' => ['experience_id', 'pdf_filename', 'pdf_data'],
'schools' => ['id', 'title', 'status', 'tags', 'description', 'duration', 'image_url', 'markdown', 'link_url', 'created_at'],
'school_pdfs' => ['school_id', 'pdf_filename', 'pdf_data'],
'users' => ['id', 'username', 'password', 'role', 'created_at', 'last_login'],
];
// Colonnes binaires : le JSON ne peut pas contenir de binaire brut, on les encode en base64
// (+33% de taille mais nécessaire pour que le PDF survive à un export/import complet)
$colonnesBinaires = [
'project_pdfs' => ['pdf_data'],
'experience_pdfs' => ['pdf_data'],
'school_pdfs' => ['pdf_data'],
];
// Métadonnées de l'export (pratique pour s'y retrouver dans les sauvegardes)
$export = [
'_meta' => [
'site' => 'Portfolio Esteban MOUSSU',
'date_export' => date('Y-m-d H:i:s'),
'version' => 1
]
];
// Pour chaque table on récupère toutes les lignes
foreach ($tables as $table => $colonnes) {
try {
// SELECT * puis filtrage par liste blanche : fonctionne même si certaines
// colonnes optionnelles (markdown, link_url, pdf_data...) n'existent pas encore
$stmt = $dbh->query('SELECT * FROM `' . $table . '`');
$lignes = $stmt->fetchAll(PDO::FETCH_ASSOC);
$binaires = $colonnesBinaires[$table] ?? [];
// On ne garde que les colonnes de la liste blanche, et on encode les colonnes binaires
$export[$table] = array_map(function ($ligne) use ($colonnes, $binaires) {
$ligne = array_intersect_key($ligne, array_flip($colonnes));
foreach ($binaires as $colonne) {
if (isset($ligne[$colonne]) && $ligne[$colonne] !== null) {
$ligne[$colonne] = base64_encode($ligne[$colonne]);
}
}
return $ligne;
}, $lignes);
} catch (PDOException $e) {
// Si une table n'existe pas encore on l'exporte vide plutôt que de bloquer l'export
$export[$table] = [];
}
}
// Log Discord
sendLog("Export des données du site en JSON");
// Envoi du fichier JSON en téléchargement
header('Content-Type: application/json; charset=utf-8');
header('Content-Disposition: attachment; filename="portfolio-backup-' . date('Y-m-d-His') . '.json"');
echo json_encode($export, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
exit;
+126
View File
@@ -0,0 +1,126 @@
<?php
// Import d'une sauvegarde JSON (créée par exportData.php)
// ATTENTION : les données actuelles des tables présentes dans le fichier sont ÉCRASÉES
// On démarre la session
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.php');
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
header('Location: ../pages/home.php');
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// La requête doit être un POST avec un fichier sinon on refuse
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !isset($_FILES['jsonfile']) || $_FILES['jsonfile']['error'] !== UPLOAD_ERR_OK) {
header('Location: ../pages/admin.php');
exit;
}
// Lecture et décodage du fichier JSON
$contenu = file_get_contents($_FILES['jsonfile']['tmp_name']);
$donnees = json_decode($contenu, true);
if (!is_array($donnees)) {
die("Erreur : le fichier n'est pas un JSON valide.");
}
// Même liste blanche que l'export : seules ces tables / colonnes peuvent être importées.
// L'ORDRE COMPTE : chaque table de PDF associée (project_pdfs...) doit être importée
// juste après sa table parente (projects...), car sa clé étrangère exige que la ligne
// parente existe déjà (voir migration Juillet 2026 (6))
$tables = [
'settings' => ['name', 'value'],
'skills' => ['id', 'title', 'icon', 'items', 'position', 'created_at'],
'projects' => ['id', 'title', 'status', 'tags', 'description', 'image_url', 'markdown', 'link_url', 'created_at'],
'project_pdfs' => ['project_id', 'pdf_filename', 'pdf_data'],
'experiences' => ['id', 'title', 'status', 'xptype', 'tags', 'description', 'duration', 'image_url', 'markdown', 'link_url', 'created_at'],
'experience_pdfs' => ['experience_id', 'pdf_filename', 'pdf_data'],
'schools' => ['id', 'title', 'status', 'tags', 'description', 'duration', 'image_url', 'markdown', 'link_url', 'created_at'],
'school_pdfs' => ['school_id', 'pdf_filename', 'pdf_data'],
'users' => ['id', 'username', 'password', 'role', 'created_at', 'last_login'],
];
// Colonnes binaires : encodées en base64 par exportData.php, à redécoder ici
$colonnesBinaires = [
'project_pdfs' => ['pdf_data'],
'experience_pdfs' => ['pdf_data'],
'school_pdfs' => ['pdf_data'],
];
try {
// Transaction : si une erreur survient, TOUT est annulé (la base reste intacte)
$dbh->beginTransaction();
foreach ($tables as $table => $colonnesAutorisees) {
// On ne touche qu'aux tables présentes dans le fichier
if (!isset($donnees[$table]) || !is_array($donnees[$table])) {
continue;
}
// Sécurité : on refuse de vider la table users si la sauvegarde n'en contient aucun
// (sinon l'admin se retrouverait enfermé dehors sans aucun compte)
if ($table === 'users' && count($donnees[$table]) === 0) {
continue;
}
// On écrase les données existantes de la table
$dbh->exec('DELETE FROM `' . $table . '`');
// Colonnes binaires (PDF) de cette table, à redécoder du base64
$binaires = $colonnesBinaires[$table] ?? [];
// Puis on insère chaque ligne de la sauvegarde
foreach ($donnees[$table] as $ligne) {
if (!is_array($ligne)) {
continue;
}
// On ne garde que les colonnes autorisées ET présentes dans la ligne
$colonnes = array_values(array_intersect($colonnesAutorisees, array_keys($ligne)));
if (empty($colonnes)) {
continue;
}
$placeholders = array_map(function ($c) { return ':' . $c; }, $colonnes);
$stmt = $dbh->prepare(
'INSERT INTO `' . $table . '` (`' . implode('`, `', $colonnes) . '`) VALUES (' . implode(', ', $placeholders) . ')'
);
foreach ($colonnes as $colonne) {
$valeur = $ligne[$colonne];
// Les colonnes binaires (pdf_data) sont ré-encodées en base64 par l'export : on les redécode
if (in_array($colonne, $binaires, true) && $valeur !== null) {
$stmt->bindValue(':' . $colonne, base64_decode($valeur), PDO::PARAM_LOB);
} else {
$stmt->bindValue(':' . $colonne, $valeur);
}
}
$stmt->execute();
}
}
// Tout s'est bien passé : on valide définitivement
$dbh->commit();
} catch (Exception $e) {
// En cas d'erreur on annule tout : la base revient à son état d'avant l'import
$dbh->rollBack();
die("Erreur pendant l'import, aucune donnée n'a été modifiée : " . $e->getMessage());
}
// Log Discord
sendLog("Import d'une sauvegarde JSON : données du site écrasées");
// On renvoie l'utilisateur sur admin.php (?import=1 déclenche la notification de succès)
header('Location: ../pages/admin.php?import=1');
exit;
+40
View File
@@ -0,0 +1,40 @@
<?php
// On démarre la session
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.php');
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
header('Location: ../pages/home.php');
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
//L'id doit être collecté sinon l'utilisateur ne peux pas executer le reste du script
if (!isset($_GET['id']) || !is_numeric($_GET['id'])) {
header('Location: ../pages/admin.php');
exit;
}
// On collecte l'id et on le met en variable locale
$id = (int) $_GET['id'];
try { // On essaie d'executer une requête qui est préparée
$stmt = $dbh->prepare('DELETE FROM logo_banners WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute(); // Sinon on renvoie l'erreur
} catch (PDOException $e) {
die("Erreur lors de la suppression du logo :" . $e->getMessage());
}
// Log Discord
sendLog("Suppression du logo id " . $id);
// On renvoie l'utilisateur sur admin.php, directement sur la section Logos
header('Location: ../pages/admin.php#admin-logos');
exit;
+9 -1
View File
@@ -1,8 +1,10 @@
<?php
// On démarre la session
session_start();
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.php');
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
@@ -10,6 +12,9 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
//L'id doit être collecté sinon l'utilisateur ne peux pas executer le reste du script
if (!isset($_GET['id']) || !is_numeric($_GET['id'])) {
header('Location: ../pages/admin.php');
@@ -26,6 +31,9 @@ try { // On essaie d'executer une requête qui est préparée
} catch (PDOException $e) {
die("Erreur lors de la suppression du projet :" . $e->getMessage());
}
// Log Discord
sendLog("Suppression du projet id " . $id);
// On renvoie l'utilisateur sur admin.php
header('Location: ../pages/admin.php');
exit;
+40
View File
@@ -0,0 +1,40 @@
<?php
// On démarre la session
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.php');
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
header('Location: ../pages/home.php');
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
//L'id doit être collecté sinon l'utilisateur ne peux pas executer le reste du script
if (!isset($_GET['id']) || !is_numeric($_GET['id'])) {
header('Location: ../pages/admin.php');
exit;
}
// On collecte l'id et on le met en variable locale
$id = (int) $_GET['id'];
try { // On essaie d'executer une requête qui est préparée
$stmt = $dbh->prepare('DELETE FROM schools WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute(); // Sinon on renvoie l'erreur
} catch (PDOException $e) {
die("Erreur lors de la suppression de l'école :" . $e->getMessage());
}
// Log Discord
sendLog("Suppression de l'école id " . $id);
// On renvoie l'utilisateur sur admin.php
header('Location: ../pages/admin.php');
exit;
+40
View File
@@ -0,0 +1,40 @@
<?php
// On démarre la session
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.php');
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
header('Location: ../pages/home.php');
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
//L'id doit être collecté sinon l'utilisateur ne peux pas executer le reste du script
if (!isset($_GET['id']) || !is_numeric($_GET['id'])) {
header('Location: ../pages/admin.php');
exit;
}
// On collecte l'id et on le met en variable locale
$id = (int) $_GET['id'];
try { // On essaie d'executer une requête qui est préparée
$stmt = $dbh->prepare('DELETE FROM skills WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute(); // Sinon on renvoie l'erreur
} catch (PDOException $e) {
die("Erreur lors de la suppression de la compétence :" . $e->getMessage());
}
// Log Discord
sendLog("Suppression de la catégorie de compétences id " . $id);
// On renvoie l'utilisateur sur admin.php
header('Location: ../pages/admin.php');
exit;
+46
View File
@@ -0,0 +1,46 @@
<?php
// Démarrage de la session pour vérifier les droits de l'utilisateur
require_once(__DIR__ . '/../functions/security.php');
// Ajout de connexion à la BDD
require_once("../functions/databaseConnection.php");
// Ajout des logs Discord
require_once("../functions/sendLogs.php");
// Vérification que l'utilisateur est bien admin
if (!isset($_SESSION["role"]) || $_SESSION["role"] != "admin") {
header("Location: ../pages/home.php");
exit();
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// Récupération de l'ID passé en paramètre GET
$id = $_GET["id"];
// Vérification que l'ID n'est pas vide
if ($id == "") {
echo "ID manquant.";
exit();
} else {
try {
// Requête SQL de suppression de l'utilisateur
$sql = "DELETE FROM users WHERE id = :id";
$requete = $dbh->prepare($sql);
$requete->execute([
":id" => $id
]);
// Log Discord
sendLog("Suppression du compte id " . $id);
// Redirection vers la page admin après suppression
header("Location: ../pages/admin.php");
exit();
} catch (PDOException $e) {
// En cas d'erreur PDO, affichage du message
echo "Erreur pendant la suppression : " . $e->getMessage();
exit();
}
}
?>
+10 -1
View File
@@ -1,8 +1,10 @@
<?php
// On démarre la session
session_start();
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.php');
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
@@ -10,6 +12,9 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
//L'id doit être collecté sinon l'utilisateur ne peux pas executer le reste du script
if (!isset($_GET['id']) || !is_numeric($_GET['id'])) {
header('Location: ../pages/admin.php');
@@ -26,6 +31,10 @@ try { // On essaie d'executer une requête qui est préparée
} catch (PDOException $e) {
die("Erreur lors de la suppression de l experience :" . $e->getMessage());
}
// Log Discord
sendLog("Suppression de l'expérience id " . $id);
// On renvoie l'utilisateur sur admin.php
header('Location: ../pages/admin.php');
exit;
+12
View File
@@ -0,0 +1,12 @@
<?php
require_once(__DIR__ . '/../functions/listSettings.php');
// Le captcha peut être désactivé depuis le panel Admin (section Sécurité)
$captchaEnabled = recupererSetting('captcha_enabled') ?? '1';
if ($captchaEnabled === '1' && (!isset($_SESSION['captcha_valid']) || $_SESSION['captcha_valid'] !== true)) {
header('Location: ../captcha.php');
exit;
}
?>
+14 -3
View File
@@ -1,6 +1,16 @@
<?php
// Envoi de message de contact via WebHook discord => Correction de faille de sécurité en cachant le webhook dans un fichier à part
require_once('../../idDiscord.php');
// Ajout des logs Discord
require_once(__DIR__ . '/../functions/security.php');
require_once('../functions/sendLogs.php');
// Envoi de formulaire de contact via WebHook discord
$webhook = "https://discord.com/api/webhooks/1513445938033721399/2NxoIw5TYCH7Rei0tyw1lEswmuYizTkgNPIaPe0gmSqvlXNub7qz_uora3duTUxPg8vm";
$webhook = ID_DISCORD2; // URL du webhook Discord
// EXEMPLE de a quoi ressemble idDiscord :
// define('ID_DISCORD1', 'https://discord.com/api/webhooks/URL_WEBHOOK1');
// define('ID_DISCORD2', 'https://discord.com/api/webhooks/URL_WEBHOOK2');
// Exemple du message :
@@ -49,12 +59,13 @@ if ($subject == "" || $name == "" || $mail == "" || $message == "") {
$resultat = curl_exec($ch);
$codeHttp = curl_getinfo($ch, CURLINFO_HTTP_CODE);
// (pas de curl_close : déprécié depuis PHP 8.5, la ressource se libère toute seule)
if ($codeHttp == 204) { // Si ça renvoi 204 alors le message a été correctement envoyé
curl_close($ch); // Fermer la ressource curl lancée
// Log Discord : message de contact envoyé
sendLog("Nouveau message de contact envoyé par : " . $name);
header("Location: ../pages/contact.php?success=1");
exit();
} else { // Sinon c'est que ça n'a pas été envoyé (discord down / webhook expiré / etcc...)
curl_close($ch); // Fermer la ressource curl lancée
echo "Le message n'a pas pu être envoyé sur Discord.";
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
// On démarre la session
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.php');
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
header('Location: ../pages/home.php');
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
if ( // Si les champs sont vides on renvoie vers admin.php
!isset($_POST['id']) || !is_numeric($_POST['id']) ||
empty($_POST['name']) ||
(empty($_POST['icon']) && empty($_POST['logourl']))
) {
header('Location: ../pages/admin.php');
exit;
}
// On récupère les données pour les mettres en variables locales
$id = (int) $_POST['id'];
$category = ($_POST['category'] ?? '') === 'partner' ? 'partner' : 'tool';
$name = trim($_POST['name']);
$icon = trim($_POST['icon'] ?? '');
$logourl = trim($_POST['logourl'] ?? '');
$linkurl = trim($_POST['linkurl'] ?? '');
$position = (int) ($_POST['position'] ?? 0);
try { // On essaie d'executer une requête qui est préparée
$stmt = $dbh->prepare('
UPDATE logo_banners
SET category = :category, name = :name, icon = :icon, logo_url = :logo_url, link_url = :link_url, position = :position
WHERE id = :id
');
$stmt->bindValue(':category', $category, PDO::PARAM_STR);
$stmt->bindValue(':name', $name, PDO::PARAM_STR);
$stmt->bindValue(':icon', $icon !== '' ? $icon : null, $icon !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':logo_url', $logourl !== '' ? $logourl : null, $logourl !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':link_url', $linkurl, PDO::PARAM_STR);
$stmt->bindValue(':position', $position, PDO::PARAM_INT);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute(); // Sinon on renvoie l'erreur
} catch (PDOException $e) {
die("Erreur lors de la modification du logo :" . $e->getMessage());
}
// Log Discord
sendLog("Modification du logo : " . $name . " (id " . $id . ")");
// On renvoie l'utilisateur sur admin.php, directement sur la section Logos
header('Location: ../pages/admin.php#admin-logos');
exit;
+66 -2
View File
@@ -1,8 +1,11 @@
<?php
// On démarre la session
session_start();
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.php');
require_once('../functions/pdfUpload.php');
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
@@ -10,6 +13,15 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// Le fichier envoyé dépasse post_max_size : $_POST/$_FILES sont vides, on le détecte
// tout de suite (sinon le script redirige silencieusement vers admin.php sans explication)
if (postDepasseLimite()) {
die(messageDepassementPost());
}
if ( // Si les champs sont vides on renvoie vers admin.php
!isset($_POST['id']) || !is_numeric($_POST['id']) ||
empty($_POST['title']) ||
@@ -23,25 +35,77 @@ if ( // Si les champs sont vides on renvoie vers admin.php
// On récupère les données pour les mettres en variables locales
$id = (int) $_POST['id'];
$title = trim($_POST['title']);
$status = trim($_POST['status'] ?? '');
$tags = trim($_POST['tags'] ?? '');
$description = trim($_POST['description']);
$imageurl = trim($_POST['imageurl']);
$markdown = trim($_POST['markdown'] ?? '');
$link_url = trim($_POST['linkurl'] ?? '');
// Lecture et validation du PDF envoyé (optionnel)
$pdfResultat = lireUploadPdf('pdf');
if (is_string($pdfResultat)) {
// Le fichier envoyé n'est pas valide : on affiche l'erreur et on s'arrête
echo $pdfResultat;
exit;
}
// Le PDF n'est mis à jour que si un nouveau fichier est envoyé OU si la case
// "Supprimer ce PDF" est cochée. Sinon on garde le PDF actuel intact.
$updatePdf = false;
$pdfData = null;
$pdfFilename = null;
if (is_array($pdfResultat)) {
$updatePdf = true;
$pdfData = $pdfResultat['data'];
$pdfFilename = $pdfResultat['filename'];
} elseif (isset($_POST['remove_pdf'])) {
$updatePdf = true;
}
try { // On essaie d'executer une requête qui est préparée
// Le PDF (pdf_data) n'est plus stocké dans projects : il vit dans project_pdfs (table
// associée, voir migration Juillet 2026 (6)), géré à part juste après
$stmt = $dbh->prepare('
UPDATE projects
SET title = :title, tags = :tags, description = :description, image_url = :image_url
SET title = :title, status = :status, tags = :tags, description = :description, image_url = :image_url, markdown = :markdown, link_url = :link_url
WHERE id = :id
');
$stmt->bindValue(':title', $title, PDO::PARAM_STR);
$stmt->bindValue(':status', $status, PDO::PARAM_STR);
$stmt->bindValue(':tags', $tags, PDO::PARAM_STR);
$stmt->bindValue(':description', $description, PDO::PARAM_STR);
$stmt->bindValue(':image_url', $imageurl, PDO::PARAM_STR);
$stmt->bindValue(':markdown', $markdown, PDO::PARAM_STR);
$stmt->bindValue(':link_url', $link_url, PDO::PARAM_STR);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute(); // Sinon on renvoie l'erreur
if ($updatePdf) {
if ($pdfData !== null) {
// Nouveau PDF envoyé : on remplace l'éventuel PDF existant (clé primaire = project_id)
$stmtPdf = $dbh->prepare('
INSERT INTO project_pdfs (project_id, pdf_filename, pdf_data) VALUES (:id, :pdf_filename, :pdf_data)
ON DUPLICATE KEY UPDATE pdf_filename = VALUES(pdf_filename), pdf_data = VALUES(pdf_data)
');
$stmtPdf->bindValue(':id', $id, PDO::PARAM_INT);
$stmtPdf->bindValue(':pdf_filename', $pdfFilename, PDO::PARAM_STR);
$stmtPdf->bindValue(':pdf_data', $pdfData, PDO::PARAM_LOB);
$stmtPdf->execute();
} else {
// Case "Supprimer ce PDF" cochée, sans nouveau fichier envoyé
$stmtPdf = $dbh->prepare('DELETE FROM project_pdfs WHERE project_id = :id');
$stmtPdf->bindValue(':id', $id, PDO::PARAM_INT);
$stmtPdf->execute();
}
}
} catch (PDOException $e) {
die("Erreur lors de la modification du projet :" . $e->getMessage());
}
// Log Discord
sendLog("Modification du projet : " . $title . " (id " . $id . ")");
// On renvoie l'utilisateur sur admin.php
header('Location: ../pages/admin.php');
exit;
+113
View File
@@ -0,0 +1,113 @@
<?php
// On démarre la session
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.php');
require_once('../functions/pdfUpload.php');
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
header('Location: ../pages/home.php');
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// Le fichier envoyé dépasse post_max_size : $_POST/$_FILES sont vides, on le détecte
// tout de suite (sinon le script redirige silencieusement vers admin.php sans explication)
if (postDepasseLimite()) {
die(messageDepassementPost());
}
if ( // Si les champs sont vides on renvoie vers admin.php
!isset($_POST['id']) || !is_numeric($_POST['id']) ||
empty($_POST['title']) ||
empty($_POST['description']) ||
empty($_POST['duration']) ||
empty($_POST['imageurl'])
) {
header('Location: ../pages/admin.php');
exit;
}
// On récupère les données pour les mettres en variables locales
$id = (int) $_POST['id'];
$title = trim($_POST['title']);
$status = trim($_POST['status'] ?? '');
$tags = trim($_POST['tags'] ?? '');
$description = trim($_POST['description']);
$duration = trim($_POST['duration']);
$imageurl = trim($_POST['imageurl']);
$markdown = trim($_POST['markdown'] ?? '');
$link_url = trim($_POST['linkurl'] ?? '');
// Lecture et validation du PDF envoyé (optionnel)
$pdfResultat = lireUploadPdf('pdf');
if (is_string($pdfResultat)) {
echo $pdfResultat;
exit;
}
// Le PDF n'est mis à jour que si un nouveau fichier est envoyé OU si la case
// "Supprimer ce PDF" est cochée. Sinon on garde le PDF actuel intact.
$updatePdf = false;
$pdfData = null;
$pdfFilename = null;
if (is_array($pdfResultat)) {
$updatePdf = true;
$pdfData = $pdfResultat['data'];
$pdfFilename = $pdfResultat['filename'];
} elseif (isset($_POST['remove_pdf'])) {
$updatePdf = true;
}
try { // On essaie d'executer une requête qui est préparée
// Le PDF (pdf_data) n'est plus stocké dans schools : il vit dans school_pdfs (table
// associée, voir migration Juillet 2026 (6)), géré à part juste après
$stmt = $dbh->prepare('
UPDATE schools
SET title = :title, status = :status, tags = :tags, description = :description, duration = :duration, image_url = :image_url, markdown = :markdown, link_url = :link_url
WHERE id = :id
');
$stmt->bindValue(':title', $title, PDO::PARAM_STR);
$stmt->bindValue(':status', $status, PDO::PARAM_STR);
$stmt->bindValue(':tags', $tags, PDO::PARAM_STR);
$stmt->bindValue(':description', $description, PDO::PARAM_STR);
$stmt->bindValue(':duration', $duration, PDO::PARAM_STR);
$stmt->bindValue(':image_url', $imageurl, PDO::PARAM_STR);
$stmt->bindValue(':markdown', $markdown, PDO::PARAM_STR);
$stmt->bindValue(':link_url', $link_url, PDO::PARAM_STR);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute(); // Sinon on renvoie l'erreur
if ($updatePdf) {
if ($pdfData !== null) {
// Nouveau PDF envoyé : on remplace l'éventuel PDF existant (clé primaire = school_id)
$stmtPdf = $dbh->prepare('
INSERT INTO school_pdfs (school_id, pdf_filename, pdf_data) VALUES (:id, :pdf_filename, :pdf_data)
ON DUPLICATE KEY UPDATE pdf_filename = VALUES(pdf_filename), pdf_data = VALUES(pdf_data)
');
$stmtPdf->bindValue(':id', $id, PDO::PARAM_INT);
$stmtPdf->bindValue(':pdf_filename', $pdfFilename, PDO::PARAM_STR);
$stmtPdf->bindValue(':pdf_data', $pdfData, PDO::PARAM_LOB);
$stmtPdf->execute();
} else {
// Case "Supprimer ce PDF" cochée, sans nouveau fichier envoyé
$stmtPdf = $dbh->prepare('DELETE FROM school_pdfs WHERE school_id = :id');
$stmtPdf->bindValue(':id', $id, PDO::PARAM_INT);
$stmtPdf->execute();
}
}
} catch (PDOException $e) {
die("Erreur lors de la modification de l'école :" . $e->getMessage());
}
// Log Discord
sendLog("Modification de l'école : " . $title . " (id " . $id . ")");
// On renvoie l'utilisateur sur admin.php
header('Location: ../pages/admin.php');
exit;
+74
View File
@@ -0,0 +1,74 @@
<?php
// On démarre la session
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.php');
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
header('Location: ../pages/home.php');
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// La requête doit être un POST sinon on l'a refuse
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../pages/admin.php');
exit;
}
// On récupère les données pour les mettres en variables locales
// La checkbox n'est envoyée que si elle est cochée
// settings_group : évite qu'un formulaire écrase les réglages d'un autre (ex : la case
// "notice_enabled" non cochée ne doit pas désactiver le captcha, et inversement)
$groupeReglages = $_POST['settings_group'] ?? 'notice';
if ($groupeReglages === 'captcha') {
$reglages = [
'captcha_enabled' => isset($_POST['captcha_enabled']) ? '1' : '0'
];
} elseif ($groupeReglages === 'contact') {
$reglages = [
'contact_email' => trim($_POST['contact_email'] ?? ''),
'contact_email_enabled' => isset($_POST['contact_email_enabled']) ? '1' : '0',
'contact_address_line1' => trim($_POST['contact_address_line1'] ?? ''),
'contact_address_line2' => trim($_POST['contact_address_line2'] ?? ''),
'contact_address_enabled' => isset($_POST['contact_address_enabled']) ? '1' : '0'
];
} else {
$reglages = [
'site_notice_title' => trim($_POST['notice_title'] ?? ''),
'site_notice_text' => trim($_POST['notice_text'] ?? ''),
'site_notice_enabled' => isset($_POST['notice_enabled']) ? '1' : '0'
];
}
try { // On essaie d'executer une requête qui est préparée pour chaque réglage
// ON DUPLICATE KEY UPDATE : crée le réglage s'il n'existe pas encore, le met à jour sinon
$stmt = $dbh->prepare('INSERT INTO settings (name, value) VALUES (:name, :value) ON DUPLICATE KEY UPDATE value = VALUES(value)');
foreach ($reglages as $name => $value) {
$stmt->execute([
':name' => $name,
':value' => $value
]);
}
} catch (PDOException $e) {
die("Erreur lors de la modification de la notification :" . $e->getMessage());
}
// Log Discord
$libellesGroupes = [
'captcha' => 'Modification du réglage du captcha',
'contact' => 'Modification des coordonnées de contact',
'notice' => "Modification de la notification de la page d'accueil"
];
sendLog($libellesGroupes[$groupeReglages] ?? "Modification des réglages du site");
// On renvoie l'utilisateur sur admin.php
header('Location: ../pages/admin.php');
exit;
+55
View File
@@ -0,0 +1,55 @@
<?php
// On démarre la session
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.php');
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
header('Location: ../pages/home.php');
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
if ( // Si les champs sont vides on renvoie vers admin.php
!isset($_POST['id']) || !is_numeric($_POST['id']) ||
empty($_POST['title']) ||
empty($_POST['items'])
) {
header('Location: ../pages/admin.php');
exit;
}
// On récupère les données pour les mettres en variables locales
$id = (int) $_POST['id'];
$title = trim($_POST['title']);
$icon = trim($_POST['icon'] ?? '');
$items = trim($_POST['items']);
$position = (int) ($_POST['position'] ?? 0);
try { // On essaie d'executer une requête qui est préparée
$stmt = $dbh->prepare('
UPDATE skills
SET title = :title, icon = :icon, items = :items, position = :position
WHERE id = :id
');
$stmt->bindValue(':title', $title, PDO::PARAM_STR);
$stmt->bindValue(':icon', $icon, PDO::PARAM_STR);
$stmt->bindValue(':items', $items, PDO::PARAM_STR);
$stmt->bindValue(':position', $position, PDO::PARAM_INT);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute(); // Sinon on renvoie l'erreur
} catch (PDOException $e) {
die("Erreur lors de la modification de la compétence :" . $e->getMessage());
}
// Log Discord
sendLog("Modification de la catégorie de compétences : " . $title . " (id " . $id . ")");
// On renvoie l'utilisateur sur admin.php
header('Location: ../pages/admin.php');
exit;
+65 -2
View File
@@ -1,8 +1,11 @@
<?php
// On démarre la session
session_start();
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.php');
require_once('../functions/pdfUpload.php');
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
@@ -10,6 +13,15 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// Le fichier envoyé dépasse post_max_size : $_POST/$_FILES sont vides, on le détecte
// tout de suite (sinon le script redirige silencieusement vers admin.php sans explication)
if (postDepasseLimite()) {
die(messageDepassementPost());
}
// Si le formulaire à un champ vide
if (
!isset($_POST['id']) || !is_numeric($_POST['id']) ||
@@ -26,29 +38,80 @@ if (
//On récupère les données pour les mettres en variables locales
$id = (int) $_POST['id'];
$title = trim($_POST['title']);
$status = trim($_POST['status'] ?? '');
$xptype = trim($_POST['xptype']);
$tags = trim($_POST['tags'] ?? '');
$description = trim($_POST['description']);
$duration = trim($_POST['duration']);
$imageurl = trim($_POST['imageurl']);
$markdown = trim($_POST['markdown'] ?? '');
$link_url = trim($_POST['linkurl'] ?? '');
// Lecture et validation du PDF envoyé (optionnel)
$pdfResultat = lireUploadPdf('pdf');
if (is_string($pdfResultat)) {
echo $pdfResultat;
exit;
}
// Le PDF n'est mis à jour que si un nouveau fichier est envoyé OU si la case
// "Supprimer ce PDF" est cochée. Sinon on garde le PDF actuel intact.
$updatePdf = false;
$pdfData = null;
$pdfFilename = null;
if (is_array($pdfResultat)) {
$updatePdf = true;
$pdfData = $pdfResultat['data'];
$pdfFilename = $pdfResultat['filename'];
} elseif (isset($_POST['remove_pdf'])) {
$updatePdf = true;
}
try { // On essaie d'executer une requête qui est préparée
// Le PDF (pdf_data) n'est plus stocké dans experiences : il vit dans experience_pdfs
// (table associée, voir migration Juillet 2026 (6)), géré à part juste après
$stmt = $dbh->prepare('
UPDATE experiences
SET title = :title, xptype = :xptype, tags = :tags, description = :description, duration = :duration, image_url = :image_url
SET title = :title, status = :status, xptype = :xptype, tags = :tags, description = :description, duration = :duration, image_url = :image_url, markdown = :markdown, link_url = :link_url
WHERE id = :id
');
$stmt->bindValue(':title', $title, PDO::PARAM_STR);
$stmt->bindValue(':status', $status, PDO::PARAM_STR);
$stmt->bindValue(':xptype', $xptype, PDO::PARAM_STR);
$stmt->bindValue(':tags', $tags, PDO::PARAM_STR);
$stmt->bindValue(':description', $description, PDO::PARAM_STR);
$stmt->bindValue(':duration', $duration, PDO::PARAM_STR);
$stmt->bindValue(':image_url', $imageurl, PDO::PARAM_STR);
$stmt->bindValue(':markdown', $markdown, PDO::PARAM_STR);
$stmt->bindValue(':link_url', $link_url, PDO::PARAM_STR);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute(); // Sinon on renvoie l'erreur
if ($updatePdf) {
if ($pdfData !== null) {
// Nouveau PDF envoyé : on remplace l'éventuel PDF existant (clé primaire = experience_id)
$stmtPdf = $dbh->prepare('
INSERT INTO experience_pdfs (experience_id, pdf_filename, pdf_data) VALUES (:id, :pdf_filename, :pdf_data)
ON DUPLICATE KEY UPDATE pdf_filename = VALUES(pdf_filename), pdf_data = VALUES(pdf_data)
');
$stmtPdf->bindValue(':id', $id, PDO::PARAM_INT);
$stmtPdf->bindValue(':pdf_filename', $pdfFilename, PDO::PARAM_STR);
$stmtPdf->bindValue(':pdf_data', $pdfData, PDO::PARAM_LOB);
$stmtPdf->execute();
} else {
// Case "Supprimer ce PDF" cochée, sans nouveau fichier envoyé
$stmtPdf = $dbh->prepare('DELETE FROM experience_pdfs WHERE experience_id = :id');
$stmtPdf->bindValue(':id', $id, PDO::PARAM_INT);
$stmtPdf->execute();
}
}
} catch (PDOException $e) {
die("Erreur lors de la modification de l experience :" . $e->getMessage());
}
// Log Discord
sendLog("Modification de l'expérience : " . $title . " (id " . $id . ")");
// On renvoie l'utilisateur sur admin.php
header('Location: ../pages/admin.php');
exit;
+50
View File
@@ -0,0 +1,50 @@
<?php
// Sert le PDF stocké en base pour un projet / une expérience / une école
// Utilisé en <iframe> par la page de détails (mini lecteur) et pour le téléchargement direct
require_once('../functions/databaseConnection.php');
// Liste blanche des types autorisés : empêche toute injection de nom de table
// (le PDF est stocké dans une table associée depuis la migration Juillet 2026 (6),
// pour ne plus jamais charger son contenu binaire lors des listes de projets/xp/écoles)
$tables = [
'projet' => ['pdf' => 'project_pdfs', 'colonne' => 'project_id'],
'experience' => ['pdf' => 'experience_pdfs', 'colonne' => 'experience_id'],
'ecole' => ['pdf' => 'school_pdfs', 'colonne' => 'school_id'],
];
$type = $_GET['type'] ?? '';
$id = $_GET['id'] ?? '';
if (!isset($tables[$type]) || !is_numeric($id)) {
http_response_code(404);
exit;
}
try {
$stmt = $dbh->prepare('SELECT pdf_data, pdf_filename FROM `' . $tables[$type]['pdf'] . '` WHERE `' . $tables[$type]['colonne'] . '` = :id');
$stmt->bindValue(':id', (int) $id, PDO::PARAM_INT);
$stmt->execute();
$row = $stmt->fetch(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
http_response_code(404);
exit;
}
if (!$row || $row['pdf_data'] === null) {
http_response_code(404);
exit;
}
$filename = !empty($row['pdf_filename']) ? $row['pdf_filename'] : 'document.pdf';
// ?download=1 force le téléchargement, sinon le PDF s'affiche dans le mini lecteur (iframe)
$disposition = (isset($_GET['download']) && $_GET['download'] === '1') ? 'attachment' : 'inline';
header('Content-Type: application/pdf');
header('Content-Disposition: ' . $disposition . '; filename="' . basename($filename) . '"');
header('Content-Length: ' . strlen($row['pdf_data']));
header('Cache-Control: private, max-age=3600');
echo $row['pdf_data'];
exit;
+677
View File
@@ -0,0 +1,677 @@
body {
background:
radial-gradient(circle at top, rgba(255, 122, 26, 0.16), transparent 35%),
#0a0a10;
margin: 0;
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Arial, sans-serif;
color: white;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
box-sizing: border-box;
}
.captcha-shell {
width: 100%;
max-width: 1100px;
}
.captcha-card,
.success-screen {
background: linear-gradient(180deg, #111116 0%, #161622 100%);
border: 1px solid #262638;
border-radius: 24px;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28);
padding: 30px;
animation: cardEnter 0.5s ease;
}
@keyframes cardEnter {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.captcha-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-radius: 999px;
background: rgba(255, 122, 26, 0.14);
border: 1px solid rgba(255, 122, 26, 0.3);
color: #ffa155;
font-size: 13px;
font-weight: bold;
margin-bottom: 18px;
}
.captcha-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 24px;
margin-bottom: 18px;
}
.captcha-title {
margin: 0 0 10px;
font-size: 34px;
font-weight: 800;
}
.captcha-subtitle {
margin: 0;
color: #c1c1cf;
font-size: 16px;
line-height: 1.7;
max-width: 700px;
}
.captcha-subtitle strong {
color: #ffa155;
}
.captcha-target {
min-width: 240px;
background-color: #1a1a24;
border: 1px solid #262638;
border-radius: 18px;
padding: 18px;
}
.captcha-target span {
display: block;
color: #8f8fa8;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 8px;
}
.captcha-target strong {
font-size: 22px;
color: #ffa155;
}
.captcha-stats {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.captcha-attempts {
display: flex;
align-items: center;
gap: 14px;
background-color: #171b28;
border: 1px solid #283049;
border-radius: 14px;
padding: 12px 16px;
}
.captcha-attempts span {
display: block;
color: #99a0bc;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 6px;
}
.captcha-attempts strong {
font-size: 22px;
color: #ffffff;
}
/* Pastilles visuelles représentant les essais restants */
.attempts-dots {
display: flex;
gap: 6px;
}
.attempts-dots span {
width: 10px;
height: 10px;
border-radius: 50%;
transition: background-color 0.3s ease, transform 0.3s ease;
}
.attempts-dots .dot-filled {
background: linear-gradient(180deg, #ff7a1a 0%, #e8630a 100%);
box-shadow: 0 0 8px rgba(255, 122, 26, 0.5);
}
.attempts-dots .dot-empty {
background-color: #2a2f42;
}
.captcha-reset,
.captcha-secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 18px;
border-radius: 12px;
text-decoration: none;
color: white;
background-color: #2a2a3a;
border: 1px solid #3a3a4e;
transition: transform 0.2s ease, background-color 0.2s ease, border-color 0.2s ease;
}
.captcha-reset:hover,
.captcha-secondary-button:hover {
background-color: #34344a;
border-color: rgba(255, 122, 26, 0.4);
transform: translateY(-1px);
}
.captcha-error {
margin-bottom: 18px;
background-color: rgba(161, 44, 47, 0.15);
border: 1px solid rgba(255, 84, 84, 0.45);
border-radius: 12px;
color: #ffb8bb;
padding: 14px 16px;
font-size: 14px;
animation: errorShake 0.4s ease;
}
@keyframes errorShake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-6px); }
40% { transform: translateX(5px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(3px); }
}
.mailbox {
background-color: #0f1118;
border: 1px solid #22263a;
border-radius: 20px;
overflow: hidden;
}
.mailbox-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 20px;
background-color: #141825;
border-bottom: 1px solid #22263a;
}
.mailbox-dots {
display: flex;
gap: 8px;
}
.mailbox-dots span {
width: 10px;
height: 10px;
border-radius: 50%;
display: block;
}
.mailbox-dots span:nth-child(1) {
background-color: #ff5f57;
}
.mailbox-dots span:nth-child(2) {
background-color: #febc2e;
}
.mailbox-dots span:nth-child(3) {
background-color: #28c840;
}
.mailbox-label {
color: #d6daf0;
font-size: 14px;
font-weight: bold;
}
.mail-list {
display: flex;
flex-direction: column;
}
.mail-option {
position: relative;
opacity: 0;
animation: mailEnter 0.4s ease forwards;
animation-delay: calc(var(--mail-index, 0) * 0.04s);
}
@keyframes mailEnter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.mail-option-disabled {
pointer-events: none;
opacity: 0.5;
}
.mail-option input {
position: absolute;
opacity: 0;
pointer-events: none;
}
/* Anneau de focus visible au clavier (accessibilité) sur la ligne associée à la checkbox */
.mail-option input:focus-visible + .mail-row {
outline: 2px solid #ff7a1a;
outline-offset: -2px;
}
.mail-row {
display: grid;
grid-template-columns: 26px 48px minmax(120px, 220px) 1fr 70px;
gap: 14px;
align-items: center;
padding: 16px 18px;
border-bottom: 1px solid #1d2234;
cursor: pointer;
transition:
background-color 0.2s ease,
border-color 0.2s ease,
transform 0.2s ease,
box-shadow 0.2s ease;
}
.mail-row:hover {
background-color: #161b2a;
transform: translateX(2px);
}
.mail-option input:checked + .mail-row {
background-color: rgba(255, 122, 26, 0.12);
box-shadow: inset 0 0 0 1px rgba(255, 122, 26, 0.35);
}
/* Case à cocher personnalisée */
.mail-check {
width: 22px;
height: 22px;
border-radius: 6px;
background-color: #1a1a24;
border: 1px solid #3a3a4e;
display: flex;
align-items: center;
justify-content: center;
color: transparent;
font-size: 12px;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.15s ease, transform 0.15s ease;
}
.mail-option input:checked + .mail-row .mail-check {
background: linear-gradient(180deg, #ff7a1a 0%, #e8630a 100%);
border-color: #ff7a1a;
color: white;
transform: scale(1.08);
}
.mail-avatar {
width: 42px;
height: 42px;
border-radius: 50%;
background: linear-gradient(180deg, #2a2a3a 0%, #1a1a24 100%);
border: 1px solid #303040;
display: flex;
align-items: center;
justify-content: center;
color: #d7dcf2;
font-weight: bold;
font-size: 15px;
}
.mail-sender {
font-weight: bold;
color: white;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mail-content {
min-width: 0;
}
.mail-subject {
color: #f3f4fb;
font-size: 15px;
margin-bottom: 4px;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mail-preview {
color: #b5b8ca;
font-size: 13px;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mail-time {
color: #8f8fa8;
font-size: 13px;
text-align: right;
}
/* Champ piège (honeypot) : invisible pour un humain, exclu du tabulateur,
mais toujours présent dans le HTML pour les robots qui remplissent tout */
.captcha-honeypot {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.captcha-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-top: 24px;
}
.captcha-note {
color: #b7bbcd;
font-size: 14px;
line-height: 1.6;
max-width: 720px;
}
.captcha-actions {
display: flex;
gap: 12px;
}
.captcha-submit {
background: linear-gradient(180deg, #ff8c3a 0%, #e8630a 100%);
color: white;
border: none;
border-radius: 12px;
padding: 14px 24px;
font-size: 15px;
font-weight: bold;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 12px 24px rgba(232, 99, 10, 0.32);
}
.captcha-submit:hover {
transform: translateY(-1px) scale(1.01);
box-shadow: 0 16px 30px rgba(232, 99, 10, 0.4);
}
.captcha-submit:active {
transform: translateY(1px) scale(0.99);
}
.success-mode {
overflow: hidden;
}
.success-screen {
text-align: center;
position: relative;
overflow: hidden;
animation: successPop 0.6s ease;
}
.success-icon {
font-size: 68px;
margin-bottom: 14px;
animation: bounceIn 0.9s ease;
}
.success-screen h1 {
margin: 0 0 12px;
font-size: 40px;
}
.success-screen p {
margin: 0 auto 12px;
max-width: 520px;
color: #ced2e6;
font-size: 18px;
}
/* Bloc pédagogique listant les indices de phishing repérés */
.success-reasons {
max-width: 460px;
margin: 18px auto 26px;
padding: 18px 22px;
text-align: left;
background: rgba(255, 122, 26, 0.08);
border: 1px solid rgba(255, 122, 26, 0.25);
border-radius: 16px;
animation: cardEnter 0.5s ease 0.3s both;
}
.success-reasons-title {
margin: 0 0 10px;
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #ffa155;
}
.success-reasons ul {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
.success-reasons li {
display: flex;
align-items: flex-start;
gap: 10px;
color: #d8d9e6;
font-size: 14.5px;
line-height: 1.5;
}
.reason-dot {
flex-shrink: 0;
width: 7px;
height: 7px;
margin-top: 6px;
border-radius: 50%;
background: #ff8c3a;
}
.success-loader {
width: 240px;
height: 10px;
margin: 0 auto;
background: #23283d;
border-radius: 999px;
overflow: hidden;
border: 1px solid #323a5b;
}
.success-loader span {
display: block;
height: 100%;
width: 0;
background: linear-gradient(90deg, #ffa155 0%, #ff7a1a 100%);
animation: loadingBar 2.4s linear forwards;
}
.success-burst {
position: absolute;
inset: 0;
pointer-events: none;
background:
radial-gradient(circle at 20% 30%, rgba(255, 197, 92, 0.25), transparent 18%),
radial-gradient(circle at 80% 20%, rgba(98, 210, 111, 0.2), transparent 18%),
radial-gradient(circle at 30% 80%, rgba(255, 122, 26, 0.22), transparent 20%),
radial-gradient(circle at 75% 70%, rgba(255, 145, 77, 0.18), transparent 18%);
animation: burstPulse 1s ease-in-out infinite alternate;
}
@keyframes successPop {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes bounceIn {
0% {
opacity: 0;
transform: scale(0.3) translateY(20px);
}
60% {
opacity: 1;
transform: scale(1.08) translateY(-6px);
}
100% {
transform: scale(1) translateY(0);
}
}
@keyframes loadingBar {
from {
width: 0;
}
to {
width: 100%;
}
}
@keyframes burstPulse {
from {
opacity: 0.7;
transform: scale(1);
}
to {
opacity: 1;
transform: scale(1.03);
}
}
@media (prefers-reduced-motion: reduce) {
.captcha-card,
.success-screen,
.mail-option,
.success-reasons,
.success-icon,
.captcha-error {
animation: none;
opacity: 1;
}
.success-burst {
animation: none;
}
}
@media (max-width: 900px) {
.captcha-top {
flex-direction: column;
}
.captcha-target {
width: 100%;
min-width: 0;
}
.mail-row {
grid-template-columns: 22px 42px 1fr 56px;
}
.mail-sender {
display: none;
}
.captcha-footer {
flex-direction: column;
align-items: stretch;
}
.captcha-actions {
width: 100%;
}
.captcha-submit,
.captcha-secondary-button {
width: 100%;
}
}
@media (max-width: 640px) {
body {
padding: 14px;
}
.captcha-card,
.success-screen {
padding: 20px;
border-radius: 18px;
}
.captcha-title {
font-size: 28px;
}
.mailbox-toolbar {
padding: 14px 16px;
}
.mail-row {
grid-template-columns: 20px 36px 1fr;
gap: 10px;
padding: 14px 14px;
}
.mail-time {
display: none;
}
.success-screen h1 {
font-size: 30px;
}
.success-screen p {
font-size: 16px;
}
.success-loader {
width: 100%;
}
}
+2093 -174
View File
File diff suppressed because it is too large Load Diff