Compare commits

..
12 Commits
Author SHA1 Message Date
Esteban 4f29ee15f1 Ajout tableau 2026-09-03 01:24:22 +02:00
Esteban a98dd4f58e Utilisation du CDN 2026-08-28 00:11:22 +02:00
Esteban 08875d3b5a Ajout mode présentation 2026-08-28 00:04:48 +02:00
Esteban 4862d61ccd Patch img home page 2026-08-27 22:41:14 +02:00
Esteban 5057a7be2f Patch gitignore 2026-08-08 19:36:29 +02:00
Esteban 9dfa10f0d7 Remove html file 2026-07-11 04:59:37 +02:00
Esteban 6fc5d01d30 Changement d'adresse dynamique 2026-07-09 14:40:26 +02:00
Esteban 0287b66f89 Suppression easteregg + fix captcha 2026-07-09 11:54:37 +02:00
Esteban 128386578e Ajout d'un easteregg 2026-07-03 10:56:54 +02:00
Esteban 388e282cd4 Patch php 2026-07-03 02:05:18 +02:00
Esteban 85dba4341e Fix lag 2026-07-03 02:02:13 +02:00
Esteban 6c91a3344b Sauvegarde 2026-07-03 00:23:37 +02:00
22 changed files with 1859 additions and 226 deletions

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@
# Éditeurs et outils locaux
.vscode/
.idea/
.claude/
.nexus/
*.iml
# Logs
+101 -77
View File
@@ -4,6 +4,9 @@ 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');
// Versionnement des fichiers statiques (casse le cache navigateur à chaque modif du CSS)
require_once(__DIR__ . '/functions/assets.php');
// Recharger une nouvelle série si demandé
if (isset($_GET['reload']) && $_GET['reload'] === '1') {
unset($_SESSION['captcha_round']);
@@ -403,33 +406,44 @@ $attemptsLeft = $round['attempts_left'] ?? 0;
<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">
<!-- Favicon servie par le CDN (voir elements/header.php) -->
<link rel="icon" type="image/png" href="https://cdn.eldorianet.work/i/c2cc96aa-1560-4062-ba4d-b455098af627.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">
<link rel="stylesheet" href="<?php echo htmlspecialchars(urlAsset('styles/captcha.css', './')); ?>">
</head>
<body class="<?php echo $success ? 'success-mode' : ''; ?>">
<main class="captcha-shell">
<?php if ($success): ?>
<?php
// Deux écrans possibles : la réussite (avec redirection automatique), ou le test lui-même.
if ($success) {
// --- Écran de réussite ---
echo <<<'HTML'
<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>
HTML;
<?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; ?>
// Rappel de ce que le visiteur a correctement identifié, s'il y a quelque chose à dire
if (!empty($foundReasons)) {
echo '<div class="success-reasons">';
echo '<p class="success-reasons-title">Ce que tu as repéré :</p>';
echo '<ul>';
foreach ($foundReasons as $reason) {
echo '<li><i class="reason-dot"></i>' . htmlspecialchars($reason, ENT_QUOTES, 'UTF-8') . '</li>';
}
echo '</ul>';
echo '</div>';
}
echo <<<'HTML'
<div class="success-loader">
<span></span>
</div>
@@ -440,7 +454,12 @@ $attemptsLeft = $round['attempts_left'] ?? 0;
window.location.href = './index.php';
}, 2400);
</script>
<?php else: ?>
HTML;
} else {
// --- Écran du test ---
echo <<<'HTML'
<form class="captcha-card" method="post" action="">
<div class="captcha-badge">🛡️ Test anti-robot</div>
@@ -462,23 +481,35 @@ $attemptsLeft = $round['attempts_left'] ?? 0;
<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>
HTML;
<?php if ($locked): ?>
<a class="captcha-reset" href="./captcha.php?reload=1">Recharger une nouvelle série</a>
<?php endif; ?>
</div>
// Compteur d'essais restants
echo '<strong>' . htmlspecialchars((string) $attemptsLeft, ENT_QUOTES, 'UTF-8')
. ' / ' . htmlspecialchars((string) $maxAttempts, ENT_QUOTES, 'UTF-8') . '</strong>';
<?php if ($error !== ''): ?>
<div class="captcha-error"><?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?></div>
<?php endif; ?>
// Une pastille par essai : pleine tant qu'il reste des essais, vide ensuite
echo '<div class="attempts-dots">';
for ($i = 0; $i < $maxAttempts; $i++) {
echo '<span class="' . ($i < $attemptsLeft ? 'dot-filled' : 'dot-empty') . '"></span>';
}
echo '</div>';
echo '</div>';
// Plus d'essais disponibles : on propose une nouvelle série de mails
if ($locked) {
echo '<a class="captcha-reset" href="./captcha.php?reload=1">Recharger une nouvelle série</a>';
}
echo '</div>';
// Message d'erreur du dernier essai raté
if ($error !== '') {
echo '<div class="captcha-error">' . htmlspecialchars($error, ENT_QUOTES, 'UTF-8') . '</div>';
}
echo <<<'HTML'
<div class="mailbox">
<div class="mailbox-toolbar">
<div class="mailbox-dots">
@@ -490,51 +521,39 @@ $attemptsLeft = $round['attempts_left'] ?? 0;
</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>
HTML;
<span class="mail-avatar">
<?php echo htmlspecialchars(firstLetterSafe($mail['sender']), ENT_QUOTES, 'UTF-8'); ?>
</span>
// Un bloc "mail" cliquable par message de la série tirée au sort
foreach ($round['emails'] as $index => $mail) {
$desactive = $locked ? 'disabled' : '';
$classeOption = $locked ? 'mail-option mail-option-disabled' : 'mail-option ';
<span class="mail-sender">
<?php echo htmlspecialchars($mail['sender'], ENT_QUOTES, 'UTF-8'); ?>
</span>
echo '<label class="' . $classeOption . '" style="--mail-index: ' . (int) $index . '">';
echo '<input type="checkbox" name="selected_mails[]" value="'
. htmlspecialchars($mail['id'], ENT_QUOTES, 'UTF-8') . '" ' . $desactive . '>';
echo '<span class="mail-row">';
echo '<span class="mail-check"><i class="fa-solid fa-check"></i></span>';
echo '<span class="mail-avatar">' . htmlspecialchars(firstLetterSafe($mail['sender']), ENT_QUOTES, 'UTF-8') . '</span>';
echo '<span class="mail-sender">' . htmlspecialchars($mail['sender'], ENT_QUOTES, 'UTF-8') . '</span>';
echo '<span class="mail-content">';
echo '<span class="mail-subject">' . htmlspecialchars($mail['subject'], ENT_QUOTES, 'UTF-8') . '</span>';
echo '<span class="mail-preview">' . htmlspecialchars($mail['preview'], ENT_QUOTES, 'UTF-8') . '</span>';
echo '</span>';
echo '<span class="mail-time">' . htmlspecialchars($mail['time'], ENT_QUOTES, 'UTF-8') . '</span>';
echo '</span>';
echo '</label>';
}
<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>
echo '</div>';
echo '</div>';
<span class="mail-time">
<?php echo htmlspecialchars($mail['time'], ENT_QUOTES, 'UTF-8'); ?>
</span>
</span>
</label>
<?php endforeach; ?>
</div>
</div>
// Jeton de la série en cours : permet de vérifier les réponses côté serveur
echo '<input type="hidden" name="captcha_token" value="'
. htmlspecialchars($round['token'], ENT_QUOTES, 'UTF-8') . '">';
<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 -->
// Champ piège (honeypot) : invisible et exclu du tabulateur pour un humain,
// mais souvent rempli automatiquement par les robots qui remplissent tout
echo <<<'HTML'
<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">
@@ -546,15 +565,20 @@ $attemptsLeft = $round['attempts_left'] ?? 0;
</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; ?>
HTML;
// Bouton de validation, remplacé par un lien de relance quand les essais sont épuisés
if ($locked) {
echo '<a class="captcha-secondary-button" href="./captcha.php?reload=1">Nouvelle série</a>';
} else {
echo '<button type="submit" class="captcha-submit">Valider ma sélection</button>';
}
echo '</div>';
echo '</div>';
echo '</form>';
}
?>
</main>
</body>
</html>
+20
View File
@@ -114,6 +114,26 @@ CREATE TABLE IF NOT EXISTS logo_banners (
PRIMARY KEY (id)
);
-- Compteur de tentatives ratées sur le code PIN du "Mode présentation" (voir
-- pages/presentation.php), une ligne par IP visiteuse. ip_hash : jamais l'IP en clair,
-- juste son hash SHA-256, suffisant pour reconnaître le même visiteur sans stocker une
-- donnée personnelle. locked_until : posé une fois le nombre max de tentatives atteint,
-- au-delà duquel le visiteur doit se connecter en administrateur plutôt que ressaisir le code
CREATE TABLE IF NOT EXISTS presentation_attempts (
ip_hash CHAR(64) NOT NULL,
attempts INT UNSIGNED NOT NULL DEFAULT 0,
locked_until DATETIME NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (ip_hash)
);
-- Contenu markdown du mode présentation. Pas de valeur par défaut pour presentation_pin_hash
-- ici volontairement : tant qu'aucun code n'est configuré depuis le panel admin, personne
-- ne peut entrer (voir verifierPinPresentation dans functions/presentationAuth.php)
INSERT INTO settings (name, value) VALUES
('presentation_markdown', '# Mode présentation\n\nContenu à venir, modifiable depuis le panel admin.')
ON DUPLICATE KEY UPDATE value = VALUES(value);
-- 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é)
Binary file not shown.
+14 -7
View File
@@ -17,13 +17,16 @@ if (isset($_GET['success']) && $_GET['success'] === '1') {
?>
</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; ?>
<?php
// Notification toast : disparaît toute seule, clic pour fermer.
// Elle n'est affichée que si une des conditions plus haut a rempli $toast.
if ($toast !== null) {
echo '<div class="toast" onclick="this.remove()" role="status">';
echo '<i class="fa-solid fa-circle-check"></i>';
echo '<span>' . $toast . '</span>';
echo '</div>';
}
?>
<footer class="site-footer">
<div class="footer-content">
@@ -36,6 +39,10 @@ if (isset($_GET['success']) && $_GET['success'] === '1') {
<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/presentation.php">Mode présentation</a>
<!-- Le tableau de synthèse E4 est affiché dans le site (voir pages/synthese.php),
avec un bouton plein écran et un bouton de téléchargement au-dessus de l'aperçu -->
<a href="../pages/synthese.php">Tableau de synthèse Oral</a>
<a href="../pages/cgu.php">CGU</a>
</div>
</div>
+13 -7
View File
@@ -14,11 +14,14 @@ require_once('../scripts/requireCaptcha.php');
// Ajout des logs sur toutes les pages
require_once('../functions/sendLogs.php');
// Versionnement des fichiers statiques (casse le cache navigateur à chaque modif du CSS)
require_once(__DIR__ . '/../functions/assets.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';
$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)
@@ -49,15 +52,17 @@ $currentPage = basename($_SERVER['PHP_SELF']);
<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:image" content="https://cdn.eldorianet.work/i/c2cc96aa-1560-4062-ba4d-b455098af627.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">
<meta name="twitter:image" content="https://cdn.eldorianet.work/i/c2cc96aa-1560-4062-ba4d-b455098af627.png">
<!-- Favicon du site -->
<link rel="icon" type="image/png" href="../memoji.png">
<!-- Favicon du site, hébergé sur le CDN plutôt que dans le dépôt : une seule
adresse à changer pour mettre le logo à jour partout, et le fichier n'alourdit
plus le projet -->
<link rel="icon" type="image/png" href="https://cdn.eldorianet.work/i/c2cc96aa-1560-4062-ba4d-b455098af627.png">
<!-- Applique le thème sauvegardé AVANT le chargement du CSS pour éviter un flash sombre/clair -->
<script>
@@ -73,8 +78,9 @@ $currentPage = basename($_SERVER['PHP_SELF']);
<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">
<!-- CSS principal du site. urlAsset ajoute "?v=<date de modif>" pour que le navigateur
recharge la feuille de style dès qu'elle change (voir functions/assets.php) -->
<link rel="stylesheet" href="<?php echo htmlspecialchars(urlAsset('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,
+32
View File
@@ -0,0 +1,32 @@
<?php
// Fonction qui construit l'URL d'un fichier statique (CSS, JS, image) en lui ajoutant un
// numéro de version basé sur la date de dernière modification du fichier sur le disque.
//
// Pourquoi : le navigateur garde les fichiers CSS en cache parfois plusieurs jours, donc un
// visiteur déjà venu continuait de voir l'ancien style après une mise en ligne (d'où la
// notice "videz votre cache" affichée sur la page d'accueil). Avec "?v=1756329123" dans
// l'URL, l'adresse change dès que le fichier change : le navigateur la considère comme une
// nouvelle ressource et la retélécharge, sans qu'il ait à faire quoi que ce soit. Tant que
// le fichier ne bouge pas, l'URL reste identique et le cache continue de servir (pas de
// téléchargement inutile à chaque visite).
//
// filemtime plutôt que le hash du dernier commit : ça ne dépend pas de la présence de git
// sur le serveur, et ça suit aussi les fichiers modifiés directement en production.
//
// $chemin : chemin du fichier depuis la racine du site (ex : 'styles/primary_css.css')
// $prefixe : préfixe à mettre dans le HTML selon l'emplacement de la page qui l'appelle
// ('../' pour les pages de /pages, './' pour les fichiers à la racine)
function urlAsset($chemin, $prefixe = '../') {
$fichier = __DIR__ . '/../' . $chemin;
// @ : si le fichier est introuvable on renvoie l'URL sans version plutôt que de
// provoquer un warning PHP au milieu du <head>
$version = @filemtime($fichier);
if ($version === false) {
return $prefixe . $chemin;
}
return $prefixe . $chemin . '?v=' . $version;
}
+158
View File
@@ -0,0 +1,158 @@
<?php
// Fonctions liées au verrouillage anti-bruteforce du code PIN du "Mode présentation"
// (voir pages/presentation.php). Le compteur de tentatives est stocké côté serveur, par
// adresse IP (hashée, jamais en clair), pour ne pas pouvoir être remis à zéro en vidant
// juste les cookies ou en ouvrant une fenêtre de navigation privée.
require_once(__DIR__ . '/databaseConnection.php');
// Nombre de chiffres attendu pour le code PIN
define('PRESENTATION_PIN_LENGTH', 9);
// Durée du blocage une fois le nombre maximum de tentatives atteint (en heures)
define('PRESENTATION_LOCK_HOURS', 24);
// Hash SHA-256 de l'IP du visiteur : sert de clé dans la table presentation_attempts.
// On ne stocke jamais l'IP en clair, un hash suffit pour reconnaître le même visiteur
// d'une requête à l'autre sans conserver une donnée personnelle en clair.
function hashIpPresentation() {
return hash('sha256', $_SERVER['REMOTE_ADDR'] ?? 'inconnue');
}
// Vérifie le code PIN saisi contre le hash enregistré dans les réglages du site.
// Renvoie false si aucun code n'a encore été configuré (accès bloqué par défaut tant
// que l'admin n'a pas défini de code depuis le panel).
function verifierPinPresentation($pinSaisi) {
require_once(__DIR__ . '/listSettings.php');
$hash = recupererSetting('presentation_pin_hash');
if (empty($hash) || strlen($pinSaisi) !== PRESENTATION_PIN_LENGTH) {
return false;
}
return password_verify($pinSaisi, $hash);
}
// Récupère l'état de blocage actuel pour une IP (hashée) donnée.
// Renvoie ['attempts' => int, 'locked_until' => string|null] ou null si cette IP n'a
// encore jamais raté de tentative.
function recupererTentativePresentation($ipHash) {
global $dbh;
try {
$stmt = $dbh->prepare('SELECT attempts, locked_until FROM presentation_attempts WHERE ip_hash = :ip');
$stmt->bindValue(':ip', $ipHash, PDO::PARAM_STR);
$stmt->execute();
$ligne = $stmt->fetch(PDO::FETCH_ASSOC);
return $ligne ?: null;
} catch (PDOException $e) {
// Tant que la table n'existe pas encore (migration SQL pas encore appliquée),
// on considère qu'il n'y a pas de blocage plutôt que de casser la page
return null;
}
}
// Vrai si cette IP est actuellement bloquée (trop de tentatives ratées récemment)
function presentationEstVerrouillee($ipHash, $maxTentatives) {
$etat = recupererTentativePresentation($ipHash);
return $etat
&& (int) $etat['attempts'] >= $maxTentatives
&& !empty($etat['locked_until'])
&& strtotime($etat['locked_until']) > time();
}
// Enregistre une tentative ratée : incrémente le compteur de cette IP, et pose un
// blocage de PRESENTATION_LOCK_HOURS heures si le maximum est atteint.
// Renvoie le nouvel état, au même format que recupererTentativePresentation().
function enregistrerEchecPresentation($ipHash, $maxTentatives) {
global $dbh;
$etatActuel = recupererTentativePresentation($ipHash);
$tentatives = $etatActuel ? (int) $etatActuel['attempts'] + 1 : 1;
$verrouJusqua = $tentatives >= $maxTentatives
? date('Y-m-d H:i:s', time() + PRESENTATION_LOCK_HOURS * 3600)
: null;
try {
$stmt = $dbh->prepare(
'INSERT INTO presentation_attempts (ip_hash, attempts, locked_until) VALUES (:ip, :tentatives, :verrou)
ON DUPLICATE KEY UPDATE attempts = VALUES(attempts), locked_until = VALUES(locked_until)'
);
$stmt->execute([
':ip' => $ipHash,
':tentatives' => $tentatives,
':verrou' => $verrouJusqua
]);
} catch (PDOException $e) {
// Si la table n'existe pas encore, on ne bloque pas le visiteur pour autant
}
return ['attempts' => $tentatives, 'locked_until' => $verrouJusqua];
}
// Efface le compteur de tentatives d'une IP : code correct, connexion admin, ou
// déblocage manuel depuis le panel admin
function reinitialiserTentativesPresentation($ipHash) {
global $dbh;
try {
$stmt = $dbh->prepare('DELETE FROM presentation_attempts WHERE ip_hash = :ip');
$stmt->bindValue(':ip', $ipHash, PDO::PARAM_STR);
$stmt->execute();
} catch (PDOException $e) {
// Rien à faire si la table n'existe pas encore
}
}
// Liste des IP actuellement bloquées, pour le panel admin
function listerTentativesVerrouillees() {
global $dbh;
try {
$stmt = $dbh->query(
'SELECT ip_hash, attempts, locked_until FROM presentation_attempts
WHERE locked_until IS NOT NULL AND locked_until > NOW()
ORDER BY locked_until DESC'
);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
return [];
}
}
// Affiche, dans le panel admin, la liste des IP actuellement bloquées avec un bouton
// de déblocage manuel pour chacune (utile si un visiteur légitime se fait bloquer)
function afficherTentativesVerrouilleesAdmin() {
$lignes = listerTentativesVerrouillees();
if (empty($lignes)) {
echo '<p class="admin-notice">Aucune IP bloquée actuellement.</p>';
return;
}
echo '<div class="projects-list">';
foreach ($lignes as $ligne) {
// On n'affiche jamais l'IP en clair : seulement une courte empreinte de son hash,
// suffisante pour distinguer deux blocages différents sans exposer de donnée perso
$empreinte = substr($ligne['ip_hash'], 0, 12);
$jusqua = date('d/m/Y à H:i', strtotime($ligne['locked_until']));
echo '<div class="project-card">';
echo '<div class="project-content">';
echo '<h2>IP bloquée</h2>';
echo '<p><strong>Empreinte :</strong> ' . htmlspecialchars($empreinte) . '…</p>';
echo '<p><strong>Tentatives ratées :</strong> ' . (int) $ligne['attempts'] . '</p>';
echo '<p><strong>Bloquée jusqu\'au :</strong> ' . htmlspecialchars($jusqua) . '</p>';
echo '<div class="admin-actions">';
echo '<a href="../scripts/unlockPresentationAttempt.php?ip=' . urlencode($ligne['ip_hash'])
. '&csrf_token=' . urlencode(csrfToken()) . '" class="btn-submit btn-delete"'
. ' onclick="return confirm(\'Débloquer cette IP ?\');">Débloquer</a>';
echo '</div>';
echo '</div>';
echo '</div>';
}
echo '</div>';
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+181 -45
View File
@@ -14,6 +14,7 @@ require_once('../functions/listSchool.php');
require_once('../functions/listSkills.php');
require_once('../functions/listSettings.php');
require_once('../functions/listLogos.php');
require_once('../functions/presentationAuth.php');
// L'utilisateur doit être administrateur
@@ -62,6 +63,11 @@ $contactAddressLine1 = recupererSetting('contact_address_line1') ?? '20 Grande r
$contactAddressLine2 = recupererSetting('contact_address_line2') ?? '52500 Broncourt';
$contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
// Contenu markdown actuel du mode présentation (voir pages/presentation.php)
// Le code PIN, lui, n'est jamais relu ici : seul son hash est en base, impossible à
// retransformer en code lisible (voir functions/presentationAuth.php)
$presentationMarkdown = recupererSetting('presentation_markdown') ?? '';
// Page du panneau d'administration
?>
@@ -79,6 +85,7 @@ $contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
<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-presentation"><i class="fa-solid fa-lock"></i> Mode présentation</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>
@@ -101,9 +108,13 @@ $contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
<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']; ?>">
<?php endif; ?>
<?php
// En mode modification, on transmet l'id de la ligne à mettre à jour.
// En mode ajout la variable est vide : pas de champ caché, donc pas d'id envoyé.
if ($projetAModifier) {
echo '<input type="hidden" name="id" value="' . (int) $projetAModifier['id'] . '">';
}
?>
<label>Titre *</label>
<input type="text" name="title" required value="<?php echo $projetAModifier ? htmlspecialchars($projetAModifier['title']) : ''; ?>">
@@ -124,16 +135,22 @@ $contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
<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; ?>
<?php
// Si un PDF est déjà enregistré, on le rappelle avec un lien pour le consulter
// et une case à cocher pour le supprimer à l'enregistrement du formulaire.
if ($projetAModifier && !empty($projetAModifier['pdf_filename'])) {
$lienPdf = '../scripts/viewPdf.php?type=projet&id=' . (int) $projetAModifier['id'];
echo '<div class="pdf-current">';
echo '<i class="fa-solid fa-file-pdf"></i>';
echo '<span>' . htmlspecialchars($projetAModifier['pdf_filename']) . '</span>';
echo '<a href="' . $lienPdf . '" target="_blank" rel="noopener noreferrer">Voir</a>';
echo '<label class="checkbox-row pdf-remove-row">';
echo '<input type="checkbox" name="remove_pdf"> Supprimer ce PDF';
echo '</label>';
echo '</div>';
}
?>
<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>
@@ -166,9 +183,13 @@ $contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
<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']; ?>">
<?php endif; ?>
<?php
// En mode modification, on transmet l'id de la ligne à mettre à jour.
// En mode ajout la variable est vide : pas de champ caché, donc pas d'id envoyé.
if ($xpAModifier) {
echo '<input type="hidden" name="id" value="' . (int) $xpAModifier['id'] . '">';
}
?>
<label>Titre *</label>
<input type="text" name="title" required value="<?php echo $xpAModifier ? htmlspecialchars($xpAModifier['title']) : ''; ?>">
@@ -195,16 +216,22 @@ $contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
<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; ?>
<?php
// Si un PDF est déjà enregistré, on le rappelle avec un lien pour le consulter
// et une case à cocher pour le supprimer à l'enregistrement du formulaire.
if ($xpAModifier && !empty($xpAModifier['pdf_filename'])) {
$lienPdf = '../scripts/viewPdf.php?type=experience&id=' . (int) $xpAModifier['id'];
echo '<div class="pdf-current">';
echo '<i class="fa-solid fa-file-pdf"></i>';
echo '<span>' . htmlspecialchars($xpAModifier['pdf_filename']) . '</span>';
echo '<a href="' . $lienPdf . '" target="_blank" rel="noopener noreferrer">Voir</a>';
echo '<label class="checkbox-row pdf-remove-row">';
echo '<input type="checkbox" name="remove_pdf"> Supprimer ce PDF';
echo '</label>';
echo '</div>';
}
?>
<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>
@@ -237,9 +264,13 @@ $contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
<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; ?>
<?php
// En mode modification, on transmet l'id de la ligne à mettre à jour.
// En mode ajout la variable est vide : pas de champ caché, donc pas d'id envoyé.
if ($ecoleAModifier) {
echo '<input type="hidden" name="id" value="' . (int) $ecoleAModifier['id'] . '">';
}
?>
<label>Nom de l'école *</label>
<input type="text" name="title" required value="<?php echo $ecoleAModifier ? htmlspecialchars($ecoleAModifier['title']) : ''; ?>">
@@ -263,16 +294,22 @@ $contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
<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; ?>
<?php
// Si un PDF est déjà enregistré, on le rappelle avec un lien pour le consulter
// et une case à cocher pour le supprimer à l'enregistrement du formulaire.
if ($ecoleAModifier && !empty($ecoleAModifier['pdf_filename'])) {
$lienPdf = '../scripts/viewPdf.php?type=ecole&id=' . (int) $ecoleAModifier['id'];
echo '<div class="pdf-current">';
echo '<i class="fa-solid fa-file-pdf"></i>';
echo '<span>' . htmlspecialchars($ecoleAModifier['pdf_filename']) . '</span>';
echo '<a href="' . $lienPdf . '" target="_blank" rel="noopener noreferrer">Voir</a>';
echo '<label class="checkbox-row pdf-remove-row">';
echo '<input type="checkbox" name="remove_pdf"> Supprimer ce PDF';
echo '</label>';
echo '</div>';
}
?>
<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>
@@ -305,9 +342,13 @@ $contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
<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; ?>
<?php
// En mode modification, on transmet l'id de la ligne à mettre à jour.
// En mode ajout la variable est vide : pas de champ caché, donc pas d'id envoyé.
if ($skillAModifier) {
echo '<input type="hidden" name="id" value="' . (int) $skillAModifier['id'] . '">';
}
?>
<label>Titre de la catégorie *</label>
<input type="text" name="title" required value="<?php echo $skillAModifier ? htmlspecialchars($skillAModifier['title']) : ''; ?>">
@@ -333,6 +374,67 @@ $contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
</div>
</section>
<!-- ========== SECTION MODE PRÉSENTATION ========== -->
<section id="admin-presentation" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-lock"></i> Mode présentation</h2>
<!-- Contenu affiché une fois le code PIN entré (voir pages/presentation.php) :
même principe que le markdown des projets/expériences/écoles, avec les mêmes
images possibles via ![légende](url) -->
<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="presentation">
<label>Contenu (markdown, comme pour les fiches projets)</label>
<!-- Petit bouton d'aide : insère la syntaxe markdown d'une image au
curseur, pour ne pas avoir à s'en souvenir -->
<div class="markdown-toolbar">
<button type="button" class="markdown-insert-image" data-target="presentation_markdown">
<i class="fa-solid fa-image"></i> Insérer une image
</button>
</div>
<textarea name="presentation_markdown" id="presentation_markdown" class="markdown-textarea" placeholder="# Titre&#10;&#10;Texte de présentation en **markdown**...&#10;&#10;![Légende de l'image](https://...)"><?php echo htmlspecialchars($presentationMarkdown); ?></textarea>
<input type="submit" name="submitsettings" value="Enregistrer le contenu" class="btn-submit">
</form>
</div>
</div>
<!-- Code PIN à part : formulaire séparé car il doit être haché avant d'être
enregistré (voir scripts/updatePresentationPin.php), contrairement au markdown
juste au-dessus qui est stocké tel quel -->
<div class="project-container">
<div class="project-form-card">
<form method="POST" action="../scripts/updatePresentationPin.php">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<label>Code PIN (exactement 9 chiffres)</label>
<input type="text" name="pin" inputmode="numeric" pattern="\d{9}" minlength="9" maxlength="9" placeholder="9 chiffres" required>
<input type="submit" value="Changer le code PIN" class="btn-submit">
</form>
<!-- Le code actuel n'est jamais réaffiché : seul son hash est stocké en
base, impossible à relire (comme un mot de passe) -->
<p class="admin-data-text">Le code actuel n'apparaît jamais ici, seul son hash est stocké en base.</p>
</div>
</div>
<!-- IP actuellement bloquées après 4 échecs : un visiteur légitime qui se trompe
plusieurs fois peut se retrouver bloqué, ce bouton permet de le débloquer
sans attendre les 24h de blocage automatique -->
<div class="project-container">
<div class="project-form-card">
<h3 class="admin-data-title"><i class="fa-solid fa-lock"></i> IP bloquées</h3>
<?php afficherTentativesVerrouilleesAdmin(); ?>
</div>
</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>
@@ -350,9 +452,13 @@ $contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
<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; ?>
<?php
// En mode modification, on transmet l'id de la ligne à mettre à jour.
// En mode ajout la variable est vide : pas de champ caché, donc pas d'id envoyé.
if ($logoAModifier) {
echo '<input type="hidden" name="id" value="' . (int) $logoAModifier['id'] . '">';
}
?>
<!-- Seul le bandeau "Ils m'ont fait confiance" est géré ici -->
<input type="hidden" name="category" value="partner">
@@ -604,6 +710,36 @@ $contactAddressEnabled = recupererSetting('contact_address_enabled') ?? '1';
})();
</script>
<!-- Bouton "Insérer une image" du mode présentation : ajoute la syntaxe markdown d'une
image (![légende](url)) à l'endroit où était le curseur dans le champ, pour ne pas
avoir à se souvenir de la syntaxe -->
<script>
(function () {
document.querySelectorAll('.markdown-insert-image').forEach(function (bouton) {
bouton.addEventListener('click', function () {
var champ = document.getElementById(bouton.dataset.target);
if (!champ) return;
var url = prompt('Lien de l\'image (URL) :');
if (!url) return;
var legende = prompt('Légende de l\'image (optionnel) :') || '';
var markdown = '![' + legende + '](' + url + ')';
// On insère au niveau du curseur plutôt qu'à la fin, pour pouvoir placer
// l'image n'importe où dans le texte déjà écrit
var debut = champ.selectionStart;
var fin = champ.selectionEnd;
champ.value = champ.value.slice(0, debut) + markdown + champ.value.slice(fin);
champ.focus();
var curseur = debut + markdown.length;
champ.setSelectionRange(curseur, curseur);
});
});
})();
</script>
<?php
// On ajoute ici le footer
require_once('../elements/footer.php'); ?>
+12 -7
View File
@@ -24,13 +24,18 @@ $contactAddressEnabled = (recupererSetting('contact_address_enabled') ?? '1') ==
<div class="contact-info">
<p class="contact-title">Contact</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; ?>
<?php
// L'e-mail n'est affiché que s'il est renseigné et activé dans le panel admin
if ($contactEmailEnabled && !empty($contactEmail)) {
echo '<p class="contact-line"><i class="fa-solid fa-envelope"></i> ' . htmlspecialchars($contactEmail) . '</p>';
}
// Même principe pour l'adresse postale (deux lignes)
if ($contactAddressEnabled && (!empty($contactAddressLine1) || !empty($contactAddressLine2))) {
echo '<p class="contact-line"><i class="fa-solid fa-location-dot"></i> ' . htmlspecialchars($contactAddressLine1) . '</p>';
echo '<p class="contact-line">' . htmlspecialchars($contactAddressLine2) . '</p>';
}
?>
</div>
<!-- Formulaire d'envoi -->
+77 -49
View File
@@ -87,58 +87,78 @@ if (!empty($element['tags'])) {
<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; ?>
<?php
// Bandeau d'image, seulement si une image est renseignée pour cet élément
if (!empty($image)) {
echo '<div class="details-banner project-banner">';
echo '<img src="' . $image . '" alt="Image : ' . $titre . '">';
// Pastille d'état (En cours, Terminé...) posée par-dessus l'image
if (!empty($status)) {
echo '<div class="project-state">' . $status . '</div>';
}
echo '</div>';
}
?>
<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
// Métadonnées : type d'expérience et durée, affichés seulement si renseignés
if (!empty($xptype) || !empty($duration)) {
echo '<p class="details-meta">';
<?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; ?>
if (!empty($xptype)) {
echo '<span><i class="fa-solid fa-tag"></i> ' . $xptype . '</span>';
}
<?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; ?>
if (!empty($duration)) {
echo '<span><i class="fa-solid fa-clock"></i> ' . $duration . '</span>';
}
echo '</p>';
}
// Étiquettes (technologies, mots-clés), une par tag séparé par une virgule en BDD
if (!empty($tags)) {
echo '<div class="project-tags">';
foreach ($tags as $tag) {
echo '<span class="tag">' . htmlspecialchars($tag) . '</span>';
}
echo '</div>';
}
?>
<?php
// Deux contenus possibles, jamais les deux en même temps : soit un texte en
// markdown, soit un PDF. Le markdown est prioritaire quand les deux existent.
if ($hasMarkdown) {
// Le markdown brut est placé ici (échappé), puis rendu en HTML par le script plus bas
echo '<div id="markdown-source" hidden>' . htmlspecialchars($element['markdown']) . '</div>';
echo '<div class="markdown-content" id="markdown-target">';
echo '<p>Chargement du contenu...</p>';
echo '</div>';
} elseif ($hasPdf) {
// Mini lecteur PDF : le fichier est servi par viewPdf.php depuis la base de données
$lienPdf = '../scripts/viewPdf.php?type=' . $type . '&id=' . (int) $id;
echo '<div class="pdf-viewer-wrap">';
echo '<div class="pdf-viewer-toolbar">';
echo '<span><i class="fa-solid fa-file-pdf"></i> ' . htmlspecialchars($element['pdf_filename']) . '</span>';
echo '<a class="btn btn-ghost pdf-download-btn" href="' . $lienPdf . '&download=1" download>';
echo '<i class="fa-solid fa-download"></i> Télécharger';
echo '</a>';
echo '</div>';
echo '<iframe class="pdf-viewer" src="' . $lienPdf . '" title="Aperçu PDF : ' . $titre . '"></iframe>';
echo '</div>';
}
?>
</div>
@@ -146,10 +166,16 @@ if (!empty($element['tags'])) {
</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>
<?php
// Ces deux librairies ne sont chargées que sur les pages qui ont réellement du markdown :
// marked.js convertit le markdown en HTML, DOMPurify nettoie le HTML généré (sécurité XSS)
if ($hasMarkdown) {
echo '<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>';
echo '<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>';
// <<<'JS' ... JS; = "nowdoc" : un bloc de texte sur plusieurs lignes, recopié tel quel.
// Pratique ici pour ne pas avoir à échapper les guillemets du JavaScript.
echo <<<'JS'
<script>
(function () {
var source = document.getElementById('markdown-source');
@@ -159,7 +185,9 @@ if (!empty($element['tags'])) {
cible.innerHTML = DOMPurify.sanitize(marked.parse(markdown, { breaks: true }));
})();
</script>
<?php endif; ?>
JS;
}
?>
<?php
// Ajout du footer
+47 -29
View File
@@ -57,27 +57,39 @@ $statsAffichables = array_filter([
'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
// Bandeau de chiffres clés : compteurs réels, calculés depuis la BDD.
// Rien n'est affiché s'il n'y a aucun compteur à montrer.
if (!empty($statsAffichables)) {
echo '<section class="stats-strip">';
<?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; ?>
// Une tuile par compteur
foreach ($statsAffichables as $stat) {
echo '<div class="stat-item">';
echo '<span class="stat-number">' . (int) $stat['valeur'] . '</span>';
echo '<span class="stat-label">' . htmlspecialchars($stat['label']) . '</span>';
echo '</div>';
}
echo '</section>';
}
?>
<?php
// Bandeau défilant des outils, dérivé des icônes de la page Compétences.
// Si aucune compétence n'a d'icône reconnaissable, la section entière est masquée.
if (!empty(listeIconesCompetences())) {
echo '<section class="logo-banner-section">';
echo '<div class="logo-banner-header">';
echo '<h2>Outils que je sais manipuler</h2>';
echo '</div>';
// Cette fonction affiche elle-même le bandeau (voir functions/listSkills.php)
afficherOutilsCompetences();
echo '</section>';
}
?>
<!-- Section d'exploration rapide -->
<section class="quick-links-section" id="explorer">
@@ -123,15 +135,21 @@ $statsAffichables = array_filter([
</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
// Bandeau défilant des entreprises partenaires, géré depuis le panel Admin.
// Tant qu'aucun logo "partner" n'est enregistré, la section n'apparaît pas.
if (!empty(listeLogos('partner'))) {
echo '<section class="logo-banner-section">';
echo '<div class="logo-banner-header">';
echo '<h2>Ils m\'ont fait confiance</h2>';
echo '</div>';
// Cette fonction affiche elle-même le bandeau (voir functions/listLogos.php)
afficherLogos('partner');
echo '</section>';
}
?>
<?php
// Ajout du footer
+11 -1
View File
@@ -15,9 +15,13 @@ if (isset($_SESSION['user_id'])){
exit;
}
// Redirection optionnelle après connexion, whitelist stricte (pas d'URL arbitraire ici :
// on ne veut pas transformer ce champ en redirection ouverte). Utilisé quand le mode
// présentation renvoie ici après un blocage (voir pages/presentation.php)
$redirect = ($_GET['redirect'] ?? '') === 'presentation' ? 'presentation' : '';
?>
<!-- CSS Login (fait par Intelligence Arti) -->
<div class="login-container">
<div class="login-card">
@@ -26,6 +30,12 @@ if (isset($_SESSION['user_id'])){
<p>Connexion</p>
<?php
if ($redirect !== '') {
echo '<input type="hidden" name="redirect" value="' . htmlspecialchars($redirect) . '">';
}
?>
<!-- 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">
+484
View File
@@ -0,0 +1,484 @@
<?php
// Page "Mode présentation" : contenu markdown protégé par un code PIN à 9 chiffres.
// Le compteur de tentatives est géré côté serveur par IP (voir functions/presentationAuth.php)
// pour ne pas pouvoir être remis à zéro en vidant les cookies. Au bout de 4 tentatives
// ratées, on ne redemande plus le code : on propose une connexion admin à la place.
//
// Une fois débloquée (code correct, ou compte admin connecté), la page se rend sans le
// header ni le footer du site : contenu markdown en plein écran, lecture immersive.
require_once(__DIR__ . '/../functions/security.php');
require_once('../functions/databaseConnection.php');
require_once('../functions/listSettings.php');
require_once('../functions/presentationAuth.php');
$adminConnecte = isset($_SESSION['role']) && $_SESSION['role'] === 'admin';
$ipHash = hashIpPresentation();
$maxTentatives = 4;
// Un administrateur connecté a toujours accès, et ça lève un éventuel blocage sur son IP
// (pratique s'il teste lui-même depuis chez lui après s'être fait bloquer en visiteur)
if ($adminConnecte) {
reinitialiserTentativesPresentation($ipHash);
$_SESSION['presentation_unlocked'] = true;
}
$deverrouille = $adminConnecte || (($_SESSION['presentation_unlocked'] ?? false) === true);
$verrouille = false;
$erreur = '';
if (!$deverrouille) {
$verrouille = presentationEstVerrouillee($ipHash, $maxTentatives);
if (!$verrouille && $_SERVER['REQUEST_METHOD'] === 'POST') {
$pinSaisi = preg_replace('/\D/', '', $_POST['pin'] ?? '');
if (verifierPinPresentation($pinSaisi)) {
reinitialiserTentativesPresentation($ipHash);
// Empêche la fixation de session, comme à la connexion admin
session_regenerate_id(true);
$_SESSION['presentation_unlocked'] = true;
$deverrouille = true;
} else {
$etat = enregistrerEchecPresentation($ipHash, $maxTentatives);
$verrouille = (int) $etat['attempts'] >= $maxTentatives;
$erreur = $verrouille ? '' : 'Code incorrect. Réessaie.';
}
}
}
// Tentatives restantes à afficher (pastilles), calculées après un éventuel échec ci-dessus
$tentativesRestantes = $maxTentatives;
$etatActuel = null;
if (!$deverrouille) {
$etatActuel = recupererTentativePresentation($ipHash);
$tentativesRestantes = $maxTentatives - ($etatActuel ? (int) $etatActuel['attempts'] : 0);
if ($tentativesRestantes < 0) {
$tentativesRestantes = 0;
}
}
if ($deverrouille) {
// ---- Présentation en cours : page autonome plein écran, sans le header du site ----
require_once('../scripts/requireCaptcha.php');
require_once('../functions/sendLogs.php');
require_once(__DIR__ . '/../functions/assets.php');
sendPageLog();
$markdown = recupererSetting('presentation_markdown') ?? '';
// Diapositives séparées par une ligne ne contenant que --- (même convention que
// Marp/reveal.js : facile à retenir, et ça reste du markdown lisible tel quel).
// Sans --- dans le texte, tout le contenu forme une seule diapositive : dans ce
// cas on garde une simple page de lecture, sans flèches ni compteur inutiles.
$morceaux = preg_split('/^[ \t]*-{3,}[ \t]*$/m', $markdown);
$diapositives = [];
foreach ($morceaux as $morceau) {
$texte = trim($morceau);
if ($texte !== '') {
$diapositives[] = $texte;
}
}
$nombreDiapositives = count($diapositives);
$modeDiaporama = $nombreDiapositives > 1;
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mode présentation - Esteban MOUSSU</title>
<meta name="robots" content="noindex, nofollow">
<!-- Favicon servie par le CDN (voir elements/header.php) -->
<link rel="icon" type="image/png" href="https://cdn.eldorianet.work/i/c2cc96aa-1560-4062-ba4d-b455098af627.png">
<!-- Applique le thème sauvegardé AVANT le chargement du CSS pour éviter un flash sombre/clair -->
<script>
(function () {
try {
if (localStorage.getItem('theme') === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
}
} catch (e) {}
})();
</script>
<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">
<!-- On réutilise les variables et styles de base du site (thème clair/sombre, police...) -->
<link rel="stylesheet" href="<?php echo htmlspecialchars(urlAsset('styles/primary_css.css')); ?>">
<link rel="stylesheet" href="<?php echo htmlspecialchars(urlAsset('styles/presentation.css')); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
</head>
<body class="presentation-body">
<!-- Barre minimale : pas de nav du site, juste un rappel du mode + sortie -->
<div class="presentation-bar">
<span class="presentation-bar-tag"><i class="fa-solid fa-lock-open"></i> <span>Mode présentation</span></span>
<div class="presentation-bar-actions">
<button class="theme-toggle" type="button" aria-label="Changer de thème"
onclick="var h=document.documentElement;var l=h.getAttribute('data-theme')==='light';if(l){h.removeAttribute('data-theme');try{localStorage.setItem('theme','dark');}catch(e){}}else{h.setAttribute('data-theme','light');try{localStorage.setItem('theme','light');}catch(e){}}">
<i class="fa-solid fa-sun"></i>
<i class="fa-solid fa-moon"></i>
</button>
<a class="presentation-exit" href="../pages/home.php"><i class="fa-solid fa-xmark"></i> Quitter</a>
</div>
</div>
<?php if (!$modeDiaporama) { ?>
<!-- Un seul bloc de contenu : barre de progression de lecture (s'anime au scroll dans
les navigateurs qui supportent animation-timeline: scroll() ; reste simplement
invisible sinon, aucune régression) -->
<div class="presentation-progress" aria-hidden="true"></div>
<?php } ?>
<?php if ($nombreDiapositives === 0) { ?>
<main class="presentation-reader">
<p class="presentation-empty">Contenu à venir. Ajoute du texte depuis le panel admin, section « Mode présentation ».</p>
</main>
<?php } elseif (!$modeDiaporama) { ?>
<!-- Mode lecture simple : tout défile dans une seule colonne -->
<main class="presentation-reader">
<?php
// Même mécanisme que sur les pages de détail (projets/xp/écoles) : le markdown
// brut est déposé ici (échappé), puis rendu en HTML par marked.js plus bas.
// Les images s'insèrent avec la syntaxe markdown standard : ![légende](url)
echo '<div id="markdown-source" hidden>' . htmlspecialchars($diapositives[0]) . '</div>';
echo '<div class="markdown-content presentation-markdown" id="markdown-target"><p>Chargement du contenu...</p></div>';
?>
</main>
<?php } else { ?>
<!-- Mode diaporama : une diapositive à la fois, séparées dans le champ admin par
une ligne "---". Navigation en bas (flèches, barre de progression, compteur) -->
<main class="presentation-deck" data-total="<?php echo $nombreDiapositives; ?>">
<?php
foreach ($diapositives as $index => $texteDiapositive) {
$classeActive = $index === 0 ? ' is-active' : '';
echo '<section class="slide' . $classeActive . '" data-slide="' . $index . '">';
echo '<div class="slide-source" hidden>' . htmlspecialchars($texteDiapositive) . '</div>';
echo '<div class="markdown-content presentation-markdown slide-target"><p>Chargement du contenu...</p></div>';
echo '</section>';
}
?>
</main>
<nav class="slide-nav" aria-label="Navigation entre les diapositives">
<button type="button" class="slide-nav-btn slide-prev" aria-label="Diapositive précédente" disabled>
<i class="fa-solid fa-chevron-left"></i>
</button>
<div class="slide-progress" aria-hidden="true">
<div class="slide-progress-bar"></div>
</div>
<span class="slide-counter"><span class="slide-counter-current">1</span> / <?php echo $nombreDiapositives; ?></span>
<button type="button" class="slide-nav-btn slide-next" aria-label="Diapositive suivante">
<i class="fa-solid fa-chevron-right"></i>
</button>
</nav>
<?php } ?>
<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>
<?php
if (!$modeDiaporama) {
// ---- Rendu simple : un seul bloc, apparition douce au fil du scroll ----
echo <<<'JS'
<script>
(function () {
var source = document.getElementById('markdown-source');
var cible = document.getElementById('markdown-target');
if (source && cible) {
var markdown = source.textContent;
cible.innerHTML = DOMPurify.sanitize(marked.parse(markdown, { breaks: true }));
}
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
if (!('IntersectionObserver' in window)) return;
// Chaque bloc du contenu (titre, paragraphe, image...) apparaît en douceur à la lecture
var cibles = document.querySelectorAll('.presentation-markdown > *');
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 -60px 0px' });
cibles.forEach(function (el) {
el.classList.add('reveal');
observer.observe(el);
});
})();
</script>
JS;
} else {
// ---- Rendu diaporama : une diapositive affichée à la fois, navigation clavier/
// tactile/boutons, position mémorisée dans l'URL (#slide-3) pour pouvoir
// partager un lien direct vers une diapositive précise ----
echo <<<'JS'
<script>
(function () {
var slides = Array.prototype.slice.call(document.querySelectorAll('.slide'));
// Rendu du markdown de chaque diapositive (source échappée côté serveur,
// transformée en HTML ici, comme sur les fiches projets)
slides.forEach(function (slide) {
var source = slide.querySelector('.slide-source');
var cible = slide.querySelector('.slide-target');
if (source && cible) {
cible.innerHTML = DOMPurify.sanitize(marked.parse(source.textContent, { breaks: true }));
}
});
var index = 0;
var deck = document.querySelector('.presentation-deck');
var boutonPrec = document.querySelector('.slide-prev');
var boutonSuiv = document.querySelector('.slide-next');
var barre = document.querySelector('.slide-progress-bar');
var compteur = document.querySelector('.slide-counter-current');
function afficher(nouvelIndex) {
nouvelIndex = Math.max(0, Math.min(slides.length - 1, nouvelIndex));
slides[index].classList.remove('is-active');
index = nouvelIndex;
slides[index].classList.add('is-active');
boutonPrec.disabled = index === 0;
boutonSuiv.disabled = index === slides.length - 1;
if (barre) barre.style.width = ((index + 1) / slides.length * 100) + '%';
if (compteur) compteur.textContent = String(index + 1);
history.replaceState(null, '', '#slide-' + (index + 1));
}
boutonPrec.addEventListener('click', function () { afficher(index - 1); });
boutonSuiv.addEventListener('click', function () { afficher(index + 1); });
document.addEventListener('keydown', function (e) {
// On ignore les flèches si le focus est dans un champ de texte (aucun ici,
// mais garde-fou si jamais du contenu interactif est ajouté un jour)
var balise = (document.activeElement || {}).tagName;
if (balise === 'INPUT' || balise === 'TEXTAREA') return;
if (e.key === 'ArrowRight' || e.key === ' ') { e.preventDefault(); afficher(index + 1); }
if (e.key === 'ArrowLeft') { afficher(index - 1); }
});
// Clic sur la diapositive (comme Google Slides / Keynote) : moitié gauche de l'écran
// = diapositive précédente, moitié droite = suivante. On laisse passer le clic sans
// rien faire s'il vise un lien, un bouton ou la barre de navigation elle-même, pour
// ne pas voler le clic d'un lien présent dans le contenu markdown
deck.addEventListener('click', function (e) {
if (e.target.closest('a, button')) return;
afficher(e.clientX < window.innerWidth / 2 ? index - 1 : index + 1);
});
// Glissement tactile (mobile) : swipe vers la gauche = diapositive suivante
var startX = null;
deck.addEventListener('touchstart', function (e) {
startX = e.touches[0].clientX;
}, { passive: true });
deck.addEventListener('touchend', function (e) {
if (startX === null) return;
var delta = e.changedTouches[0].clientX - startX;
if (Math.abs(delta) > 50) {
afficher(delta < 0 ? index + 1 : index - 1);
}
startX = null;
}, { passive: true });
// Reprend la diapositive indiquée dans l'URL au chargement (#slide-3)
var depart = parseInt((location.hash.match(/slide-(\d+)/) || [])[1], 10);
afficher(isNaN(depart) ? 0 : depart - 1);
})();
</script>
JS;
}
?>
</body>
</html>
<?php
} else {
// ---- Écran de saisie du code, ou écran "trop de tentatives" : chrome normal du site ----
$pageTitle = 'Mode présentation';
require_once('../elements/header.php');
if ($verrouille) {
$jusqua = ($etatActuel && !empty($etatActuel['locked_until']))
? date('d/m/Y à H:i', strtotime($etatActuel['locked_until']))
: '';
?>
<div class="presentation-lock-container">
<div class="presentation-lock-card presentation-lock-card--blocked">
<span class="presentation-lock-badge presentation-lock-badge--danger">
<i class="fa-solid fa-triangle-exclamation"></i> Accès bloqué
</span>
<h1>Trop de tentatives</h1>
<p>Le code a été saisi plusieurs fois de suite sans succès. L'accès par code PIN est bloqué pour cet appareil<?php echo $jusqua !== '' ? ' jusqu\'au ' . htmlspecialchars($jusqua) : ''; ?>.</p>
<p>Connecte-toi en administrateur pour débloquer l'accès immédiatement.</p>
<a class="btn btn-primary presentation-lock-btn" href="../pages/login.php?redirect=presentation">
<i class="fa-solid fa-right-to-bracket"></i> Se connecter en administrateur
</a>
</div>
</div>
<?php
} else {
?>
<div class="presentation-lock-container">
<div class="presentation-lock-card">
<span class="presentation-lock-badge"><i class="fa-solid fa-lock"></i> Accès protégé</span>
<h1>Mode présentation</h1>
<p>Entre le code à 9 chiffres pour accéder au contenu.</p>
<?php
if ($erreur !== '') {
echo '<div class="presentation-lock-error">' . htmlspecialchars($erreur) . '</div>';
}
?>
<form method="POST" action="" class="presentation-pin-form" novalidate>
<div class="pin-boxes" role="group" aria-label="Code à 9 chiffres">
<?php
// type="password" : les chiffres s'affichent en points par défaut, comme un
// mot de passe. Le bouton œil juste en dessous les révèle, mais seulement
// tant qu'il reste appuyé (voir le JS plus bas)
for ($i = 0; $i < 9; $i++) {
echo '<input type="password" class="pin-box" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="off" aria-label="Chiffre ' . ($i + 1) . ' sur 9">';
}
?>
</div>
<!-- Maintenir appuyé pour voir le code, relâcher pour le remasquer -->
<button type="button" class="pin-reveal" aria-pressed="false">
<i class="fa-solid fa-eye"></i> <span>Maintenir pour afficher</span>
</button>
<input type="hidden" name="pin" id="pin-value">
<input type="submit" value="Valider" class="btn btn-primary presentation-pin-submit">
</form>
<div class="pin-attempts">
<span>Tentatives restantes</span>
<div class="pin-attempts-dots">
<?php
for ($i = 0; $i < $maxTentatives; $i++) {
echo '<span class="' . ($i < $tentativesRestantes ? 'dot-filled' : 'dot-empty') . '"></span>';
}
?>
</div>
</div>
</div>
</div>
<script>
(function () {
var boxes = Array.prototype.slice.call(document.querySelectorAll('.pin-box'));
var hidden = document.getElementById('pin-value');
var submit = document.querySelector('.presentation-pin-submit');
function syncHidden() {
var valeur = boxes.map(function (b) { return b.value; }).join('');
hidden.value = valeur;
submit.disabled = valeur.length !== 9;
}
boxes.forEach(function (box, index) {
box.addEventListener('input', function () {
box.value = box.value.replace(/\D/g, '').slice(0, 1);
if (box.value && boxes[index + 1]) {
boxes[index + 1].focus();
}
syncHidden();
});
box.addEventListener('keydown', function (e) {
if (e.key === 'Backspace' && !box.value && boxes[index - 1]) {
boxes[index - 1].focus();
}
});
box.addEventListener('paste', function (e) {
var texte = (e.clipboardData || window.clipboardData).getData('text').replace(/\D/g, '');
if (texte.length === 0) return;
e.preventDefault();
texte.slice(0, 9).split('').forEach(function (chiffre, i) {
if (boxes[i]) boxes[i].value = chiffre;
});
syncHidden();
var prochain = boxes[Math.min(texte.length, 8)];
if (prochain) prochain.focus();
});
});
// Bouton œil : le code n'est visible QUE tant que le bouton reste appuyé. Dès qu'on
// relâche (ou que le doigt/la souris quitte le bouton, ou que l'onglet perd le focus),
// on repasse en points. Évite de laisser le code affiché par inadvertance à l'écran.
var oeil = document.querySelector('.pin-reveal');
var icone = oeil.querySelector('i');
function afficherCode(visible) {
boxes.forEach(function (box) {
box.type = visible ? 'text' : 'password';
});
oeil.setAttribute('aria-pressed', visible ? 'true' : 'false');
oeil.classList.toggle('is-revealing', visible);
icone.className = visible ? 'fa-solid fa-eye-slash' : 'fa-solid fa-eye';
}
['mousedown', 'touchstart'].forEach(function (evt) {
oeil.addEventListener(evt, function (e) {
// Empêche le navigateur mobile de déclencher aussi un mousedown en double,
// et évite de voler le focus au champ en cours de saisie
e.preventDefault();
afficherCode(true);
});
});
['mouseup', 'mouseleave', 'touchend', 'touchcancel', 'blur'].forEach(function (evt) {
oeil.addEventListener(evt, function () {
afficherCode(false);
});
});
// Accessibilité clavier : Espace ou Entrée maintenus révèlent le code de la même façon
oeil.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
afficherCode(true);
}
});
oeil.addEventListener('keyup', function (e) {
if (e.key === ' ' || e.key === 'Enter') {
afficherCode(false);
}
});
// Garde-fou : si l'utilisateur change d'onglet en gardant le bouton appuyé, le
// relâchement n'est jamais reçu par la page, donc on remasque de force
window.addEventListener('blur', function () {
afficherCode(false);
});
syncHidden();
if (boxes[0]) boxes[0].focus();
})();
</script>
<?php
}
require_once('../elements/footer.php');
}
+66
View File
@@ -0,0 +1,66 @@
<?php
// Page du tableau de synthèse (épreuve E4 du BTS SIO), affiché directement dans le site.
// Le PDF est un fichier statique du dépôt (dossier documents/), pas un PDF stocké en base
// comme ceux des projets/expériences : il n'y en a qu'un seul et il ne change quasiment
// jamais, une table dédiée n'apporterait rien ici.
// Titre de l'onglet
$pageTitle = 'Tableau de synthèse';
// Description pour le SEO et les aperçus de partage
$pageDescription = 'Tableau de synthèse des réalisations professionnelles d\'Esteban MOUSSU, épreuve E4 du BTS SIO option SISR.';
// On ajoute ici le header
require_once('./../elements/header.php');
// Chemin du PDF, écrit une seule fois : il sert à la fois à l'aperçu et au bouton
// d'ouverture en plein écran juste en dessous
$cheminPdf = '../documents/tableau-de-synthese-e4.pdf';
?>
<div class="page-header">
<h1>Tableau de synthèse</h1>
<p>Réalisations professionnelles présentées à l'épreuve E4 du BTS SIO, option SISR.</p>
</div>
<div class="synthese-page">
<!-- Barre au-dessus de l'aperçu : rappel du document et sorties possibles.
Le tableau est dense (en-têtes de colonnes en texte vertical), donc on met en
avant l'ouverture plein écran, qui donne le zoom de la visionneuse du navigateur -->
<div class="pdf-viewer-toolbar">
<span><i class="fa-solid fa-file-pdf"></i> Tableau de synthèse E4 (PDF, 1 page)</span>
<span class="synthese-actions">
<!-- Une seule action mise en avant : l'ouverture plein écran, celle qui rend
vraiment le tableau lisible. Le téléchargement reste en bouton discret,
même style que sur les fiches projets (voir pages/details.php) -->
<a class="btn btn-primary pdf-download-btn" href="<?php echo $cheminPdf; ?>" target="_blank" rel="noopener noreferrer">
<i class="fa-solid fa-up-right-from-square"></i> Plein écran
</a>
<a class="btn btn-ghost pdf-download-btn" href="<?php echo $cheminPdf; ?>" download>
<i class="fa-solid fa-download"></i> Télécharger
</a>
</span>
</div>
<!-- Aperçu intégré. L'attribut title sert aux lecteurs d'écran, et le contenu de
secours s'affiche si le navigateur ne sait pas afficher un PDF dans une iframe
(c'est le cas de plusieurs navigateurs mobiles) -->
<!-- #view=FitH ajuste le document à la largeur, pagemode=none masque le panneau de
vignettes qui mange la moitié de la place sur un écran étroit (respecté par
Firefox, ignoré sans dommage par les autres) -->
<iframe class="pdf-viewer synthese-viewer" src="<?php echo $cheminPdf; ?>#view=FitH&amp;pagemode=none"
title="Tableau de synthèse des réalisations professionnelles, épreuve E4 du BTS SIO">
<p class="synthese-repli">
Ton navigateur ne peut pas afficher le PDF directement.
<a href="<?php echo $cheminPdf; ?>" target="_blank" rel="noopener noreferrer">Ouvre-le dans un nouvel onglet</a>.
</p>
</iframe>
</div>
<?php
// Ajout du footer
require_once('../elements/footer.php');
?>
+5 -2
View File
@@ -59,8 +59,11 @@ try {
// Log Discord : connexion réussie
sendLog("Connexion réussie", $user['username']);
//Redirection vers home.php puisque que l'utilisateur est forcément connecté
// Redirection après connexion : vers presentation.php si on vient de s'y faire bloquer
// (voir pages/login.php, seule valeur acceptée pour ne pas ouvrir une redirection
// arbitraire), vers home.php sinon
// (?login=1 déclenche la notification "Connexion réussie" sur la page)
header('Location: ./../pages/home.php?login=1');
$pageRetour = ($_POST['redirect'] ?? '') === 'presentation' ? 'presentation.php' : 'home.php';
header('Location: ./../pages/' . $pageRetour . '?login=1');
exit;
?>
+29
View File
@@ -0,0 +1,29 @@
<?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');
// reinitialiserTentativesPresentation
require_once('../functions/presentationAuth.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();
$ipHash = $_GET['ip'] ?? '';
if ($ipHash !== '') {
reinitialiserTentativesPresentation($ipHash);
sendLog('Déblocage manuel du mode présentation pour une IP');
}
// On renvoie l'utilisateur sur admin.php, directement sur la section Mode présentation
header('Location: ../pages/admin.php#admin-presentation');
exit;
+51
View File
@@ -0,0 +1,51 @@
<?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');
// PRESENTATION_PIN_LENGTH
require_once('../functions/presentationAuth.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;
}
$pin = trim($_POST['pin'] ?? '');
// Le code doit faire exactement 9 chiffres, rien d'autre : sinon on ignore la demande
// plutôt que d'enregistrer un code invalide (on ne veut jamais bloquer l'accès par erreur)
if (!preg_match('/^\d{' . PRESENTATION_PIN_LENGTH . '}$/', $pin)) {
header('Location: ../pages/admin.php#admin-presentation');
exit;
}
try {
// On ne stocke jamais le code en clair, seulement son hash (comme un mot de passe)
$stmt = $dbh->prepare('INSERT INTO settings (name, value) VALUES (:name, :value) ON DUPLICATE KEY UPDATE value = VALUES(value)');
$stmt->execute([
':name' => 'presentation_pin_hash',
':value' => password_hash($pin, PASSWORD_DEFAULT)
]);
} catch (PDOException $e) {
die("Erreur lors du changement du code PIN : " . $e->getMessage());
}
// Log Discord
sendLog('Changement du code PIN du mode présentation');
// On renvoie l'utilisateur sur admin.php, directement sur la section Mode présentation
header('Location: ../pages/admin.php#admin-presentation');
exit;
+8 -1
View File
@@ -39,6 +39,12 @@ if ($groupeReglages === 'captcha') {
'contact_address_line2' => trim($_POST['contact_address_line2'] ?? ''),
'contact_address_enabled' => isset($_POST['contact_address_enabled']) ? '1' : '0'
];
} elseif ($groupeReglages === 'presentation') {
// Le code PIN se change à part (voir scripts/updatePresentationPin.php) : il a
// besoin d'être haché, contrairement aux autres réglages stockés tels quels ici
$reglages = [
'presentation_markdown' => trim($_POST['presentation_markdown'] ?? '')
];
} else {
$reglages = [
'site_notice_title' => trim($_POST['notice_title'] ?? ''),
@@ -65,7 +71,8 @@ try { // On essaie d'executer une requête qui est préparée pour chaque régla
$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"
'notice' => "Modification de la notification de la page d'accueil",
'presentation' => 'Modification du contenu du mode présentation'
];
sendLog($libellesGroupes[$groupeReglages] ?? "Modification des réglages du site");
+261
View File
@@ -0,0 +1,261 @@
/* ---- MODE PRÉSENTATION (page autonome, plein écran) ----
Ce fichier ne s'occupe QUE de la lecture immersive une fois le code déverrouillé.
L'écran de saisie du code (avant déverrouillage) vit dans le site normal et est
stylé dans primary_css.css, comme le reste des pages (voir pages/presentation.php). */
/* Barre du haut : pas de nav, juste le rappel du mode + sortie. Même traitement
visuel que .site-header (position sticky + flou) pour rester cohérent avec le site */
.presentation-bar {
position: sticky;
top: 0;
z-index: 100;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 14px clamp(16px, 4vw, 48px);
background: var(--header-bg);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid var(--border);
}
.presentation-bar-tag {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 14px;
font-weight: 600;
}
.presentation-bar-tag i {
color: var(--accent);
}
.presentation-bar-actions {
display: flex;
align-items: center;
gap: 10px;
}
.presentation-exit {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 16px;
height: 42px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text-muted);
font-size: 14px;
font-weight: 600;
text-decoration: none;
transition: color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
.presentation-exit:hover {
color: var(--text);
border-color: var(--accent-border);
transform: translateY(-1px);
}
/* Barre de progression de lecture : ne s'anime que si le navigateur sait suivre le
scroll en CSS pur (pas de JS de scroll ici, volontairement, pour rester fluide).
Sans ce support, la barre reste simplement invisible : aucune régression visuelle. */
.presentation-progress {
position: sticky;
top: 70px;
z-index: 99;
height: 3px;
background: var(--accent);
transform-origin: left;
transform: scaleX(0);
opacity: 0;
}
@supports (animation-timeline: scroll()) {
.presentation-progress {
opacity: 1;
animation: presentation-progress-scroll linear;
animation-timeline: scroll(root);
}
@keyframes presentation-progress-scroll {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
}
/* Colonne de lecture : centrée, largeur confortable, grand respire au-dessus/en dessous */
.presentation-reader {
max-width: 760px;
margin: 0 auto;
padding: clamp(48px, 8vw, 96px) clamp(20px, 5vw, 24px) 120px;
}
.presentation-empty {
text-align: center;
color: var(--text-muted);
font-size: 16px;
padding: 80px 20px;
}
/* Le contenu markdown reprend .markdown-content (voir primary_css.css) mais en plus
grand : c'est le seul contenu de la page, il peut respirer davantage qu'inséré au
milieu d'une fiche projet */
.presentation-markdown {
margin-top: 0;
padding-top: 0;
border-top: none;
font-size: 18px;
line-height: 1.85;
}
.presentation-markdown h1 { font-size: clamp(30px, 5vw, 42px); }
.presentation-markdown h2 { font-size: clamp(24px, 3.5vw, 30px); }
.presentation-markdown h3 { font-size: clamp(20px, 3vw, 24px); }
.presentation-markdown img {
width: 100%;
margin: 28px 0;
box-shadow: var(--shadow-sm);
}
@media (max-width: 620px) {
.presentation-bar-tag span {
display: none;
}
.presentation-reader {
padding-top: 40px;
}
}
/* ---- MODE DIAPORAMA ----
Activé dès que le contenu du panel admin contient au moins une ligne "---" : le
texte est alors coupé en plusieurs diapositives (voir pages/presentation.php),
affichées une par une avec une navigation en bas d'écran plutôt qu'en défilement. */
.presentation-deck {
position: relative;
max-width: 760px;
margin: 0 auto;
padding: clamp(48px, 8vw, 96px) clamp(20px, 5vw, 24px) 140px;
min-height: calc(100dvh - 70px);
display: flex;
align-items: center;
/* Toute la diapositive est cliquable pour avancer/reculer (voir le JS) : le curseur
le signale, sauf sur les vrais liens/boutons qui gardent leur propre curseur */
cursor: pointer;
}
.slide {
display: none;
width: 100%;
}
.slide.is-active {
display: block;
animation: slide-enter 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slide-enter {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.slide.is-active {
animation: none;
}
}
/* La navigation reprend le même traitement "pilule flottante" que le bouton de sortie */
.slide-nav {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
z-index: 90;
display: flex;
align-items: center;
gap: 14px;
padding: 10px 18px;
background: var(--header-bg);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid var(--border);
border-radius: 999px;
box-shadow: var(--shadow);
}
.slide-nav-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
flex-shrink: 0;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 50%;
color: var(--text);
cursor: pointer;
transition: color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
.slide-nav-btn:hover:not(:disabled) {
color: var(--accent-light);
border-color: var(--accent-border);
transform: translateY(-1px);
}
.slide-nav-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.slide-progress {
width: 90px;
height: 4px;
background: var(--border);
border-radius: 999px;
overflow: hidden;
}
.slide-progress-bar {
height: 100%;
/* Valeur de repli si le JS n'a pas encore tourné : une diapositive sur le total
affiché par le compteur juste à côté (voir le style inline posé en JS ensuite) */
width: 20%;
background: var(--accent);
transition: width 0.3s ease;
}
.slide-counter {
min-width: 46px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 480px) {
.slide-nav {
gap: 10px;
padding: 8px 14px;
}
.slide-progress {
width: 54px;
}
}
+288
View File
@@ -2496,3 +2496,291 @@ main {
animation: none;
}
}
/* ---- MODE PRÉSENTATION : écran de saisie du code / écran "trop de tentatives" ----
Ces deux écrans vivent dans le chrome normal du site (header + footer), contrairement
à la lecture immersive une fois déverrouillé (voir styles/presentation.css) */
.presentation-lock-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 65vh;
padding: 40px 16px;
}
.presentation-lock-card {
background: linear-gradient(180deg, var(--surface) 0%, var(--bg-soft) 100%);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
padding: 44px 40px;
width: 460px;
max-width: 100%;
text-align: center;
}
.presentation-lock-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
margin-bottom: 20px;
border-radius: 999px;
background: var(--accent-soft);
border: 1px solid var(--accent-border);
color: var(--accent-light);
font-size: 13px;
font-weight: 700;
}
.presentation-lock-badge--danger {
background: rgba(229, 72, 77, 0.12);
border-color: rgba(229, 72, 77, 0.35);
color: #ff8a8d;
}
[data-theme="light"] .presentation-lock-badge--danger {
color: var(--danger-dark);
}
.presentation-lock-card h1 {
margin: 0 0 10px;
font-size: 26px;
font-weight: 800;
letter-spacing: -0.01em;
color: var(--text);
}
.presentation-lock-card p {
margin: 0 0 6px;
color: var(--text-soft);
font-size: 15px;
line-height: 1.6;
}
.presentation-lock-card p:last-of-type {
margin-bottom: 22px;
}
.presentation-lock-btn {
width: 100%;
justify-content: center;
}
.presentation-lock-error {
margin: 16px 0 0;
padding: 10px 14px;
border-radius: var(--radius-sm);
background: rgba(229, 72, 77, 0.12);
border: 1px solid rgba(229, 72, 77, 0.3);
color: #ff8a8d;
font-size: 14px;
}
[data-theme="light"] .presentation-lock-error {
color: var(--danger-dark);
}
/* Les 9 cases du code PIN */
.presentation-pin-form {
margin-top: 22px;
}
.pin-boxes {
display: flex;
justify-content: center;
gap: 8px;
margin-bottom: 22px;
}
.pin-box {
width: 38px;
height: 48px;
text-align: center;
background-color: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
color: var(--text);
font-family: var(--font);
font-size: 20px;
font-weight: 700;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.pin-box:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
/* Bouton "maintenir pour afficher" sous les cases du code : discret, il ne doit pas
attirer l'oeil autant que le bouton Valider */
.pin-reveal {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0 auto 20px;
padding: 7px 14px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 999px;
color: var(--text-muted);
font-family: var(--font);
font-size: 13px;
font-weight: 600;
cursor: pointer;
/* Le maintien affiche le code : on empêche la sélection du texte du bouton pendant
l'appui prolongé, sinon le navigateur surligne le libellé */
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
transition: color 0.2s ease, border-color 0.2s ease;
}
.pin-reveal:hover {
color: var(--text-soft);
border-color: var(--border-strong);
}
/* Pendant que le bouton est maintenu : accent orange pour montrer que le code est visible */
.pin-reveal.is-revealing {
color: var(--accent-light);
border-color: var(--accent-border);
background: var(--accent-soft);
}
.pin-reveal:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.presentation-pin-submit {
width: 100%;
}
.presentation-pin-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
filter: none;
transform: none;
}
/* Pastilles "tentatives restantes", même principe que sur le captcha */
.pin-attempts {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin-top: 24px;
color: var(--text-muted);
font-size: 13px;
}
.pin-attempts-dots {
display: flex;
gap: 6px;
}
.pin-attempts-dots span {
width: 9px;
height: 9px;
border-radius: 50%;
}
.pin-attempts-dots .dot-filled {
background: var(--accent);
}
.pin-attempts-dots .dot-empty {
background: var(--border-strong);
}
@media (max-width: 480px) {
.pin-box {
width: 30px;
height: 42px;
font-size: 17px;
}
.pin-boxes {
gap: 5px;
}
}
/* Petit bouton "Insérer une image" au-dessus du champ markdown du mode présentation
(panel admin) : insère la syntaxe markdown au curseur, sans avoir à s'en souvenir */
.markdown-toolbar {
margin-bottom: 8px;
}
.markdown-insert-image {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-soft);
font-family: var(--font);
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
transition: color 0.2s ease, border-color 0.2s ease;
}
.markdown-insert-image:hover {
color: var(--accent-light);
border-color: var(--accent-border);
}
/* ---- PAGE TABLEAU DE SYNTHÈSE ----
Le PDF est le contenu principal de la page : on lui donne plus de largeur et plus de
hauteur que l'aperçu inséré dans une fiche projet, parce que le tableau est dense et
qu'il devient vite illisible sur une petite surface */
.synthese-page {
max-width: 1200px;
margin: 20px auto clamp(48px, 7vw, 72px);
padding: 0 clamp(14px, 4vw, 40px);
}
/* On réutilise .pdf-viewer-toolbar (voir plus haut) mais sans le trait de séparation
hérité de .pdf-viewer-wrap, qui n'a pas lieu d'être en haut de page */
.synthese-page .pdf-viewer-toolbar {
margin-bottom: 14px;
}
.synthese-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.synthese-viewer {
height: 82vh;
min-height: 520px;
}
.synthese-repli {
color: var(--text-soft);
padding: 20px;
}
/* Sur mobile l'aperçu intégré d'un PDF est souvent inutilisable (plusieurs navigateurs
affichent juste une zone vide) : on le garde plus court, les deux boutons au-dessus
restant le vrai chemin d'accès au document */
@media (max-width: 700px) {
.synthese-viewer {
height: 60vh;
min-height: 360px;
}
.synthese-page .pdf-viewer-toolbar {
flex-direction: column;
align-items: flex-start;
}
}