Compare commits
21
Commits
0c5ce36608
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f29ee15f1 | ||
|
|
a98dd4f58e | ||
|
|
08875d3b5a | ||
|
|
4862d61ccd | ||
|
|
5057a7be2f | ||
|
|
9dfa10f0d7 | ||
|
|
6fc5d01d30 | ||
|
|
0287b66f89 | ||
|
|
128386578e | ||
|
|
388e282cd4 | ||
|
|
85dba4341e | ||
|
|
6c91a3344b | ||
|
|
92f04a44f6 | ||
|
|
05ce889300 | ||
|
|
2953bad5e8 | ||
|
|
787576424a | ||
|
|
8168584d21 | ||
|
|
bcc1ffc945 | ||
|
|
4bef500407 | ||
|
|
04479ba198 | ||
|
|
fac36a9f56 |
No files matched your search
+13
@@ -0,0 +1,13 @@
|
||||
# macOS
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
# Éditeurs et outils locaux
|
||||
.vscode/
|
||||
.idea/
|
||||
.nexus/
|
||||
*.iml
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
@@ -11,8 +11,8 @@ La réutilisation du projet est autorisée, sous réserve que les fichiers `idCo
|
||||
## Informations techniques
|
||||
|
||||
- La base de données peut être restaurée rapidement à l'aide du fichier `scriptDatabase.sql`.
|
||||
- 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.
|
||||
- Le site est développé en PHP, sans framework, avec MySQL/MariaDB pour le stockage.
|
||||
- Le déploiement se fait via Docker (voir le dossier `docker/`).
|
||||
|
||||
## Signalement de vulnérabilité
|
||||
|
||||
|
||||
+233
-103
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
session_start();
|
||||
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') {
|
||||
@@ -17,6 +23,11 @@ if (isset($_SESSION['captcha_valid']) && $_SESSION['captcha_valid'] === true) {
|
||||
// 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é',
|
||||
@@ -147,40 +158,44 @@ $normalSenders = [
|
||||
'Service facturation'
|
||||
];
|
||||
|
||||
// Liste des sujets spam
|
||||
// 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 = [
|
||||
'URGENT !!! VOTRE COMPTE SERA SUPPRIMÉ AUJOURD’HUI',
|
||||
'FÉLICITATIONS !!! VOUS AVEZ GAGNÉ 5000 € MAINTENANT',
|
||||
'CLIQUEZ ICI IMMÉDIATEMENT POUR ÉVITER LE BLOCAGE',
|
||||
'VOTRE COLIS EST BLOQUÉ, PAYEZ TOUT DE SUITE',
|
||||
'DERNIÈRE ALERTE : CONFIRMEZ VOTRE MOT DE PASSE',
|
||||
'ACTION OBLIGATOIRE DANS LES 5 MINUTES',
|
||||
'VOTRE BANQUE EXIGE UNE VÉRIFICATION IMMÉDIATE',
|
||||
'RÉCUPÉREZ VOTRE CADEAU GRATUIT MAINTENANT',
|
||||
'ALERTE SÉCURITÉ : VOTRE IDENTITÉ EST EN DANGER',
|
||||
'VOUS ÊTES LE GRAND GAGNANT DU JOUR',
|
||||
'PAIEMENT REFUSÉ !!! METTEZ À JOUR VOTRE CARTE',
|
||||
'CONFIRMEZ VOS INFORMATIONS SINON FERMETURE',
|
||||
'OFFRE SECRÈTE EXCEPTIONNELLE À RÉCUPÉRER',
|
||||
'VOTRE SESSION EXPIRE, CLIQUEZ MAINTENANT',
|
||||
'REMBOURSEMENT IMMÉDIAT DISPONIBLE ICI',
|
||||
'ATTENTION !!! COMPTE BLOQUÉ DANS QUELQUES MINUTES',
|
||||
'DERNIÈRE CHANCE AVANT SUPPRESSION DÉFINITIVE',
|
||||
'VOTRE MOT DE PASSE A ÉTÉ PIRATÉ, AGISSEZ',
|
||||
'VÉRIFICATION URGENTE DE VOTRE CARTE BANCAIRE',
|
||||
'CONFIRMEZ VOTRE IDENTITÉ POUR RECEVOIR VOTRE ARGENT'
|
||||
['text' => 'URGENT !!! VOTRE COMPTE SERA SUPPRIMÉ AUJOURD’HUI', '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 à l’action 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 d’un 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 d’informations bancaires par email'],
|
||||
['text' => 'CONFIRMEZ VOTRE IDENTITÉ POUR RECEVOIR VOTRE ARGENT', 'reason' => 'Promesse d’argent contre des données personnelles'],
|
||||
];
|
||||
|
||||
// Liste des expéditeurs spam
|
||||
// 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 = [
|
||||
'service-urgent@compte-bloque-now.biz',
|
||||
'cadeau-gratuit@super-offre-win.click',
|
||||
'banque-alerte@verification-securite.top',
|
||||
'colis-stop@paiement-obligatoire.help',
|
||||
'admin-compte@action-immediate.world',
|
||||
'support-prime@argent-rapide-fast.support',
|
||||
'verification@motdepasse-danger.win',
|
||||
'alerte@compte-fermeture.click'
|
||||
'Sécurité du compte',
|
||||
'Service client',
|
||||
'Banque familiale',
|
||||
'Livraison Colis',
|
||||
'Support technique',
|
||||
'Service facturation',
|
||||
'Assurance Habitation',
|
||||
'Mutuelle Santé'
|
||||
];
|
||||
|
||||
// Liste des aperçus normaux
|
||||
@@ -216,6 +231,10 @@ function firstLetterSafe(string $text): string
|
||||
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,
|
||||
@@ -233,18 +252,20 @@ function buildInboxRound(
|
||||
'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 => $subject) {
|
||||
foreach ($spamSubjects as $index => $spam) {
|
||||
$spamPool[] = [
|
||||
'id' => 'spam_' . $index,
|
||||
'sender' => $spamSenders[array_rand($spamSenders)],
|
||||
'subject' => $subject,
|
||||
'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))
|
||||
];
|
||||
}
|
||||
@@ -252,15 +273,25 @@ function buildInboxRound(
|
||||
shuffle($normalPool);
|
||||
shuffle($spamPool);
|
||||
|
||||
$selected = array_slice($normalPool, 0, 9);
|
||||
$selected[] = $spamPool[0];
|
||||
// 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_id' => $spamPool[0]['id'],
|
||||
'correct_ids' => $correctIds,
|
||||
'emails' => $selected,
|
||||
'attempts_left' => $maxAttempts
|
||||
'attempts_left' => $maxAttempts,
|
||||
'started_at' => microtime(true)
|
||||
];
|
||||
}
|
||||
|
||||
@@ -280,8 +311,15 @@ $round = $_SESSION['captcha_round'];
|
||||
$error = '';
|
||||
$success = false;
|
||||
$locked = false;
|
||||
$foundReasons = [];
|
||||
|
||||
if (!isset($round['emails']) || !is_array($round['emails'])) {
|
||||
// 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,
|
||||
@@ -297,27 +335,66 @@ if (!isset($round['emails']) || !is_array($round['emails'])) {
|
||||
// Gestion de la soumission du formulaire
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$postedToken = $_POST['captcha_token'] ?? '';
|
||||
$selectedMail = $_POST['selected_mail'] ?? '';
|
||||
|
||||
$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 n’as plus d’essais. Recharge une nouvelle série.';
|
||||
} elseif ($selectedMail !== $round['correct_id']) {
|
||||
} 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 n’as plus d’essais. Recharge une nouvelle série.'
|
||||
: 'Ce n’est 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 n’as plus d’essais. Recharge une nouvelle série.'
|
||||
: 'Ce n’est 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 n’as plus d’essais. Recharge une nouvelle série.';
|
||||
sendLog('Captcha raté : plus aucun essai restant (possible robot)');
|
||||
} else {
|
||||
$error = 'Ce n’est pas le bon mail. Regarde mieux le message le plus agressif ou trop urgent.';
|
||||
$error = 'Ce n’est 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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,16 +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="stylesheet" href="./styles/captcha.css">
|
||||
<!-- 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="<?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 trouvé le mail frauduleux. Redirection en cours...</p>
|
||||
<p>Bravo, tu as repéré le(s) mail(s) frauduleux. Redirection en cours...</p>
|
||||
HTML;
|
||||
|
||||
// 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>
|
||||
@@ -347,9 +452,14 @@ $attemptsLeft = $round['attempts_left'] ?? 0;
|
||||
<script>
|
||||
setTimeout(function () {
|
||||
window.location.href = './index.php';
|
||||
}, 1800);
|
||||
}, 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>
|
||||
|
||||
@@ -357,31 +467,49 @@ $attemptsLeft = $round['attempts_left'] ?? 0;
|
||||
<div>
|
||||
<h1 class="captcha-title">Vérification humaine</h1>
|
||||
<p class="captcha-subtitle">
|
||||
Pour continuer, cliquez sur le <strong>mail frauduleux</strong> parmi les 10 messages ci-dessous.
|
||||
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>Trouver le spam</strong>
|
||||
<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>
|
||||
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">
|
||||
@@ -389,50 +517,47 @@ $attemptsLeft = $round['attempts_left'] ?? 0;
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="mailbox-label">Test anti-robot : trouvez le mail qui ressemble à une arnaque</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 $mail): ?>
|
||||
<label class="mail-option <?php echo $locked ? 'mail-option-disabled' : ''; ?>">
|
||||
<input
|
||||
type="radio"
|
||||
name="selected_mail"
|
||||
value="<?php echo htmlspecialchars($mail['id'], ENT_QUOTES, 'UTF-8'); ?>"
|
||||
<?php echo $locked ? 'disabled' : 'required'; ?>
|
||||
>
|
||||
<span class="mail-row">
|
||||
<span class="mail-avatar">
|
||||
<?php echo htmlspecialchars(firstLetterSafe($mail['sender']), ENT_QUOTES, 'UTF-8'); ?>
|
||||
</span>
|
||||
HTML;
|
||||
|
||||
<span class="mail-sender">
|
||||
<?php echo htmlspecialchars($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-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 '<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-time">
|
||||
<?php echo htmlspecialchars($mail['time'], ENT_QUOTES, 'UTF-8'); ?>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
echo '</div>';
|
||||
echo '</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') . '">';
|
||||
|
||||
// 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">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name="captcha_token"
|
||||
value="<?php echo htmlspecialchars($round['token'], ENT_QUOTES, 'UTF-8'); ?>"
|
||||
>
|
||||
|
||||
<div class="captcha-footer">
|
||||
<p class="captcha-note">
|
||||
@@ -440,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>
|
||||
+124
-22
@@ -1,16 +1,11 @@
|
||||
-- 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'@'%' IDENTIFIED BY 'mdp';
|
||||
|
||||
-- On met les droits au compte sur la base portfolio à l'utilisateur
|
||||
-- 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'@'%';
|
||||
|
||||
-- On actualise les permissions
|
||||
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,
|
||||
@@ -18,43 +13,150 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
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,
|
||||
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,
|
||||
markdown LONGTEXT NULL,
|
||||
link_url VARCHAR(255) NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
-- Compte de test (PLUS A JOUR METTRE UN HASH)
|
||||
-- INSERT INTO users (username, password, role)
|
||||
-- VALUES ('admin', 'admin', 'admin');
|
||||
insert into `users` (`created_at`, `id`, `last_login`, `password`, `role`, `username`)
|
||||
values ('2026-06-08 11:10:21', 11, '2026-06-08 13:10:41', '$2y$12$x01fDqrHQEYIyj7MDHa72eRyl2j/n5cNq9sv3.FIiQCaRsB50Q4iK', '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)
|
||||
);
|
||||
|
||||
-- Ajout d'une collonne status pour les projets et les expériences pour pouvoir différencier les projets/expériences en cours, terminés ou à venir
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN status VARCHAR(100) NULL AFTER title;
|
||||
ALTER TABLE experiences
|
||||
ADD COLUMN status VARCHAR(100) NULL AFTER title;
|
||||
-- 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)
|
||||
);
|
||||
|
||||
-- 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é)
|
||||
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
|
||||
);
|
||||
@@ -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
|
||||
Binary file not shown.
@@ -1,9 +1,33 @@
|
||||
<?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
|
||||
// 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">
|
||||
<p class="footer-copyright">
|
||||
@@ -15,7 +39,47 @@
|
||||
<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>
|
||||
</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>
|
||||
+99
-15
@@ -6,32 +6,103 @@
|
||||
|
||||
|
||||
// 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 des logs sur toutes les pages
|
||||
require_once('../functions/sendLogs.php');
|
||||
?>
|
||||
|
||||
<!-- Ajout du CSS Primaire (fait par IA) -->
|
||||
<link rel="stylesheet" href="../styles/primary_css.css">
|
||||
// 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';
|
||||
|
||||
// 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>
|
||||
|
||||
<!-- 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://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://cdn.eldorianet.work/i/c2cc96aa-1560-4062-ba4d-b455098af627.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>
|
||||
(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">
|
||||
|
||||
<!-- 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,
|
||||
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="./../pages/projects.php">Projets</a>
|
||||
<a href="./../pages/xp.php">Expériences</a>
|
||||
<a href="./../pages/skills.php">Compétences</a>
|
||||
<a href="./../pages/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') {
|
||||
@@ -40,7 +111,7 @@ require_once('../functions/sendLogs.php');
|
||||
?>
|
||||
|
||||
<!-- Lien vers le panneau admin -->
|
||||
<a href="./../pages/admin.php">Administration</a>
|
||||
<a href="./../pages/admin.php" class="<?php echo ($currentPage === 'admin.php') ? 'active' : ''; ?>">Administration</a>
|
||||
|
||||
<?php
|
||||
}
|
||||
@@ -51,18 +122,31 @@ require_once('../functions/sendLogs.php');
|
||||
|
||||
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="./../pages/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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>';
|
||||
}
|
||||
@@ -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, status, 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, status, 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,10 +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...)
|
||||
@@ -90,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>';
|
||||
@@ -144,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>';
|
||||
|
||||
@@ -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>';
|
||||
}
|
||||
@@ -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>';
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<?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 du bandeau en thème sombre. On leur laisse la couleur par défaut, qui suit le
|
||||
// thème actif (voir .logo-tile i:not(.colored) dans styles/primary_css.css).
|
||||
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>';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ function afficherUsersAdmin()
|
||||
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"] . '" class="btn-submit btn-delete" onclick="return confirm(\'Supprimer cet utilisateur ?\')">Supprimer</a>';
|
||||
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>';
|
||||
|
||||
+39
-6
@@ -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, status, 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,15 @@ function recupererXPParId($id) {
|
||||
global $dbh;
|
||||
|
||||
try {
|
||||
// Préparation de la requête préparée
|
||||
$stmt = $dbh->prepare('SELECT id, title, status, 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();
|
||||
@@ -58,12 +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 = [];
|
||||
|
||||
|
||||
@@ -109,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>';
|
||||
|
||||
|
||||
@@ -175,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>';
|
||||
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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>';
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
+47
-2
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
// On cache les webhooks discord => Correction de faille de sécurité
|
||||
require_once('../../idDiscord.php');
|
||||
// __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');
|
||||
@@ -8,7 +10,18 @@ require_once('../../idDiscord.php');
|
||||
|
||||
// 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 != "") {
|
||||
@@ -16,6 +29,8 @@ function sendLog($action, $user="") {
|
||||
} 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
|
||||
@@ -28,6 +43,36 @@ function sendLog($action, $user="") {
|
||||
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);
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
<?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
|
||||
|
||||
// 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');
|
||||
|
||||
+639
-49
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
// Titre de l'onglet
|
||||
$pageTitle = 'Administration';
|
||||
|
||||
// On ajoute ici le header
|
||||
require_once('./../elements/header.php');
|
||||
|
||||
@@ -7,6 +10,11 @@ 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');
|
||||
require_once('../functions/presentationAuth.php');
|
||||
|
||||
|
||||
// L'utilisateur doit être administrateur
|
||||
@@ -18,39 +26,102 @@ 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';
|
||||
|
||||
// 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
|
||||
?>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- en envoie les données à addProject.php -->
|
||||
<form method="POST" action="<?php echo $projetAModifier ? '../scripts/updateProject.php' : '../scripts/addProject.php'; ?>">
|
||||
<!-- 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-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>
|
||||
<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>
|
||||
|
||||
<?php if ($projetAModifier): ?>
|
||||
<input type="hidden" name="id" value="<?php echo (int) $projetAModifier['id']; ?>">
|
||||
<?php endif; ?>
|
||||
<!-- ========== 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" 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
|
||||
// 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']) : ''; ?>">
|
||||
|
||||
<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,32 +131,74 @@ 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 Description complète en **markdown**..."><?php echo $projetAModifier ? htmlspecialchars($projetAModifier['markdown'] ?? '') : ''; ?></textarea>
|
||||
|
||||
<label>Document PDF (optionnel, utilisé si pas de markdown)</label>
|
||||
<?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>
|
||||
<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']; ?>">
|
||||
<?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']) : ''; ?>">
|
||||
|
||||
<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,45 +207,400 @@ 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 Description complète en **markdown**..."><?php echo $xpAModifier ? htmlspecialchars($xpAModifier['markdown'] ?? '') : ''; ?></textarea>
|
||||
|
||||
<label>Document PDF (optionnel, utilisé si pas de markdown)</label>
|
||||
<?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>
|
||||
<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>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
</div>
|
||||
<!-- 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
|
||||
// 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();
|
||||
|
||||
// Création de compte
|
||||
// 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'] . '">';
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Formulaire de création de compte -->
|
||||
<div class="project-container">
|
||||
<div class="project-form-card">
|
||||
<div class="project-header">
|
||||
<h2>Nouveau compte</h2>
|
||||
<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 Description complète en **markdown**..."><?php echo $ecoleAModifier ? htmlspecialchars($ecoleAModifier['markdown'] ?? '') : ''; ?></textarea>
|
||||
|
||||
<label>Document PDF (optionnel, utilisé si pas de markdown)</label>
|
||||
<?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>
|
||||
<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
|
||||
// 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']) : ''; ?>">
|
||||
|
||||
<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 fa-solid fa-terminal|Bash 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 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  -->
|
||||
<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 Texte de présentation en **markdown**... "><?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>
|
||||
<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
|
||||
// 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">
|
||||
|
||||
<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>
|
||||
<textarea name="role" required></textarea>
|
||||
<input type="text" name="role" placeholder="user" required>
|
||||
|
||||
<!-- mot de passe -->
|
||||
<label>Mot de passe</label>
|
||||
@@ -142,14 +610,136 @@ if (isset($_GET['edit_xp']) && is_numeric($_GET['edit_xp'])) {
|
||||
<input type="submit" name="submituser" value="Ajouter l'utilisateur" class="btn-submit">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste des utilisateurs -->
|
||||
<h2 class="projects-title">Gérer les utilisateurs</h2>
|
||||
</details>
|
||||
|
||||
<!-- Affichage de la liste des utilisateurs -->
|
||||
<?php
|
||||
afficherUsersAdmin();
|
||||
<?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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Bouton "Insérer une image" du mode présentation : ajoute la syntaxe markdown d'une
|
||||
image () à 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 = '';
|
||||
|
||||
// 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'); ?>
|
||||
+14
-15
@@ -1,11 +1,15 @@
|
||||
<?php
|
||||
|
||||
// CGU / CDU
|
||||
|
||||
// Titre de l'onglet
|
||||
$pageTitle = 'Conditions générales d\'utilisation';
|
||||
|
||||
require_once('./../elements/header.php');
|
||||
|
||||
?>
|
||||
|
||||
<main class="cgu-page">
|
||||
<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>
|
||||
@@ -23,41 +27,36 @@ require_once('./../elements/header.php');
|
||||
<p>L’ensemble 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 l’intelligence artificielle</h3>
|
||||
<p>Le site et certains de ses éléments ont pu être conçus avec l'assistance d'outils d’intelligence artificielle. Cela peut notamment concerner la génération ou l’adaptation de feuilles de style CSS, l’aide à la rédaction, l’autocomplé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, l’architecture générale, l’inté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>
|
||||
<h3> 4. 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 l’entreprise TerraTech Hebergement, en collaboration avec l’association 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>
|
||||
<h3> 5. Exactitude des informations</h3>
|
||||
<p>L’éditeur s’efforce de fournir des informations aussi exactes et à jour que possible. Toutefois, aucune garantie n’est donnée quant à l’exactitude, l’exhaustivité ou l’actualité des contenus publiés.</p>
|
||||
<p>L’utilisateur reconnaît utiliser les informations disponibles sur le site sous sa seule responsabilité.</p>
|
||||
|
||||
<h3> 7. Liens externes</h3>
|
||||
<h3> 6. 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>
|
||||
<h3> 7. Responsabilité</h3>
|
||||
<p>L’éditeur ne pourra être tenu responsable des dommages directs ou indirects résultant de l’utilisation du site, d’une interruption temporaire, d’un dysfonctionnement technique ou d’une impossibilité d’accès.</p>
|
||||
<p>L’utilisateur demeure seul responsable de l’usage qu’il fait du site et des informations qu’il y consulte.</p>
|
||||
|
||||
<h3> 9. Données personnelles</h3>
|
||||
<h3> 8. Données personnelles</h3>
|
||||
<p>Le site peut proposer un ou plusieurs formulaires de contact. Les données éventuellement transmises par l’utilisateur sont uniquement destinées au traitement de sa demande, sauf mention contraire clairement indiquée.</p>
|
||||
<p>Conformément à la réglementation applicable, l’utilisateur peut demander l’accès, la rectification ou la suppression de ses données en contactant l’éditeur du site.</p>
|
||||
|
||||
<h3> 10. Sécurité</h3>
|
||||
<h3> 9. Sécurité</h3>
|
||||
<p>L’utilisateur s’interdit de perturber le bon fonctionnement du site, d’introduire des programmes malveillants, de tenter un accès non autorisé ou de porter atteinte à l’intégrité technique du service.</p>
|
||||
|
||||
<h3> 11. Modification des CGU</h3>
|
||||
<h3> 10. 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>
|
||||
<h3> 11. 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>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
require_once('../elements/footer.php');
|
||||
|
||||
+29
-8
@@ -1,19 +1,41 @@
|
||||
<?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
|
||||
// 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 -->
|
||||
@@ -32,8 +54,7 @@ require_once("../elements/header.php");
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
// Ajout du footer
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
<?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
|
||||
// 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>
|
||||
|
||||
<?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">';
|
||||
|
||||
if (!empty($xptype)) {
|
||||
echo '<span><i class="fa-solid fa-tag"></i> ' . $xptype . '</span>';
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
</article>
|
||||
|
||||
</div>
|
||||
|
||||
<?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');
|
||||
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>
|
||||
JS;
|
||||
}
|
||||
?>
|
||||
|
||||
<?php
|
||||
// Ajout du footer
|
||||
require_once('../elements/footer.php'); ?>
|
||||
+98
-10
@@ -3,28 +3,100 @@
|
||||
// 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');
|
||||
|
||||
// Ajout d'un texte pour rappeller aux utilisateurs de la démo de vider le cache si du CSS s'affiche mal
|
||||
// La notification est maintenant dynamique : modifiable depuis le panel Admin (table settings)
|
||||
require_once('../functions/listSettings.php');
|
||||
|
||||
//Si du CSS / ou des images s'affichent mal, il faut penser à vider le cache, version de développement uniquement.
|
||||
// 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();
|
||||
?>
|
||||
<!-- Message d'alerte pour les utilisateurs de la démo -->
|
||||
<p class="dev-cache-notice">
|
||||
<strong>Version de développement uniquement.</strong>
|
||||
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.
|
||||
|
||||
<!-- 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
|
||||
// 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">';
|
||||
|
||||
// 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">
|
||||
<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 du portfolio.</p>
|
||||
<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">
|
||||
@@ -50,7 +122,7 @@ require_once('./../elements/header.php');
|
||||
</div>
|
||||
<h3>Compétences</h3>
|
||||
<!-- Page où il y aura toutes mes compétences -->
|
||||
<p>PAS ENCORE DEV</p>
|
||||
<p>Systèmes, réseaux et développement.</p>
|
||||
</a>
|
||||
|
||||
<a href="../pages/contact.php" class="quick-link-card">
|
||||
@@ -63,6 +135,22 @@ require_once('./../elements/header.php');
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?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
|
||||
require_once('../elements/footer.php');
|
||||
|
||||
+13
-6
@@ -3,7 +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');
|
||||
@@ -14,11 +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' : '';
|
||||
|
||||
?>
|
||||
|
||||
<body>
|
||||
|
||||
<!-- CSS Login (fait par Intelligence Arti) -->
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
|
||||
@@ -27,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">
|
||||
@@ -38,8 +47,6 @@ if (isset($_SESSION['user_id'])){
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
<?php
|
||||
|
||||
// Ajout du footer
|
||||
|
||||
@@ -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 : 
|
||||
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');
|
||||
}
|
||||
+7
-2
@@ -3,18 +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
|
||||
|
||||
|
||||
+17
-31
@@ -1,50 +1,36 @@
|
||||
<?php
|
||||
|
||||
// Compétences
|
||||
// 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="container">
|
||||
<div class="skills-header">
|
||||
<h1>Compétences</h1>
|
||||
<p>Un aperçu des technologies et des domaines que je maîtrise.</p>
|
||||
</div>
|
||||
|
||||
<h2><i class="fa-solid fa-computer"></i> Systèmes d'exploitation :</h2>
|
||||
<ul>
|
||||
<li><i class="fa-brands fa-microsoft"></i> Windows (Server/XP 8 10 11)</li>
|
||||
<li><i class="fa-brands fa-linux"></i> Linux</li>
|
||||
<li><i class="fa-brands fa-apple"></i> macOS</li>
|
||||
</ul>
|
||||
|
||||
<h2>Réseaux :</h2>
|
||||
<ul>
|
||||
<li>Adressage IP</li>
|
||||
<li>VLAN</li>
|
||||
<li><i class="fa-brands fa-cloudflare"></i> Pare-feu</li>
|
||||
<li>DNS (Bind9)</li>
|
||||
<li>DHCP (ISC DHCP)</li>
|
||||
<li>Proxy (Proxy Squid)</li>
|
||||
</ul>
|
||||
|
||||
<h2>Code :</h2>
|
||||
<ul>
|
||||
<li>HTML</li>
|
||||
<li>CSS</li>
|
||||
<li>JavaScript</li>
|
||||
<li>PHP</li>
|
||||
</ul>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<?php
|
||||
require_once('../functions/listSkills.php');
|
||||
|
||||
// Appel de ma fonction dans Functions/listSkills.php
|
||||
afficherSkills();
|
||||
?>
|
||||
|
||||
</section>
|
||||
|
||||
<?php
|
||||
|
||||
|
||||
@@ -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&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');
|
||||
?>
|
||||
+64
-2
@@ -2,19 +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');
|
||||
@@ -22,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'); ?>
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
?>
|
||||
@@ -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;
|
||||
}
|
||||
?>
|
||||
+9
-1
@@ -1,8 +1,10 @@
|
||||
<?php
|
||||
// Démarrage de la session pour vérifier les droits de l'utilisateur
|
||||
session_start();
|
||||
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") {
|
||||
@@ -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();
|
||||
|
||||
// Si le formulaire a été soumis
|
||||
if ($_SERVER["REQUEST_METHOD"] == "POST") {
|
||||
// Récupération des données du formulaire
|
||||
@@ -58,6 +63,9 @@ if ($_SERVER["REQUEST_METHOD"] == "POST") {
|
||||
":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();
|
||||
|
||||
+51
-10
@@ -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');
|
||||
|
||||
+20
-3
@@ -3,11 +3,14 @@
|
||||
// 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');
|
||||
@@ -29,10 +32,17 @@ try {
|
||||
|
||||
// On regarde si le mdp correspond à celui en bdd
|
||||
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'];
|
||||
@@ -46,7 +56,14 @@ try {
|
||||
error_log('Le serveur WEB ne peut pas modifier la date de dernière connexion : ' . $e->getMessage());
|
||||
}
|
||||
|
||||
//Redirection vers home.php puisque que l'utilisateur est forcément connecté
|
||||
header('Location: ./../pages/home.php');
|
||||
// Log Discord : connexion réussie
|
||||
sendLog("Connexion réussie", $user['username']);
|
||||
|
||||
// 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)
|
||||
$pageRetour = ($_POST['redirect'] ?? '') === 'presentation' ? 'presentation.php' : 'home.php';
|
||||
header('Location: ./../pages/' . $pageRetour . '?login=1');
|
||||
exit;
|
||||
?>
|
||||
+11
-3
@@ -2,8 +2,14 @@
|
||||
|
||||
// Lorsque l'utilisateur est renvoyé à cette page/fonction, il est déconnecté
|
||||
|
||||
// Démarrage de session
|
||||
session_start();
|
||||
// Démarrage de session (cookie durci)
|
||||
require_once(__DIR__ . '/../functions/security.php');
|
||||
|
||||
// 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;
|
||||
@@ -15,10 +21,12 @@ session_destroy();
|
||||
// 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
|
||||
header('Location: ../index.php');
|
||||
// (?logout=1 déclenche la notification "Vous avez bien été déconnecté" sur la page)
|
||||
header('Location: ../pages/home.php?logout=1');
|
||||
exit;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,8 +1,10 @@
|
||||
<?php
|
||||
// Démarrage de la session pour vérifier les droits de l'utilisateur
|
||||
session_start();
|
||||
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") {
|
||||
@@ -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();
|
||||
|
||||
// Récupération de l'ID passé en paramètre GET
|
||||
$id = $_GET["id"];
|
||||
|
||||
@@ -26,6 +31,9 @@ if ($id == "") {
|
||||
":id" => $id
|
||||
]);
|
||||
|
||||
// Log Discord
|
||||
sendLog("Suppression du compte id " . $id);
|
||||
|
||||
// Redirection vers la page admin après suppression
|
||||
header("Location: ../pages/admin.php");
|
||||
exit();
|
||||
|
||||
+10
-1
@@ -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;
|
||||
@@ -1,6 +1,11 @@
|
||||
<?php
|
||||
|
||||
if (!isset($_SESSION['captcha_valid']) || $_SESSION['captcha_valid'] !== true) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
// 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 = ID_DISCORD2; // URL du webhook Discord
|
||||
// EXEMPLE de a quoi ressemble idDiscord :
|
||||
@@ -55,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.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,81 @@
|
||||
<?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'
|
||||
];
|
||||
} 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'] ?? ''),
|
||||
'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",
|
||||
'presentation' => 'Modification du contenu du mode présentation'
|
||||
];
|
||||
sendLog($libellesGroupes[$groupeReglages] ?? "Modification des réglages du site");
|
||||
|
||||
// On renvoie l'utilisateur sur admin.php
|
||||
header('Location: ../pages/admin.php');
|
||||
exit;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
+203
-33
@@ -1,9 +1,9 @@
|
||||
body {
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(108, 99, 255, 0.18), transparent 35%),
|
||||
#0d0d0d;
|
||||
radial-gradient(circle at top, rgba(255, 122, 26, 0.16), transparent 35%),
|
||||
#0a0a10;
|
||||
margin: 0;
|
||||
font-family: Arial, sans-serif;
|
||||
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Arial, sans-serif;
|
||||
color: white;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
@@ -21,10 +21,22 @@ body {
|
||||
.captcha-card,
|
||||
.success-screen {
|
||||
background: linear-gradient(180deg, #111116 0%, #161622 100%);
|
||||
border: 1px solid #2a2a3e;
|
||||
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 {
|
||||
@@ -33,9 +45,9 @@ body {
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(108, 99, 255, 0.14);
|
||||
border: 1px solid rgba(108, 99, 255, 0.28);
|
||||
color: #9d97ff;
|
||||
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;
|
||||
@@ -63,10 +75,14 @@ body {
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.captcha-subtitle strong {
|
||||
color: #ffa155;
|
||||
}
|
||||
|
||||
.captcha-target {
|
||||
min-width: 240px;
|
||||
background-color: #1a1a24;
|
||||
border: 1px solid #2a2a3e;
|
||||
border: 1px solid #262638;
|
||||
border-radius: 18px;
|
||||
padding: 18px;
|
||||
}
|
||||
@@ -82,7 +98,7 @@ body {
|
||||
|
||||
.captcha-target strong {
|
||||
font-size: 22px;
|
||||
color: #8f88ff;
|
||||
color: #ffa155;
|
||||
}
|
||||
|
||||
.captcha-stats {
|
||||
@@ -95,6 +111,9 @@ body {
|
||||
}
|
||||
|
||||
.captcha-attempts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
background-color: #171b28;
|
||||
border: 1px solid #283049;
|
||||
border-radius: 14px;
|
||||
@@ -115,6 +134,28 @@ body {
|
||||
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;
|
||||
@@ -124,14 +165,15 @@ body {
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
background-color: #2a314a;
|
||||
border: 1px solid #3d4767;
|
||||
transition: transform 0.2s ease, background-color 0.2s ease;
|
||||
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: #394362;
|
||||
background-color: #34344a;
|
||||
border-color: rgba(255, 122, 26, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@@ -143,6 +185,15 @@ body {
|
||||
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 {
|
||||
@@ -199,10 +250,25 @@ body {
|
||||
|
||||
.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 {
|
||||
@@ -211,9 +277,15 @@ body {
|
||||
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: 48px minmax(120px, 220px) 1fr 70px;
|
||||
grid-template-columns: 26px 48px minmax(120px, 220px) 1fr 70px;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 16px 18px;
|
||||
@@ -232,16 +304,38 @@ body {
|
||||
}
|
||||
|
||||
.mail-option input:checked + .mail-row {
|
||||
background-color: rgba(108, 99, 255, 0.14);
|
||||
box-shadow: inset 0 0 0 1px rgba(108, 99, 255, 0.35);
|
||||
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, #2f3550 0%, #1f2334 100%);
|
||||
border: 1px solid #303650;
|
||||
background: linear-gradient(180deg, #2a2a3a 0%, #1a1a24 100%);
|
||||
border: 1px solid #303040;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -287,6 +381,16 @@ body {
|
||||
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;
|
||||
@@ -308,7 +412,7 @@ body {
|
||||
}
|
||||
|
||||
.captcha-submit {
|
||||
background: linear-gradient(180deg, #7a72ff 0%, #5e56eb 100%);
|
||||
background: linear-gradient(180deg, #ff8c3a 0%, #e8630a 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
@@ -317,12 +421,16 @@ body {
|
||||
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(94, 86, 235, 0.28);
|
||||
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(94, 86, 235, 0.34);
|
||||
box-shadow: 0 16px 30px rgba(232, 99, 10, 0.4);
|
||||
}
|
||||
|
||||
.captcha-submit:active {
|
||||
transform: translateY(1px) scale(0.99);
|
||||
}
|
||||
|
||||
.success-mode {
|
||||
@@ -348,12 +456,60 @@ body {
|
||||
}
|
||||
|
||||
.success-screen p {
|
||||
margin: 0 auto 24px;
|
||||
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;
|
||||
@@ -368,8 +524,8 @@ body {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: linear-gradient(90deg, #62d26f 0%, #9cffaf 100%);
|
||||
animation: loadingBar 1.8s linear forwards;
|
||||
background: linear-gradient(90deg, #ffa155 0%, #ff7a1a 100%);
|
||||
animation: loadingBar 2.4s linear forwards;
|
||||
}
|
||||
|
||||
.success-burst {
|
||||
@@ -377,10 +533,10 @@ body {
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(circle at 20% 30%, rgba(255, 215, 0, 0.25), transparent 18%),
|
||||
radial-gradient(circle at 80% 20%, rgba(98, 210, 111, 0.22), transparent 18%),
|
||||
radial-gradient(circle at 30% 80%, rgba(122, 114, 255, 0.2), transparent 20%),
|
||||
radial-gradient(circle at 75% 70%, rgba(255, 105, 180, 0.16), transparent 18%);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -429,6 +585,22 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
@@ -440,7 +612,7 @@ body {
|
||||
}
|
||||
|
||||
.mail-row {
|
||||
grid-template-columns: 42px 1fr 56px;
|
||||
grid-template-columns: 22px 42px 1fr 56px;
|
||||
}
|
||||
|
||||
.mail-sender {
|
||||
@@ -482,15 +654,13 @@ body {
|
||||
}
|
||||
|
||||
.mail-row {
|
||||
grid-template-columns: 36px 1fr;
|
||||
grid-template-columns: 20px 36px 1fr;
|
||||
gap: 10px;
|
||||
padding: 14px 14px;
|
||||
}
|
||||
|
||||
.mail-time {
|
||||
grid-column: 2;
|
||||
text-align: left;
|
||||
margin-top: 4px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.success-screen h1 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+2215
-327
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user