Compare commits

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

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
# macOS
.DS_Store
**/.DS_Store
# Éditeurs et outils locaux
.vscode/
.idea/
.nexus/
*.iml
# Logs
*.log
logs/
+2 -2
View File
@@ -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é
+119 -92
View File
@@ -1,9 +1,12 @@
<?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') {
unset($_SESSION['captcha_round']);
@@ -180,16 +183,19 @@ $spamSubjects = [
['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
@@ -400,45 +406,61 @@ $attemptsLeft = $round['attempts_left'] ?? 0;
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test anti-robot</title>
<link rel="icon" type="image/png" href="./memoji.png">
<!-- Favicon servie par le CDN (voir elements/header.php) -->
<link rel="icon" type="image/png" href="https://cdn.eldorianet.work/i/c2cc96aa-1560-4062-ba4d-b455098af627.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
<link rel="stylesheet" href="./styles/captcha.css">
<link rel="stylesheet" href="<?php echo htmlspecialchars(urlAsset('styles/captcha.css', './')); ?>">
</head>
<body class="<?php echo $success ? 'success-mode' : ''; ?>">
<main class="captcha-shell">
<?php if ($success): ?>
<section class="success-screen">
<?php
// Deux écrans possibles : la réussite (avec redirection automatique), ou le test lui-même.
if ($success) {
// --- Écran de réussite ---
echo <<<'HTML'
<section class="success-screen">
<div class="success-burst"></div>
<div class="success-icon">✅</div>
<h1>Vérification réussie</h1>
<p>Bravo, tu as repéré le(s) mail(s) frauduleux. Redirection en cours...</p>
HTML;
<?php if (!empty($foundReasons)): ?>
<div class="success-reasons">
<p class="success-reasons-title">Ce que tu as repéré :</p>
<ul>
<?php foreach ($foundReasons as $reason): ?>
<li><i class="reason-dot"></i><?php echo htmlspecialchars($reason, ENT_QUOTES, 'UTF-8'); ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
// Rappel de ce que le visiteur a correctement identifié, s'il y a quelque chose à dire
if (!empty($foundReasons)) {
echo '<div class="success-reasons">';
echo '<p class="success-reasons-title">Ce que tu as repéré :</p>';
echo '<ul>';
foreach ($foundReasons as $reason) {
echo '<li><i class="reason-dot"></i>' . htmlspecialchars($reason, ENT_QUOTES, 'UTF-8') . '</li>';
}
echo '</ul>';
echo '</div>';
}
echo <<<'HTML'
<div class="success-loader">
<span></span>
</div>
</section>
</section>
<script>
<script>
setTimeout(function () {
window.location.href = './index.php';
}, 2400);
</script>
<?php else: ?>
<form class="captcha-card" method="post" action="">
</script>
HTML;
} else {
// --- Écran du test ---
echo <<<'HTML'
<form class="captcha-card" method="post" action="">
<div class="captcha-badge">🛡️ Test anti-robot</div>
<div class="captcha-top">
@@ -459,23 +481,35 @@ $attemptsLeft = $round['attempts_left'] ?? 0;
<div class="captcha-stats">
<div class="captcha-attempts">
<span>Essais restants</span>
<strong><?php echo htmlspecialchars((string) $attemptsLeft, ENT_QUOTES, 'UTF-8'); ?> / <?php echo htmlspecialchars((string) $maxAttempts, ENT_QUOTES, 'UTF-8'); ?></strong>
<div class="attempts-dots">
<?php for ($i = 0; $i < $maxAttempts; $i++): ?>
<span class="<?php echo $i < $attemptsLeft ? 'dot-filled' : 'dot-empty'; ?>"></span>
<?php endfor; ?>
</div>
</div>
HTML;
<?php if ($locked): ?>
<a class="captcha-reset" href="./captcha.php?reload=1">Recharger une nouvelle série</a>
<?php endif; ?>
</div>
// Compteur d'essais restants
echo '<strong>' . htmlspecialchars((string) $attemptsLeft, ENT_QUOTES, 'UTF-8')
. ' / ' . htmlspecialchars((string) $maxAttempts, ENT_QUOTES, 'UTF-8') . '</strong>';
<?php if ($error !== ''): ?>
<div class="captcha-error"><?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?></div>
<?php endif; ?>
// Une pastille par essai : pleine tant qu'il reste des essais, vide ensuite
echo '<div class="attempts-dots">';
for ($i = 0; $i < $maxAttempts; $i++) {
echo '<span class="' . ($i < $attemptsLeft ? 'dot-filled' : 'dot-empty') . '"></span>';
}
echo '</div>';
echo '</div>';
// Plus d'essais disponibles : on propose une nouvelle série de mails
if ($locked) {
echo '<a class="captcha-reset" href="./captcha.php?reload=1">Recharger une nouvelle série</a>';
}
echo '</div>';
// Message d'erreur du dernier essai raté
if ($error !== '') {
echo '<div class="captcha-error">' . htmlspecialchars($error, ENT_QUOTES, 'UTF-8') . '</div>';
}
echo <<<'HTML'
<div class="mailbox">
<div class="mailbox-toolbar">
<div class="mailbox-dots">
@@ -487,51 +521,39 @@ $attemptsLeft = $round['attempts_left'] ?? 0;
</div>
<div class="mail-list">
<?php foreach ($round['emails'] as $index => $mail): ?>
<label class="mail-option <?php echo $locked ? 'mail-option-disabled' : ''; ?>" style="--mail-index: <?php echo (int) $index; ?>">
<input
type="checkbox"
name="selected_mails[]"
value="<?php echo htmlspecialchars($mail['id'], ENT_QUOTES, 'UTF-8'); ?>"
<?php echo $locked ? 'disabled' : ''; ?>
>
<span class="mail-row">
<span class="mail-check"><i class="fa-solid fa-check"></i></span>
HTML;
<span class="mail-avatar">
<?php echo htmlspecialchars(firstLetterSafe($mail['sender']), ENT_QUOTES, 'UTF-8'); ?>
</span>
// Un bloc "mail" cliquable par message de la série tirée au sort
foreach ($round['emails'] as $index => $mail) {
$desactive = $locked ? 'disabled' : '';
$classeOption = $locked ? 'mail-option mail-option-disabled' : 'mail-option ';
<span class="mail-sender">
<?php echo htmlspecialchars($mail['sender'], ENT_QUOTES, 'UTF-8'); ?>
</span>
echo '<label class="' . $classeOption . '" style="--mail-index: ' . (int) $index . '">';
echo '<input type="checkbox" name="selected_mails[]" value="'
. htmlspecialchars($mail['id'], ENT_QUOTES, 'UTF-8') . '" ' . $desactive . '>';
echo '<span class="mail-row">';
echo '<span class="mail-check"><i class="fa-solid fa-check"></i></span>';
echo '<span class="mail-avatar">' . htmlspecialchars(firstLetterSafe($mail['sender']), ENT_QUOTES, 'UTF-8') . '</span>';
echo '<span class="mail-sender">' . htmlspecialchars($mail['sender'], ENT_QUOTES, 'UTF-8') . '</span>';
echo '<span class="mail-content">';
echo '<span class="mail-subject">' . htmlspecialchars($mail['subject'], ENT_QUOTES, 'UTF-8') . '</span>';
echo '<span class="mail-preview">' . htmlspecialchars($mail['preview'], ENT_QUOTES, 'UTF-8') . '</span>';
echo '</span>';
echo '<span class="mail-time">' . htmlspecialchars($mail['time'], ENT_QUOTES, 'UTF-8') . '</span>';
echo '</span>';
echo '</label>';
}
<span class="mail-content">
<span class="mail-subject">
<?php echo htmlspecialchars($mail['subject'], ENT_QUOTES, 'UTF-8'); ?>
</span>
<span class="mail-preview">
<?php echo htmlspecialchars($mail['preview'], ENT_QUOTES, 'UTF-8'); ?>
</span>
</span>
echo '</div>';
echo '</div>';
<span class="mail-time">
<?php echo htmlspecialchars($mail['time'], ENT_QUOTES, 'UTF-8'); ?>
</span>
</span>
</label>
<?php endforeach; ?>
</div>
</div>
// Jeton de la série en cours : permet de vérifier les réponses côté serveur
echo '<input type="hidden" name="captcha_token" value="'
. htmlspecialchars($round['token'], ENT_QUOTES, 'UTF-8') . '">';
<input
type="hidden"
name="captcha_token"
value="<?php echo htmlspecialchars($round['token'], ENT_QUOTES, 'UTF-8'); ?>"
>
<!-- Champ piège (honeypot) : invisible et exclu du tabulateur pour un humain,
mais souvent rempli automatiquement par les robots qui remplissent tout -->
// Champ piège (honeypot) : invisible et exclu du tabulateur pour un humain,
// mais souvent rempli automatiquement par les robots qui remplissent tout
echo <<<'HTML'
<div class="captcha-honeypot" aria-hidden="true">
<label for="website">Laisse ce champ vide</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
@@ -543,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>
+81 -87
View File
@@ -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,63 +13,75 @@ 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;
-- Table de réglages du site (clé / valeur) : utilisée pour la notification de la page d'accueil
-- 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)
);
-- Valeurs par défaut de la notification (modifiables ensuite depuis le panel Admin)
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')
('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);
-- Table des compétences : une ligne = une catégorie (carte sur la page Compétences)
-- items : un item par ligne, au format "classes-icone-fontawesome|Texte" (l'icône est optionnelle : "Texte" seul fonctionne aussi)
-- 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,
@@ -85,7 +92,6 @@ CREATE TABLE IF NOT EXISTS skills (
PRIMARY KEY (id)
);
-- Compétences actuelles (reprises du CV)
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),
@@ -94,75 +100,63 @@ INSERT INTO skills (title, icon, items, position) VALUES
('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);
-- Table des écoles / formations : affichée sous les expériences professionnelles sur la page Expériences
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,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
-- markdown : contenu de la page détaillée (LONGTEXT = ~4 Go, taille pratiquement illimitée)
-- link_url : lien externe utilisé à la place du markdown si celui-ci est vide
-- Les deux sont facultatifs : si les deux sont vides, la carte n'est pas cliquable
ALTER TABLE projects
ADD COLUMN markdown LONGTEXT NULL,
ADD COLUMN link_url VARCHAR(255) NULL;
ALTER TABLE experiences
ADD COLUMN markdown LONGTEXT NULL,
ADD COLUMN link_url VARCHAR(255) NULL;
ALTER TABLE schools
ADD COLUMN markdown LONGTEXT NULL,
ADD COLUMN link_url VARCHAR(255) NULL;
-- ============================================================
-- Juillet 2026 (4) : bandeaux de logos sur la page d'accueil
-- ("Outils que je sais manipuler" et "Ils m'ont fait confiance"),
-- gérés depuis le panel Admin
-- ============================================================
-- Une seule table pour les deux bandeaux, distingués par la colonne category :
-- - 'tool' => bandeau "Outils que je sais manipuler"
-- - 'partner' => bandeau "Ils m'ont fait confiance"
-- link_url est facultatif : si renseigné, le logo devient cliquable (nouvel onglet)
-- 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,
logo_url VARCHAR(255) 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)
);
-- Table créée vide volontairement : les logos sont ajoutés depuis le panel Admin
-- (section Logos) pour éviter d'afficher des images devinées/potentiellement cassées
-- 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)
);
-- ============================================================
-- Juillet 2026 (3) : PDF en drag & drop pour les projets,
-- expériences et écoles, lu depuis un mini lecteur intégré
-- ============================================================
-- 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_data : contenu binaire du PDF (LONGBLOB = ~4 Go, largement suffisant)
-- pdf_filename : nom original du fichier (affiché dans le panel admin et le lecteur)
-- Priorité d'affichage sur la page de détails : markdown > pdf > lien externe
ALTER TABLE projects
ADD COLUMN pdf_data LONGBLOB NULL,
ADD COLUMN pdf_filename VARCHAR(255) NULL;
-- 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
);
ALTER TABLE experiences
ADD COLUMN pdf_data LONGBLOB NULL,
ADD COLUMN pdf_filename VARCHAR(255) NULL;
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
);
ALTER TABLE schools
ADD COLUMN pdf_data LONGBLOB NULL,
ADD COLUMN pdf_filename VARCHAR(255) NULL;
CREATE TABLE IF NOT EXISTS school_pdfs (
school_id INT UNSIGNED NOT NULL,
pdf_filename VARCHAR(255) NOT NULL,
pdf_data LONGBLOB NOT NULL,
PRIMARY KEY (school_id),
CONSTRAINT fk_school_pdfs_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE
);
+14
View File
@@ -0,0 +1,14 @@
; Config PHP pour le conteneur Docker (image php:8.2-apache)
; Copiée dans /usr/local/etc/php/conf.d/ par le Dockerfile généré par scriptsite.sh
; Augmente les limites d'upload pour permettre le dépôt de PDF depuis le panel admin
; post_max_size doit toujours être >= upload_max_filesize
upload_max_filesize = 20M
post_max_size = 20M
; Coupe l'affichage des erreurs PHP dans le HTML en production (sécurité : ne pas exposer
; les chemins serveur aux visiteurs) et les envoie dans les logs Apache/Docker à la place.
; Bonus : évite les warnings HTML qui provoquaient "headers already sent" une fois affichés
; avant le session_start()/header() des scripts.
display_errors = Off
log_errors = On
Binary file not shown.
+16 -8
View File
@@ -17,13 +17,16 @@ if (isset($_GET['success']) && $_GET['success'] === '1') {
?>
</main>
<?php if ($toast !== null): ?>
<!-- Notification toast : disparaît toute seule, clic pour fermer -->
<div class="toast" onclick="this.remove()" role="status">
<i class="fa-solid fa-circle-check"></i>
<span><?php echo $toast; ?></span>
</div>
<?php endif; ?>
<?php
// Notification toast : disparaît toute seule, clic pour fermer.
// Elle n'est affichée que si une des conditions plus haut a rempli $toast.
if ($toast !== null) {
echo '<div class="toast" onclick="this.remove()" role="status">';
echo '<i class="fa-solid fa-circle-check"></i>';
echo '<span>' . $toast . '</span>';
echo '</div>';
}
?>
<footer class="site-footer">
<div class="footer-content">
@@ -36,6 +39,10 @@ if (isset($_GET['success']) && $_GET['success'] === '1') {
<a href="https://www.instagram.com/ours_one_" target="_blank" rel="noopener noreferrer">Instagram</a>
<a href="https://git.eldorianet.work/esteban" target="_blank" rel="noopener noreferrer">Git</a>
<span class="discord-handle" title="@ours1" onclick="alert('Discord : @ours1')">Discord</span>
<a href="../pages/presentation.php">Mode présentation</a>
<!-- Le tableau de synthèse E4 est affiché dans le site (voir pages/synthese.php),
avec un bouton plein écran et un bouton de téléchargement au-dessus de l'aperçu -->
<a href="../pages/synthese.php">Tableau de synthèse Oral</a>
<a href="../pages/cgu.php">CGU</a>
</div>
</div>
@@ -52,7 +59,8 @@ if (isset($_GET['success']) && $_GET['success'] === '1') {
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'
'.page-header, .quick-links-header, .skills-header, .projects-title, .logo-banner-header, ' +
'.stat-item'
);
var observer = new IntersectionObserver(function (entries) {
+40 -6
View File
@@ -6,7 +6,7 @@
// 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');
@@ -14,11 +14,23 @@ require_once('../scripts/requireCaptcha.php');
// Ajout des logs sur toutes les pages
require_once('../functions/sendLogs.php');
// Versionnement des fichiers statiques (casse le cache navigateur à chaque modif du CSS)
require_once(__DIR__ . '/../functions/assets.php');
// Log de la visite de la page sur Discord (anti-spam : 1 log max par page / minute / visiteur)
sendPageLog();
// Titre de la page : chaque page peut définir $pageTitle avant d'inclure le header
$pageTitle = isset($pageTitle) ? $pageTitle . ' — Esteban MOUSSU' : 'Esteban MOUSSU — Portfolio';
$pageTitle = isset($pageTitle) ? $pageTitle . ' - Esteban MOUSSU' : 'Esteban MOUSSU - Portfolio';
// Description : chaque page peut définir $pageDescription avant d'inclure le header
// (sinon description générique, utilisée pour le SEO et les aperçus de partage)
$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']);
@@ -31,8 +43,26 @@ $currentPage = basename($_SERVER['PHP_SELF']);
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php echo htmlspecialchars($pageTitle); ?></title>
<!-- Favicon du site -->
<link rel="icon" type="image/png" href="../memoji.png">
<!-- 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>
@@ -48,10 +78,14 @@ $currentPage = basename($_SERVER['PHP_SELF']);
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<!-- Ajout du CSS Primaire (fait par IA) -->
<link rel="stylesheet" href="../styles/primary_css.css">
<!-- CSS principal du site. urlAsset ajoute "?v=<date de modif>" pour que le navigateur
recharge la feuille de style dès qu'elle change (voir functions/assets.php) -->
<link rel="stylesheet" href="<?php echo htmlspecialchars(urlAsset('styles/primary_css.css')); ?>">
<!-- Ajout de Font Awesome pour les icones de home.php-->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
<!-- Devicon : équivalent gratuit de Font Awesome dédié aux logos d'outils/langages/frameworks,
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>
+32
View File
@@ -0,0 +1,32 @@
<?php
// Fonction qui construit l'URL d'un fichier statique (CSS, JS, image) en lui ajoutant un
// numéro de version basé sur la date de dernière modification du fichier sur le disque.
//
// Pourquoi : le navigateur garde les fichiers CSS en cache parfois plusieurs jours, donc un
// visiteur déjà venu continuait de voir l'ancien style après une mise en ligne (d'où la
// notice "videz votre cache" affichée sur la page d'accueil). Avec "?v=1756329123" dans
// l'URL, l'adresse change dès que le fichier change : le navigateur la considère comme une
// nouvelle ressource et la retélécharge, sans qu'il ait à faire quoi que ce soit. Tant que
// le fichier ne bouge pas, l'URL reste identique et le cache continue de servir (pas de
// téléchargement inutile à chaque visite).
//
// filemtime plutôt que le hash du dernier commit : ça ne dépend pas de la présence de git
// sur le serveur, et ça suit aussi les fichiers modifiés directement en production.
//
// $chemin : chemin du fichier depuis la racine du site (ex : 'styles/primary_css.css')
// $prefixe : préfixe à mettre dans le HTML selon l'emplacement de la page qui l'appelle
// ('../' pour les pages de /pages, './' pour les fichiers à la racine)
function urlAsset($chemin, $prefixe = '../') {
$fichier = __DIR__ . '/../' . $chemin;
// @ : si le fichier est introuvable on renvoie l'URL sans version plutôt que de
// provoquer un warning PHP au milieu du <head>
$version = @filemtime($fichier);
if ($version === false) {
return $prefixe . $chemin;
}
return $prefixe . $chemin . '?v=' . $version;
}
+24 -8
View File
@@ -12,11 +12,13 @@ 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 id, category, name, logo_url, link_url, position FROM logo_banners WHERE category = :category ORDER BY position ASC, id ASC');
$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 id, category, name, logo_url, link_url, position FROM logo_banners ORDER BY category ASC, position ASC, id ASC');
$stmt = $dbh->prepare('SELECT * FROM logo_banners ORDER BY category ASC, position ASC, id ASC');
}
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
@@ -30,7 +32,7 @@ function recupererLogoParId($id) {
global $dbh;
try {
$stmt = $dbh->prepare('SELECT id, category, name, logo_url, link_url, position FROM logo_banners WHERE id = :id');
$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);
@@ -39,6 +41,20 @@ function recupererLogoParId($id) {
}
}
// 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) {
@@ -58,7 +74,6 @@ function afficherLogos($category) {
for ($repetition = 0; $repetition < 2; $repetition++) {
foreach ($logos as $logo) {
$nom = htmlspecialchars($logo['name']);
$image = htmlspecialchars($logo['logo_url']);
$lien = trim($logo['link_url'] ?? '');
if ($lien !== '') {
@@ -67,7 +82,7 @@ function afficherLogos($category) {
echo '<div class="logo-tile" title="' . $nom . '">';
}
echo '<img src="' . $image . '" alt="' . $nom . '" loading="lazy">';
afficherLogoVisuel($logo);
echo $lien !== '' ? '</a>' : '</div>';
}
}
@@ -91,18 +106,19 @@ function afficherLogosAdmin() {
foreach ($logos as $logo) {
$id = (int) $logo['id'];
$nom = htmlspecialchars($logo['name']);
$image = htmlspecialchars($logo['logo_url']);
$badge = $logo['category'] === 'partner' ? 'Entreprise' : 'Outil';
echo '<div class="logo-admin-item">';
echo '<div class="logo-admin-preview"><img src="' . $image . '" alt="' . $nom . '"></div>';
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 . '" onclick="return confirm(\'Voulez-vous vraiment supprimer ce logo ?\');">Supprimer</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>';
}
+21 -6
View File
@@ -10,10 +10,17 @@ function listeProjets() {
// Essayer
try {
// Requête SQL qui récupère titre, tags, description, image_url
// SELECT * : les colonnes markdown / link_url sont optionnelles (la page fonctionne
// même si la migration SQL n'a pas encore été exécutée)
$stmt = $dbh->prepare('SELECT * 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
@@ -29,7 +36,15 @@ function recupererProjetParId($id) {
global $dbh;
try {
$stmt = $dbh->prepare('SELECT * 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);
@@ -164,7 +179,7 @@ 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 . '#admin-projets">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-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>';
+21 -5
View File
@@ -10,9 +10,17 @@ function listeEcoles() {
global $dbh;
try {
// SELECT * : les colonnes markdown / link_url sont optionnelles (la page fonctionne
// même si la migration SQL n'a pas encore été exécutée)
$stmt = $dbh->prepare('SELECT * FROM schools ORDER BY id DESC');
// 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) {
@@ -25,7 +33,15 @@ function recupererEcoleParId($id) {
global $dbh;
try {
$stmt = $dbh->prepare('SELECT * FROM schools WHERE id = :id');
// 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);
@@ -98,7 +114,7 @@ function afficherEcoleCard($ecole, $admin = false) {
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 . '" onclick="return confirm(\'Voulez-vous vraiment supprimer cette école ?\');">Supprimer</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>';
}
+117 -2
View File
@@ -32,6 +32,42 @@ function recupererSkillParId($id) {
}
}
// 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) {
@@ -53,7 +89,9 @@ function afficherSkillCard($skill, $admin = false) {
if (strpos($item, '|') !== false) {
list($itemIcone, $label) = array_map('trim', explode('|', $item, 2));
}
echo '<li><i class="' . htmlspecialchars($itemIcone) . '"></i> ' . htmlspecialchars($label) . '</li>';
echo '<li>';
afficherVisuelItem($itemIcone, 'skill-item-visual');
echo ' ' . htmlspecialchars($label) . '</li>';
}
echo '</ul>';
@@ -62,7 +100,7 @@ function afficherSkillCard($skill, $admin = false) {
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 . '" onclick="return confirm(\'Voulez-vous vraiment supprimer cette catégorie de compétences ?\');">Supprimer</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>';
}
@@ -96,3 +134,80 @@ function afficherSkillsAdmin() {
}
echo '</div>';
}
// Items exclus du bandeau de la home au cas par cas, en plus du filtre automatique
// (icône Devicon "réelle" mais jugée pas assez reconnaissable seule, sans le texte à côté)
const OUTILS_EXCLUS_DU_BANDEAU = ['Bash'];
// Fonction qui récupère tous les items de compétences représentant un vrai outil identifiable
// (icône Devicon ou image "img:"), toutes catégories confondues. Les items en texte seul
// SONT EXCLUS, ainsi que les icônes Font Awesome génériques (fa-solid/fa-brands) : dans un
// bandeau qui n'affiche pas le texte à côté (juste une info-bulle au survol), une icône
// générique comme un pictogramme "carte" pour "Adressage IP" ou "carnet de contact" pour
// "LDAP" ne représente aucun outil reconnaissable — ça reste illisible pour un visiteur qui
// ne survole pas. Ces items restent affichés normalement sur la page Compétences (avec leur
// texte à côté, donc sans ambiguïté), seul le bandeau de la home les filtre.
function listeIconesCompetences() {
$categories = listeSkills();
$resultats = [];
foreach ($categories as $categorie) {
$items = array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $categorie['items'])));
foreach ($items as $item) {
if (strpos($item, '|') === false) {
continue;
}
list($icone, $label) = array_map('trim', explode('|', $item, 2));
if ($icone === '' || $label === '') {
continue;
}
// Seuls les vrais logos (Devicon ou image) sont assez reconnaissables sans texte
$estUnVraiLogo = strpos($icone, 'devicon-') === 0 || strpos($icone, 'img:') === 0;
if (!$estUnVraiLogo) {
continue;
}
if (in_array($label, OUTILS_EXCLUS_DU_BANDEAU, true)) {
continue;
}
$resultats[] = ['icon' => $icone, 'label' => $label];
}
}
return $resultats;
}
// Fonction qui affiche le bandeau défilant des outils, directement à partir des icônes
// de la page Compétences (voir listeIconesCompetences). Réutilise les mêmes classes CSS
// que les bandeaux de logo_banners pour un rendu visuel identique (voir primary_css.css).
// "logo-marquee-tools" désactive le filtre grayscale/survol : contrairement aux logos
// d'entreprises, on veut que les vrais outils soient reconnaissables immédiatement.
function afficherOutilsCompetences() {
$outils = listeIconesCompetences();
if (empty($outils)) {
return;
}
echo '<div class="logo-marquee logo-marquee-tools">';
echo '<div class="logo-track">';
// On répète deux fois la liste : le défilement CSS boucle sur -50%, ce qui donne
// un effet de scroll infini fluide et sans coupure
for ($repetition = 0; $repetition < 2; $repetition++) {
foreach ($outils as $outil) {
$label = htmlspecialchars($outil['label']);
echo '<div class="logo-tile" title="' . $label . '">';
afficherVisuelItem($outil['icon'], '', true);
echo '</div>';
}
}
echo '</div>';
echo '</div>';
}
+27
View File
@@ -0,0 +1,27 @@
<?php
// Ajout de connexion a la bdd
require_once('../functions/databaseConnection.php');
// Fonction qui récupère les chiffres clés affichés sur la home (bandeau de stats)
// Chaque compteur retombe sur 0 en cas d'erreur (table pas encore créée...) plutôt
// que de bloquer la page d'accueil
function recupererStatsHome() {
global $dbh;
$stats = ['projets' => 0, 'experiences' => 0];
try {
$stats['projets'] = (int) $dbh->query('SELECT COUNT(*) FROM projects')->fetchColumn();
} catch (PDOException $e) {
// Table pas encore créée : on laisse le compteur à 0
}
try {
$stats['experiences'] = (int) $dbh->query('SELECT COUNT(*) FROM experiences')->fetchColumn();
} catch (PDOException $e) {
// Table pas encore créée : on laisse le compteur à 0
}
return $stats;
}
+1 -1
View File
@@ -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>';
+21 -7
View File
@@ -13,10 +13,17 @@ function listeXP() {
// Essayer
try {
// Requête SQL qui récupère titre, type, tags, description, durée, image_url
// SELECT * : les colonnes markdown / link_url sont optionnelles (la page fonctionne
// même si la migration SQL n'a pas encore été exécutée)
$stmt = $dbh->prepare('SELECT * 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
@@ -32,8 +39,15 @@ function recupererXPParId($id) {
global $dbh;
try {
// Préparation de la requête préparée
$stmt = $dbh->prepare('SELECT * 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();
@@ -195,7 +209,7 @@ 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 . '#admin-experiences">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-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>';
+158
View File
@@ -0,0 +1,158 @@
<?php
// Fonctions liées au verrouillage anti-bruteforce du code PIN du "Mode présentation"
// (voir pages/presentation.php). Le compteur de tentatives est stocké côté serveur, par
// adresse IP (hashée, jamais en clair), pour ne pas pouvoir être remis à zéro en vidant
// juste les cookies ou en ouvrant une fenêtre de navigation privée.
require_once(__DIR__ . '/databaseConnection.php');
// Nombre de chiffres attendu pour le code PIN
define('PRESENTATION_PIN_LENGTH', 9);
// Durée du blocage une fois le nombre maximum de tentatives atteint (en heures)
define('PRESENTATION_LOCK_HOURS', 24);
// Hash SHA-256 de l'IP du visiteur : sert de clé dans la table presentation_attempts.
// On ne stocke jamais l'IP en clair, un hash suffit pour reconnaître le même visiteur
// d'une requête à l'autre sans conserver une donnée personnelle en clair.
function hashIpPresentation() {
return hash('sha256', $_SERVER['REMOTE_ADDR'] ?? 'inconnue');
}
// Vérifie le code PIN saisi contre le hash enregistré dans les réglages du site.
// Renvoie false si aucun code n'a encore été configuré (accès bloqué par défaut tant
// que l'admin n'a pas défini de code depuis le panel).
function verifierPinPresentation($pinSaisi) {
require_once(__DIR__ . '/listSettings.php');
$hash = recupererSetting('presentation_pin_hash');
if (empty($hash) || strlen($pinSaisi) !== PRESENTATION_PIN_LENGTH) {
return false;
}
return password_verify($pinSaisi, $hash);
}
// Récupère l'état de blocage actuel pour une IP (hashée) donnée.
// Renvoie ['attempts' => int, 'locked_until' => string|null] ou null si cette IP n'a
// encore jamais raté de tentative.
function recupererTentativePresentation($ipHash) {
global $dbh;
try {
$stmt = $dbh->prepare('SELECT attempts, locked_until FROM presentation_attempts WHERE ip_hash = :ip');
$stmt->bindValue(':ip', $ipHash, PDO::PARAM_STR);
$stmt->execute();
$ligne = $stmt->fetch(PDO::FETCH_ASSOC);
return $ligne ?: null;
} catch (PDOException $e) {
// Tant que la table n'existe pas encore (migration SQL pas encore appliquée),
// on considère qu'il n'y a pas de blocage plutôt que de casser la page
return null;
}
}
// Vrai si cette IP est actuellement bloquée (trop de tentatives ratées récemment)
function presentationEstVerrouillee($ipHash, $maxTentatives) {
$etat = recupererTentativePresentation($ipHash);
return $etat
&& (int) $etat['attempts'] >= $maxTentatives
&& !empty($etat['locked_until'])
&& strtotime($etat['locked_until']) > time();
}
// Enregistre une tentative ratée : incrémente le compteur de cette IP, et pose un
// blocage de PRESENTATION_LOCK_HOURS heures si le maximum est atteint.
// Renvoie le nouvel état, au même format que recupererTentativePresentation().
function enregistrerEchecPresentation($ipHash, $maxTentatives) {
global $dbh;
$etatActuel = recupererTentativePresentation($ipHash);
$tentatives = $etatActuel ? (int) $etatActuel['attempts'] + 1 : 1;
$verrouJusqua = $tentatives >= $maxTentatives
? date('Y-m-d H:i:s', time() + PRESENTATION_LOCK_HOURS * 3600)
: null;
try {
$stmt = $dbh->prepare(
'INSERT INTO presentation_attempts (ip_hash, attempts, locked_until) VALUES (:ip, :tentatives, :verrou)
ON DUPLICATE KEY UPDATE attempts = VALUES(attempts), locked_until = VALUES(locked_until)'
);
$stmt->execute([
':ip' => $ipHash,
':tentatives' => $tentatives,
':verrou' => $verrouJusqua
]);
} catch (PDOException $e) {
// Si la table n'existe pas encore, on ne bloque pas le visiteur pour autant
}
return ['attempts' => $tentatives, 'locked_until' => $verrouJusqua];
}
// Efface le compteur de tentatives d'une IP : code correct, connexion admin, ou
// déblocage manuel depuis le panel admin
function reinitialiserTentativesPresentation($ipHash) {
global $dbh;
try {
$stmt = $dbh->prepare('DELETE FROM presentation_attempts WHERE ip_hash = :ip');
$stmt->bindValue(':ip', $ipHash, PDO::PARAM_STR);
$stmt->execute();
} catch (PDOException $e) {
// Rien à faire si la table n'existe pas encore
}
}
// Liste des IP actuellement bloquées, pour le panel admin
function listerTentativesVerrouillees() {
global $dbh;
try {
$stmt = $dbh->query(
'SELECT ip_hash, attempts, locked_until FROM presentation_attempts
WHERE locked_until IS NOT NULL AND locked_until > NOW()
ORDER BY locked_until DESC'
);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
return [];
}
}
// Affiche, dans le panel admin, la liste des IP actuellement bloquées avec un bouton
// de déblocage manuel pour chacune (utile si un visiteur légitime se fait bloquer)
function afficherTentativesVerrouilleesAdmin() {
$lignes = listerTentativesVerrouillees();
if (empty($lignes)) {
echo '<p class="admin-notice">Aucune IP bloquée actuellement.</p>';
return;
}
echo '<div class="projects-list">';
foreach ($lignes as $ligne) {
// On n'affiche jamais l'IP en clair : seulement une courte empreinte de son hash,
// suffisante pour distinguer deux blocages différents sans exposer de donnée perso
$empreinte = substr($ligne['ip_hash'], 0, 12);
$jusqua = date('d/m/Y à H:i', strtotime($ligne['locked_until']));
echo '<div class="project-card">';
echo '<div class="project-content">';
echo '<h2>IP bloquée</h2>';
echo '<p><strong>Empreinte :</strong> ' . htmlspecialchars($empreinte) . '…</p>';
echo '<p><strong>Tentatives ratées :</strong> ' . (int) $ligne['attempts'] . '</p>';
echo '<p><strong>Bloquée jusqu\'au :</strong> ' . htmlspecialchars($jusqua) . '</p>';
echo '<div class="admin-actions">';
echo '<a href="../scripts/unlockPresentationAttempt.php?ip=' . urlencode($ligne['ip_hash'])
. '&csrf_token=' . urlencode(csrfToken()) . '" class="btn-submit btn-delete"'
. ' onclick="return confirm(\'Débloquer cette IP ?\');">Débloquer</a>';
echo '</div>';
echo '</div>';
echo '</div>';
}
echo '</div>';
}
+48
View File
@@ -0,0 +1,48 @@
<?php
// Sécurité transverse : cookie de session durci, en-têtes anti-clickjacking et
// protection CSRF. A inclure à la place d'un simple session_start() sur toute
// page ou script qui utilise une session (remplace l'appel direct partout).
// Règle les paramètres du cookie de session AVANT de démarrer la session (ils
// n'ont aucun effet une fois la session déjà démarrée) :
// - httponly : empêche le JavaScript de lire le cookie (limite le vol de session via une XSS)
// - samesite=Lax : bloque une partie des requêtes intersites (CSRF)
// - secure : n'est activé que si la connexion est déjà en HTTPS (sinon le cookie ne partirait jamais en HTTP)
function configureSecureSessionCookies() {
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'httponly' => true,
'samesite' => 'Lax',
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
]);
}
// Anti-clickjacking : interdit d'afficher le site dans une <iframe> d'un autre site
header('X-Frame-Options: SAMEORIGIN');
header('X-Content-Type-Options: nosniff');
if (session_status() === PHP_SESSION_NONE) {
configureSecureSessionCookies();
session_start();
}
// Génère (si besoin) et retourne le jeton CSRF de la session en cours, à
// placer dans un champ caché de chaque formulaire admin ou dans les liens
// GET qui déclenchent une action (suppression, export...)
function csrfToken() {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
// Vérifie le jeton CSRF reçu (POST ou GET) ; arrête le script si invalide.
// A appeler juste après la vérification du rôle admin dans chaque script d'action.
function requireCsrfToken() {
$recu = $_POST['csrf_token'] ?? $_GET['csrf_token'] ?? '';
if (empty($_SESSION['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $recu)) {
http_response_code(403);
die("Requête refusée : jeton de sécurité invalide ou expiré. Recharge la page et réessaie.");
}
}
+2 -5
View File
@@ -1,14 +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 généré par intelligence artificielle ou récupéré d'autre projet.
// Note 1 : Pour lancer un serveur local PHP Depuis VsCode : 'php -v' pour vérifier que php est bien installé et 'php -S localhost:8000' pour lancer le serveur
// Note 2 : Je m'aide désormais de l'autocompletion de vsCode pour accélerer le développement avec Copilot (GitHub) ou Devin mais le reste du code est entièrement développé par moi même mais copilot
// reste utile pour la génération de commentaires, de code répétitif ou de code basique (exemple : les formulaires de contact, les requêtes SQL basiques etc...)
// Note : Pour lancer un serveur local PHP depuis VsCode : 'php -v' pour vérifier que php est bien installé et 'php -S localhost:8000' pour lancer le serveur
// Le header n'y étant pas je rajoute ici un démarrage de session
session_start();
require_once(__DIR__ . '/functions/security.php');
// Redirection de l'utilisateur vers une page Home lorsqu'il arrive sur index.php
header('location: ./pages/home.php');
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+285 -57
View File
@@ -14,6 +14,7 @@ require_once('../functions/listSchool.php');
require_once('../functions/listSkills.php');
require_once('../functions/listSettings.php');
require_once('../functions/listLogos.php');
require_once('../functions/presentationAuth.php');
// L'utilisateur doit être administrateur
@@ -52,6 +53,21 @@ $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
?>
@@ -69,8 +85,11 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<a href="#admin-experiences"><i class="fa-solid fa-briefcase"></i> Expériences</a>
<a href="#admin-ecoles"><i class="fa-solid fa-graduation-cap"></i> Écoles</a>
<a href="#admin-competences"><i class="fa-solid fa-code"></i> Compétences</a>
<a href="#admin-presentation"><i class="fa-solid fa-lock"></i> Mode présentation</a>
<a href="#admin-logos"><i class="fa-solid fa-images"></i> Logos</a>
<a href="#admin-contact"><i class="fa-solid fa-address-card"></i> Contact</a>
<a href="#admin-notification"><i class="fa-solid fa-bell"></i> Notification</a>
<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>
@@ -87,10 +106,15 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<!-- 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 if ($projetAModifier): ?>
<input type="hidden" name="id" value="<?php echo (int) $projetAModifier['id']; ?>">
<?php endif; ?>
<?php
// En mode modification, on transmet l'id de la ligne à mettre à jour.
// En mode ajout la variable est vide : pas de champ caché, donc pas d'id envoyé.
if ($projetAModifier) {
echo '<input type="hidden" name="id" value="' . (int) $projetAModifier['id'] . '">';
}
?>
<label>Titre *</label>
<input type="text" name="title" required value="<?php echo $projetAModifier ? htmlspecialchars($projetAModifier['title']) : ''; ?>">
@@ -111,16 +135,22 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<textarea name="markdown" class="markdown-textarea" placeholder="# Mon projet&#10;&#10;Description complète en **markdown**..."><?php echo $projetAModifier ? htmlspecialchars($projetAModifier['markdown'] ?? '') : ''; ?></textarea>
<label>Document PDF (optionnel, utilisé si pas de markdown)</label>
<?php if ($projetAModifier && !empty($projetAModifier['pdf_filename'])): ?>
<div class="pdf-current">
<i class="fa-solid fa-file-pdf"></i>
<span><?php echo htmlspecialchars($projetAModifier['pdf_filename']); ?></span>
<a href="../scripts/viewPdf.php?type=projet&id=<?php echo (int) $projetAModifier['id']; ?>" target="_blank" rel="noopener noreferrer">Voir</a>
<label class="checkbox-row pdf-remove-row">
<input type="checkbox" name="remove_pdf"> Supprimer ce PDF
</label>
</div>
<?php endif; ?>
<?php
// Si un PDF est déjà enregistré, on le rappelle avec un lien pour le consulter
// et une case à cocher pour le supprimer à l'enregistrement du formulaire.
if ($projetAModifier && !empty($projetAModifier['pdf_filename'])) {
$lienPdf = '../scripts/viewPdf.php?type=projet&id=' . (int) $projetAModifier['id'];
echo '<div class="pdf-current">';
echo '<i class="fa-solid fa-file-pdf"></i>';
echo '<span>' . htmlspecialchars($projetAModifier['pdf_filename']) . '</span>';
echo '<a href="' . $lienPdf . '" target="_blank" rel="noopener noreferrer">Voir</a>';
echo '<label class="checkbox-row pdf-remove-row">';
echo '<input type="checkbox" name="remove_pdf"> Supprimer ce PDF';
echo '</label>';
echo '</div>';
}
?>
<div class="pdf-dropzone" data-input="pdf-project">
<i class="fa-solid fa-cloud-arrow-up"></i>
<p>Glisse un PDF ici, ou <span class="pdf-dropzone-browse">clique pour choisir un fichier</span></p>
@@ -151,10 +181,15 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<div class="project-form-card">
<!-- en envoie les données à 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']) : ''; ?>">
@@ -181,16 +216,22 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<textarea name="markdown" class="markdown-textarea" placeholder="# Mon expérience&#10;&#10;Description complète en **markdown**..."><?php echo $xpAModifier ? htmlspecialchars($xpAModifier['markdown'] ?? '') : ''; ?></textarea>
<label>Document PDF (optionnel, utilisé si pas de markdown)</label>
<?php if ($xpAModifier && !empty($xpAModifier['pdf_filename'])): ?>
<div class="pdf-current">
<i class="fa-solid fa-file-pdf"></i>
<span><?php echo htmlspecialchars($xpAModifier['pdf_filename']); ?></span>
<a href="../scripts/viewPdf.php?type=experience&id=<?php echo (int) $xpAModifier['id']; ?>" target="_blank" rel="noopener noreferrer">Voir</a>
<label class="checkbox-row pdf-remove-row">
<input type="checkbox" name="remove_pdf"> Supprimer ce PDF
</label>
</div>
<?php endif; ?>
<?php
// Si un PDF est déjà enregistré, on le rappelle avec un lien pour le consulter
// et une case à cocher pour le supprimer à l'enregistrement du formulaire.
if ($xpAModifier && !empty($xpAModifier['pdf_filename'])) {
$lienPdf = '../scripts/viewPdf.php?type=experience&id=' . (int) $xpAModifier['id'];
echo '<div class="pdf-current">';
echo '<i class="fa-solid fa-file-pdf"></i>';
echo '<span>' . htmlspecialchars($xpAModifier['pdf_filename']) . '</span>';
echo '<a href="' . $lienPdf . '" target="_blank" rel="noopener noreferrer">Voir</a>';
echo '<label class="checkbox-row pdf-remove-row">';
echo '<input type="checkbox" name="remove_pdf"> Supprimer ce PDF';
echo '</label>';
echo '</div>';
}
?>
<div class="pdf-dropzone" data-input="pdf-xp">
<i class="fa-solid fa-cloud-arrow-up"></i>
<p>Glisse un PDF ici, ou <span class="pdf-dropzone-browse">clique pour choisir un fichier</span></p>
@@ -221,10 +262,15 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<div class="project-form-card">
<!-- on envoie les données à addSchool.php -->
<form method="POST" enctype="multipart/form-data" action="<?php echo $ecoleAModifier ? '../scripts/updateSchool.php' : '../scripts/addSchool.php'; ?>">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<?php if ($ecoleAModifier): ?>
<input type="hidden" name="id" value="<?php echo (int) $ecoleAModifier['id']; ?>">
<?php endif; ?>
<?php
// En mode modification, on transmet l'id de la ligne à mettre à jour.
// En mode ajout la variable est vide : pas de champ caché, donc pas d'id envoyé.
if ($ecoleAModifier) {
echo '<input type="hidden" name="id" value="' . (int) $ecoleAModifier['id'] . '">';
}
?>
<label>Nom de l'école *</label>
<input type="text" name="title" required value="<?php echo $ecoleAModifier ? htmlspecialchars($ecoleAModifier['title']) : ''; ?>">
@@ -248,16 +294,22 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<textarea name="markdown" class="markdown-textarea" placeholder="# Mon école&#10;&#10;Description complète en **markdown**..."><?php echo $ecoleAModifier ? htmlspecialchars($ecoleAModifier['markdown'] ?? '') : ''; ?></textarea>
<label>Document PDF (optionnel, utilisé si pas de markdown)</label>
<?php if ($ecoleAModifier && !empty($ecoleAModifier['pdf_filename'])): ?>
<div class="pdf-current">
<i class="fa-solid fa-file-pdf"></i>
<span><?php echo htmlspecialchars($ecoleAModifier['pdf_filename']); ?></span>
<a href="../scripts/viewPdf.php?type=ecole&id=<?php echo (int) $ecoleAModifier['id']; ?>" target="_blank" rel="noopener noreferrer">Voir</a>
<label class="checkbox-row pdf-remove-row">
<input type="checkbox" name="remove_pdf"> Supprimer ce PDF
</label>
</div>
<?php endif; ?>
<?php
// Si un PDF est déjà enregistré, on le rappelle avec un lien pour le consulter
// et une case à cocher pour le supprimer à l'enregistrement du formulaire.
if ($ecoleAModifier && !empty($ecoleAModifier['pdf_filename'])) {
$lienPdf = '../scripts/viewPdf.php?type=ecole&id=' . (int) $ecoleAModifier['id'];
echo '<div class="pdf-current">';
echo '<i class="fa-solid fa-file-pdf"></i>';
echo '<span>' . htmlspecialchars($ecoleAModifier['pdf_filename']) . '</span>';
echo '<a href="' . $lienPdf . '" target="_blank" rel="noopener noreferrer">Voir</a>';
echo '<label class="checkbox-row pdf-remove-row">';
echo '<input type="checkbox" name="remove_pdf"> Supprimer ce PDF';
echo '</label>';
echo '</div>';
}
?>
<div class="pdf-dropzone" data-input="pdf-school">
<i class="fa-solid fa-cloud-arrow-up"></i>
<p>Glisse un PDF ici, ou <span class="pdf-dropzone-browse">clique pour choisir un fichier</span></p>
@@ -288,10 +340,15 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<div class="project-form-card">
<!-- on envoie les données à addSkill.php -->
<form method="POST" action="<?php echo $skillAModifier ? '../scripts/updateSkill.php' : '../scripts/addSkill.php'; ?>">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<?php if ($skillAModifier): ?>
<input type="hidden" name="id" value="<?php echo (int) $skillAModifier['id']; ?>">
<?php endif; ?>
<?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']) : ''; ?>">
@@ -317,32 +374,106 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
</div>
</section>
<!-- ========== SECTION MODE PRÉSENTATION ========== -->
<section id="admin-presentation" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-lock"></i> Mode présentation</h2>
<!-- Contenu affiché une fois le code PIN entré (voir pages/presentation.php) :
même principe que le markdown des projets/expériences/écoles, avec les mêmes
images possibles via ![légende](url) -->
<div class="project-container">
<div class="project-form-card">
<!-- on envoie les données à updateSettings.php -->
<form method="POST" action="../scripts/updateSettings.php">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<input type="hidden" name="settings_group" value="presentation">
<label>Contenu (markdown, comme pour les fiches projets)</label>
<!-- Petit bouton d'aide : insère la syntaxe markdown d'une image au
curseur, pour ne pas avoir à s'en souvenir -->
<div class="markdown-toolbar">
<button type="button" class="markdown-insert-image" data-target="presentation_markdown">
<i class="fa-solid fa-image"></i> Insérer une image
</button>
</div>
<textarea name="presentation_markdown" id="presentation_markdown" class="markdown-textarea" placeholder="# Titre&#10;&#10;Texte de présentation en **markdown**...&#10;&#10;![Légende de l'image](https://...)"><?php echo htmlspecialchars($presentationMarkdown); ?></textarea>
<input type="submit" name="submitsettings" value="Enregistrer le contenu" class="btn-submit">
</form>
</div>
</div>
<!-- Code PIN à part : formulaire séparé car il doit être haché avant d'être
enregistré (voir scripts/updatePresentationPin.php), contrairement au markdown
juste au-dessus qui est stocké tel quel -->
<div class="project-container">
<div class="project-form-card">
<form method="POST" action="../scripts/updatePresentationPin.php">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<label>Code PIN (exactement 9 chiffres)</label>
<input type="text" name="pin" inputmode="numeric" pattern="\d{9}" minlength="9" maxlength="9" placeholder="9 chiffres" required>
<input type="submit" value="Changer le code PIN" class="btn-submit">
</form>
<!-- Le code actuel n'est jamais réaffiché : seul son hash est stocké en
base, impossible à relire (comme un mot de passe) -->
<p class="admin-data-text">Le code actuel n'apparaît jamais ici, seul son hash est stocké en base.</p>
</div>
</div>
<!-- IP actuellement bloquées après 4 échecs : un visiteur légitime qui se trompe
plusieurs fois peut se retrouver bloqué, ce bouton permet de le débloquer
sans attendre les 24h de blocage automatique -->
<div class="project-container">
<div class="project-form-card">
<h3 class="admin-data-title"><i class="fa-solid fa-lock"></i> IP bloquées</h3>
<?php afficherTentativesVerrouilleesAdmin(); ?>
</div>
</div>
</section>
<!-- ========== SECTION LOGOS (bandeaux page d'accueil) ========== -->
<section id="admin-logos" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-images"></i> Logos (page d'accueil)</h2>
<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']) : '+ Nouveau logo'; ?></summary>
<summary><?php echo $logoAModifier ? 'Modifier le logo : ' . htmlspecialchars($logoAModifier['name']) : '+ Nouvelle entreprise'; ?></summary>
<div class="project-form-card">
<!-- on envoie les données à addLogo.php -->
<form method="POST" action="<?php echo $logoAModifier ? '../scripts/updateLogo.php' : '../scripts/addLogo.php'; ?>">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<?php if ($logoAModifier): ?>
<input type="hidden" name="id" value="<?php echo (int) $logoAModifier['id']; ?>">
<?php endif; ?>
<?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'] . '">';
}
?>
<label>Bandeau *</label>
<select name="category">
<option value="tool" <?php echo (!$logoAModifier || $logoAModifier['category'] === 'tool') ? 'selected' : ''; ?>>Outils que je sais manipuler</option>
<option value="partner" <?php echo ($logoAModifier && $logoAModifier['category'] === 'partner') ? 'selected' : ''; ?>>Ils m'ont fait confiance</option>
</select>
<!-- Seul le bandeau "Ils m'ont fait confiance" est géré ici -->
<input type="hidden" name="category" value="partner">
<label>Nom (outil ou entreprise) *</label>
<input type="text" name="name" required placeholder="Docker, Proxmox, Nom de l'entreprise..." value="<?php echo $logoAModifier ? htmlspecialchars($logoAModifier['name']) : ''; ?>">
<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>Lien du logo *</label>
<input type="url" name="logourl" placeholder="https://..." required value="<?php echo $logoAModifier ? htmlspecialchars($logoAModifier['logo_url']) : ''; ?>">
<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'] ?? '') : ''; ?>">
@@ -360,6 +491,45 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<?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>
@@ -368,6 +538,8 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<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); ?>">
@@ -388,6 +560,30 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
</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>
@@ -398,6 +594,7 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<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>
@@ -432,7 +629,7 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
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">
<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>
@@ -446,6 +643,7 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
</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>
@@ -512,6 +710,36 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
})();
</script>
<!-- Bouton "Insérer une image" du mode présentation : ajoute la syntaxe markdown d'une
image (![légende](url)) à l'endroit où était le curseur dans le champ, pour ne pas
avoir à se souvenir de la syntaxe -->
<script>
(function () {
document.querySelectorAll('.markdown-insert-image').forEach(function (bouton) {
bouton.addEventListener('click', function () {
var champ = document.getElementById(bouton.dataset.target);
if (!champ) return;
var url = prompt('Lien de l\'image (URL) :');
if (!url) return;
var legende = prompt('Légende de l\'image (optionnel) :') || '';
var markdown = '![' + legende + '](' + url + ')';
// On insère au niveau du curseur plutôt qu'à la fin, pour pouvoir placer
// l'image n'importe où dans le texte déjà écrit
var debut = champ.selectionStart;
var fin = champ.selectionEnd;
champ.value = champ.value.slice(0, debut) + markdown + champ.value.slice(fin);
champ.focus();
var curseur = debut + markdown.length;
champ.setSelectionRange(curseur, curseur);
});
});
})();
</script>
<?php
// On ajoute ici le footer
require_once('../elements/footer.php'); ?>
+8 -13
View File
@@ -27,38 +27,33 @@ 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>
</div>
+22 -3
View File
@@ -6,6 +6,16 @@ $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';
?>
<div class="contact-page">
@@ -14,9 +24,18 @@ require_once("../elements/header.php");
<div class="contact-info">
<p class="contact-title">Contact</p>
<p class="contact-name">Esteban MOUSSU</p>
<p class="contact-line"><i class="fa-solid fa-envelope"></i> esteban.moussu@ik.me</p>
<p class="contact-line"><i class="fa-solid fa-location-dot"></i> 20 Grande rue</p>
<p class="contact-line">52500 Broncourt</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 -->
+91 -56
View File
@@ -8,10 +8,12 @@
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', 'retour' => './projects.php', 'label' => 'Projet'],
'experience' => ['table' => 'experiences', 'retour' => './xp.php', 'label' => 'Expérience'],
'ecole' => ['table' => 'schools', 'retour' => './xp.php', 'label' => 'École'],
'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'] ?? '';
@@ -28,15 +30,20 @@ $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 = 'id, title, status, tags, description, image_url, markdown, link_url, pdf_filename';
$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 . ', xptype, duration',
'ecole' => $colonnesCommunes . ', duration',
'experience' => $colonnesCommunes . ', e.xptype, e.duration',
'ecole' => $colonnesCommunes . ', e.duration',
];
try {
$stmt = $dbh->prepare('SELECT ' . $colonnesParType[$type] . ' FROM `' . $infos['table'] . '` WHERE id = :id');
$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);
@@ -80,58 +87,78 @@ if (!empty($element['tags'])) {
<article class="details-card">
<?php if (!empty($image)): ?>
<div class="details-banner project-banner">
<img src="<?php echo $image; ?>" alt="Image : <?php echo $titre; ?>">
<?php if (!empty($status)): ?>
<div class="project-state"><?php echo $status; ?></div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php
// Bandeau d'image, seulement si une image est renseignée pour cet élément
if (!empty($image)) {
echo '<div class="details-banner project-banner">';
echo '<img src="' . $image . '" alt="Image : ' . $titre . '">';
// Pastille d'état (En cours, Terminé...) posée par-dessus l'image
if (!empty($status)) {
echo '<div class="project-state">' . $status . '</div>';
}
echo '</div>';
}
?>
<div class="details-content">
<span class="details-type"><?php echo $infos['label']; ?></span>
<h1><?php echo $titre; ?></h1>
<!-- Métadonnées : type / durée si renseignés -->
<?php if (!empty($xptype) || !empty($duration)): ?>
<p class="details-meta">
<?php if (!empty($xptype)): ?><span><i class="fa-solid fa-tag"></i> <?php echo $xptype; ?></span><?php endif; ?>
<?php if (!empty($duration)): ?><span><i class="fa-solid fa-clock"></i> <?php echo $duration; ?></span><?php endif; ?>
</p>
<?php endif; ?>
<?php
// Métadonnées : type d'expérience et durée, affichés seulement si renseignés
if (!empty($xptype) || !empty($duration)) {
echo '<p class="details-meta">';
<?php if (!empty($tags)): ?>
<div class="project-tags">
<?php foreach ($tags as $tag): ?>
<span class="tag"><?php echo htmlspecialchars($tag); ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
if (!empty($xptype)) {
echo '<span><i class="fa-solid fa-tag"></i> ' . $xptype . '</span>';
}
<?php if ($hasMarkdown): ?>
<!-- Le markdown brut est placé ici (échappé), puis rendu en HTML par le script plus bas -->
<div id="markdown-source" hidden><?php echo htmlspecialchars($element['markdown']); ?></div>
<div class="markdown-content" id="markdown-target">
<p>Chargement du contenu...</p>
</div>
<?php elseif ($hasPdf): ?>
<!-- Mini lecteur PDF : le fichier est servi par viewPdf.php depuis la base de données -->
<div class="pdf-viewer-wrap">
<div class="pdf-viewer-toolbar">
<span><i class="fa-solid fa-file-pdf"></i> <?php echo htmlspecialchars($element['pdf_filename']); ?></span>
<a class="btn btn-ghost pdf-download-btn" href="../scripts/viewPdf.php?type=<?php echo $type; ?>&id=<?php echo (int) $id; ?>&download=1" download>
<i class="fa-solid fa-download"></i> Télécharger
</a>
</div>
<iframe
class="pdf-viewer"
src="../scripts/viewPdf.php?type=<?php echo $type; ?>&id=<?php echo (int) $id; ?>"
title="Aperçu PDF : <?php echo $titre; ?>"
></iframe>
</div>
<?php endif; ?>
if (!empty($duration)) {
echo '<span><i class="fa-solid fa-clock"></i> ' . $duration . '</span>';
}
echo '</p>';
}
// Étiquettes (technologies, mots-clés), une par tag séparé par une virgule en BDD
if (!empty($tags)) {
echo '<div class="project-tags">';
foreach ($tags as $tag) {
echo '<span class="tag">' . htmlspecialchars($tag) . '</span>';
}
echo '</div>';
}
?>
<?php
// Deux contenus possibles, jamais les deux en même temps : soit un texte en
// markdown, soit un PDF. Le markdown est prioritaire quand les deux existent.
if ($hasMarkdown) {
// Le markdown brut est placé ici (échappé), puis rendu en HTML par le script plus bas
echo '<div id="markdown-source" hidden>' . htmlspecialchars($element['markdown']) . '</div>';
echo '<div class="markdown-content" id="markdown-target">';
echo '<p>Chargement du contenu...</p>';
echo '</div>';
} elseif ($hasPdf) {
// Mini lecteur PDF : le fichier est servi par viewPdf.php depuis la base de données
$lienPdf = '../scripts/viewPdf.php?type=' . $type . '&id=' . (int) $id;
echo '<div class="pdf-viewer-wrap">';
echo '<div class="pdf-viewer-toolbar">';
echo '<span><i class="fa-solid fa-file-pdf"></i> ' . htmlspecialchars($element['pdf_filename']) . '</span>';
echo '<a class="btn btn-ghost pdf-download-btn" href="' . $lienPdf . '&download=1" download>';
echo '<i class="fa-solid fa-download"></i> Télécharger';
echo '</a>';
echo '</div>';
echo '<iframe class="pdf-viewer" src="' . $lienPdf . '" title="Aperçu PDF : ' . $titre . '"></iframe>';
echo '</div>';
}
?>
</div>
@@ -139,10 +166,16 @@ if (!empty($element['tags'])) {
</div>
<?php if ($hasMarkdown): ?>
<!-- marked.js : convertit le markdown en HTML / DOMPurify : nettoie le HTML généré (sécurité) -->
<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<?php
// Ces deux librairies ne sont chargées que sur les pages qui ont réellement du markdown :
// marked.js convertit le markdown en HTML, DOMPurify nettoie le HTML généré (sécurité XSS)
if ($hasMarkdown) {
echo '<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>';
echo '<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>';
// <<<'JS' ... JS; = "nowdoc" : un bloc de texte sur plusieurs lignes, recopié tel quel.
// Pratique ici pour ne pas avoir à échapper les guillemets du JavaScript.
echo <<<'JS'
<script>
(function () {
var source = document.getElementById('markdown-source');
@@ -152,7 +185,9 @@ if (!empty($element['tags'])) {
cible.innerHTML = DOMPurify.sanitize(marked.parse(markdown, { breaks: true }));
})();
</script>
<?php endif; ?>
JS;
}
?>
<?php
// Ajout du footer
+68 -20
View File
@@ -12,10 +12,19 @@ require_once('./../elements/header.php');
// La notification est maintenant dynamique : modifiable depuis le panel Admin (table settings)
require_once('../functions/listSettings.php');
// Bandeaux de logos ("Outils que je sais manipuler" / "Ils m'ont fait confiance"),
// gérés depuis le panel Admin (table logo_banners)
// 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();
?>
@@ -39,15 +48,48 @@ afficherNotice();
</a>
</section>
<?php if (!empty(listeLogos('tool'))): ?>
<!-- Bandeau défilant des outils maîtrisés, géré depuis le panel Admin -->
<section class="logo-banner-section">
<div class="logo-banner-header">
<h2>Outils que je sais manipuler</h2>
</div>
<?php afficherLogos('tool'); ?>
</section>
<?php endif; ?>
<?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" id="explorer">
@@ -93,15 +135,21 @@ afficherNotice();
</div>
</section>
<?php if (!empty(listeLogos('partner'))): ?>
<!-- Bandeau défilant des entreprises partenaires, géré depuis le panel Admin -->
<section class="logo-banner-section">
<div class="logo-banner-header">
<h2>Ils m'ont fait confiance</h2>
</div>
<?php afficherLogos('partner'); ?>
</section>
<?php endif; ?>
<?php
// Bandeau défilant des entreprises partenaires, géré depuis le panel Admin.
// Tant qu'aucun logo "partner" n'est enregistré, la section n'apparaît pas.
if (!empty(listeLogos('partner'))) {
echo '<section class="logo-banner-section">';
echo '<div class="logo-banner-header">';
echo '<h2>Ils m\'ont fait confiance</h2>';
echo '</div>';
// Cette fonction affiche elle-même le bandeau (voir functions/listLogos.php)
afficherLogos('partner');
echo '</section>';
}
?>
<?php
// Ajout du footer
+11 -1
View File
@@ -15,9 +15,13 @@ if (isset($_SESSION['user_id'])){
exit;
}
// Redirection optionnelle après connexion, whitelist stricte (pas d'URL arbitraire ici :
// on ne veut pas transformer ce champ en redirection ouverte). Utilisé quand le mode
// présentation renvoie ici après un blocage (voir pages/presentation.php)
$redirect = ($_GET['redirect'] ?? '') === 'presentation' ? 'presentation' : '';
?>
<!-- CSS Login (fait par Intelligence Arti) -->
<div class="login-container">
<div class="login-card">
@@ -26,6 +30,12 @@ if (isset($_SESSION['user_id'])){
<p>Connexion</p>
<?php
if ($redirect !== '') {
echo '<input type="hidden" name="redirect" value="' . htmlspecialchars($redirect) . '">';
}
?>
<!-- Deux champs de texte, un "username" et un "password" -->
<input type="text" name="username" id="username" placeholder="Votre identifiant">
<input type="password" name="password" id="password" placeholder="Votre mot de passe">
+484
View File
@@ -0,0 +1,484 @@
<?php
// Page "Mode présentation" : contenu markdown protégé par un code PIN à 9 chiffres.
// Le compteur de tentatives est géré côté serveur par IP (voir functions/presentationAuth.php)
// pour ne pas pouvoir être remis à zéro en vidant les cookies. Au bout de 4 tentatives
// ratées, on ne redemande plus le code : on propose une connexion admin à la place.
//
// Une fois débloquée (code correct, ou compte admin connecté), la page se rend sans le
// header ni le footer du site : contenu markdown en plein écran, lecture immersive.
require_once(__DIR__ . '/../functions/security.php');
require_once('../functions/databaseConnection.php');
require_once('../functions/listSettings.php');
require_once('../functions/presentationAuth.php');
$adminConnecte = isset($_SESSION['role']) && $_SESSION['role'] === 'admin';
$ipHash = hashIpPresentation();
$maxTentatives = 4;
// Un administrateur connecté a toujours accès, et ça lève un éventuel blocage sur son IP
// (pratique s'il teste lui-même depuis chez lui après s'être fait bloquer en visiteur)
if ($adminConnecte) {
reinitialiserTentativesPresentation($ipHash);
$_SESSION['presentation_unlocked'] = true;
}
$deverrouille = $adminConnecte || (($_SESSION['presentation_unlocked'] ?? false) === true);
$verrouille = false;
$erreur = '';
if (!$deverrouille) {
$verrouille = presentationEstVerrouillee($ipHash, $maxTentatives);
if (!$verrouille && $_SERVER['REQUEST_METHOD'] === 'POST') {
$pinSaisi = preg_replace('/\D/', '', $_POST['pin'] ?? '');
if (verifierPinPresentation($pinSaisi)) {
reinitialiserTentativesPresentation($ipHash);
// Empêche la fixation de session, comme à la connexion admin
session_regenerate_id(true);
$_SESSION['presentation_unlocked'] = true;
$deverrouille = true;
} else {
$etat = enregistrerEchecPresentation($ipHash, $maxTentatives);
$verrouille = (int) $etat['attempts'] >= $maxTentatives;
$erreur = $verrouille ? '' : 'Code incorrect. Réessaie.';
}
}
}
// Tentatives restantes à afficher (pastilles), calculées après un éventuel échec ci-dessus
$tentativesRestantes = $maxTentatives;
$etatActuel = null;
if (!$deverrouille) {
$etatActuel = recupererTentativePresentation($ipHash);
$tentativesRestantes = $maxTentatives - ($etatActuel ? (int) $etatActuel['attempts'] : 0);
if ($tentativesRestantes < 0) {
$tentativesRestantes = 0;
}
}
if ($deverrouille) {
// ---- Présentation en cours : page autonome plein écran, sans le header du site ----
require_once('../scripts/requireCaptcha.php');
require_once('../functions/sendLogs.php');
require_once(__DIR__ . '/../functions/assets.php');
sendPageLog();
$markdown = recupererSetting('presentation_markdown') ?? '';
// Diapositives séparées par une ligne ne contenant que --- (même convention que
// Marp/reveal.js : facile à retenir, et ça reste du markdown lisible tel quel).
// Sans --- dans le texte, tout le contenu forme une seule diapositive : dans ce
// cas on garde une simple page de lecture, sans flèches ni compteur inutiles.
$morceaux = preg_split('/^[ \t]*-{3,}[ \t]*$/m', $markdown);
$diapositives = [];
foreach ($morceaux as $morceau) {
$texte = trim($morceau);
if ($texte !== '') {
$diapositives[] = $texte;
}
}
$nombreDiapositives = count($diapositives);
$modeDiaporama = $nombreDiapositives > 1;
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mode présentation - Esteban MOUSSU</title>
<meta name="robots" content="noindex, nofollow">
<!-- Favicon servie par le CDN (voir elements/header.php) -->
<link rel="icon" type="image/png" href="https://cdn.eldorianet.work/i/c2cc96aa-1560-4062-ba4d-b455098af627.png">
<!-- Applique le thème sauvegardé AVANT le chargement du CSS pour éviter un flash sombre/clair -->
<script>
(function () {
try {
if (localStorage.getItem('theme') === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
}
} catch (e) {}
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<!-- On réutilise les variables et styles de base du site (thème clair/sombre, police...) -->
<link rel="stylesheet" href="<?php echo htmlspecialchars(urlAsset('styles/primary_css.css')); ?>">
<link rel="stylesheet" href="<?php echo htmlspecialchars(urlAsset('styles/presentation.css')); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
</head>
<body class="presentation-body">
<!-- Barre minimale : pas de nav du site, juste un rappel du mode + sortie -->
<div class="presentation-bar">
<span class="presentation-bar-tag"><i class="fa-solid fa-lock-open"></i> <span>Mode présentation</span></span>
<div class="presentation-bar-actions">
<button class="theme-toggle" type="button" aria-label="Changer de thème"
onclick="var h=document.documentElement;var l=h.getAttribute('data-theme')==='light';if(l){h.removeAttribute('data-theme');try{localStorage.setItem('theme','dark');}catch(e){}}else{h.setAttribute('data-theme','light');try{localStorage.setItem('theme','light');}catch(e){}}">
<i class="fa-solid fa-sun"></i>
<i class="fa-solid fa-moon"></i>
</button>
<a class="presentation-exit" href="../pages/home.php"><i class="fa-solid fa-xmark"></i> Quitter</a>
</div>
</div>
<?php if (!$modeDiaporama) { ?>
<!-- Un seul bloc de contenu : barre de progression de lecture (s'anime au scroll dans
les navigateurs qui supportent animation-timeline: scroll() ; reste simplement
invisible sinon, aucune régression) -->
<div class="presentation-progress" aria-hidden="true"></div>
<?php } ?>
<?php if ($nombreDiapositives === 0) { ?>
<main class="presentation-reader">
<p class="presentation-empty">Contenu à venir. Ajoute du texte depuis le panel admin, section « Mode présentation ».</p>
</main>
<?php } elseif (!$modeDiaporama) { ?>
<!-- Mode lecture simple : tout défile dans une seule colonne -->
<main class="presentation-reader">
<?php
// Même mécanisme que sur les pages de détail (projets/xp/écoles) : le markdown
// brut est déposé ici (échappé), puis rendu en HTML par marked.js plus bas.
// Les images s'insèrent avec la syntaxe markdown standard : ![légende](url)
echo '<div id="markdown-source" hidden>' . htmlspecialchars($diapositives[0]) . '</div>';
echo '<div class="markdown-content presentation-markdown" id="markdown-target"><p>Chargement du contenu...</p></div>';
?>
</main>
<?php } else { ?>
<!-- Mode diaporama : une diapositive à la fois, séparées dans le champ admin par
une ligne "---". Navigation en bas (flèches, barre de progression, compteur) -->
<main class="presentation-deck" data-total="<?php echo $nombreDiapositives; ?>">
<?php
foreach ($diapositives as $index => $texteDiapositive) {
$classeActive = $index === 0 ? ' is-active' : '';
echo '<section class="slide' . $classeActive . '" data-slide="' . $index . '">';
echo '<div class="slide-source" hidden>' . htmlspecialchars($texteDiapositive) . '</div>';
echo '<div class="markdown-content presentation-markdown slide-target"><p>Chargement du contenu...</p></div>';
echo '</section>';
}
?>
</main>
<nav class="slide-nav" aria-label="Navigation entre les diapositives">
<button type="button" class="slide-nav-btn slide-prev" aria-label="Diapositive précédente" disabled>
<i class="fa-solid fa-chevron-left"></i>
</button>
<div class="slide-progress" aria-hidden="true">
<div class="slide-progress-bar"></div>
</div>
<span class="slide-counter"><span class="slide-counter-current">1</span> / <?php echo $nombreDiapositives; ?></span>
<button type="button" class="slide-nav-btn slide-next" aria-label="Diapositive suivante">
<i class="fa-solid fa-chevron-right"></i>
</button>
</nav>
<?php } ?>
<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<?php
if (!$modeDiaporama) {
// ---- Rendu simple : un seul bloc, apparition douce au fil du scroll ----
echo <<<'JS'
<script>
(function () {
var source = document.getElementById('markdown-source');
var cible = document.getElementById('markdown-target');
if (source && cible) {
var markdown = source.textContent;
cible.innerHTML = DOMPurify.sanitize(marked.parse(markdown, { breaks: true }));
}
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
if (!('IntersectionObserver' in window)) return;
// Chaque bloc du contenu (titre, paragraphe, image...) apparaît en douceur à la lecture
var cibles = document.querySelectorAll('.presentation-markdown > *');
var observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.1, rootMargin: '0px 0px -60px 0px' });
cibles.forEach(function (el) {
el.classList.add('reveal');
observer.observe(el);
});
})();
</script>
JS;
} else {
// ---- Rendu diaporama : une diapositive affichée à la fois, navigation clavier/
// tactile/boutons, position mémorisée dans l'URL (#slide-3) pour pouvoir
// partager un lien direct vers une diapositive précise ----
echo <<<'JS'
<script>
(function () {
var slides = Array.prototype.slice.call(document.querySelectorAll('.slide'));
// Rendu du markdown de chaque diapositive (source échappée côté serveur,
// transformée en HTML ici, comme sur les fiches projets)
slides.forEach(function (slide) {
var source = slide.querySelector('.slide-source');
var cible = slide.querySelector('.slide-target');
if (source && cible) {
cible.innerHTML = DOMPurify.sanitize(marked.parse(source.textContent, { breaks: true }));
}
});
var index = 0;
var deck = document.querySelector('.presentation-deck');
var boutonPrec = document.querySelector('.slide-prev');
var boutonSuiv = document.querySelector('.slide-next');
var barre = document.querySelector('.slide-progress-bar');
var compteur = document.querySelector('.slide-counter-current');
function afficher(nouvelIndex) {
nouvelIndex = Math.max(0, Math.min(slides.length - 1, nouvelIndex));
slides[index].classList.remove('is-active');
index = nouvelIndex;
slides[index].classList.add('is-active');
boutonPrec.disabled = index === 0;
boutonSuiv.disabled = index === slides.length - 1;
if (barre) barre.style.width = ((index + 1) / slides.length * 100) + '%';
if (compteur) compteur.textContent = String(index + 1);
history.replaceState(null, '', '#slide-' + (index + 1));
}
boutonPrec.addEventListener('click', function () { afficher(index - 1); });
boutonSuiv.addEventListener('click', function () { afficher(index + 1); });
document.addEventListener('keydown', function (e) {
// On ignore les flèches si le focus est dans un champ de texte (aucun ici,
// mais garde-fou si jamais du contenu interactif est ajouté un jour)
var balise = (document.activeElement || {}).tagName;
if (balise === 'INPUT' || balise === 'TEXTAREA') return;
if (e.key === 'ArrowRight' || e.key === ' ') { e.preventDefault(); afficher(index + 1); }
if (e.key === 'ArrowLeft') { afficher(index - 1); }
});
// Clic sur la diapositive (comme Google Slides / Keynote) : moitié gauche de l'écran
// = diapositive précédente, moitié droite = suivante. On laisse passer le clic sans
// rien faire s'il vise un lien, un bouton ou la barre de navigation elle-même, pour
// ne pas voler le clic d'un lien présent dans le contenu markdown
deck.addEventListener('click', function (e) {
if (e.target.closest('a, button')) return;
afficher(e.clientX < window.innerWidth / 2 ? index - 1 : index + 1);
});
// Glissement tactile (mobile) : swipe vers la gauche = diapositive suivante
var startX = null;
deck.addEventListener('touchstart', function (e) {
startX = e.touches[0].clientX;
}, { passive: true });
deck.addEventListener('touchend', function (e) {
if (startX === null) return;
var delta = e.changedTouches[0].clientX - startX;
if (Math.abs(delta) > 50) {
afficher(delta < 0 ? index + 1 : index - 1);
}
startX = null;
}, { passive: true });
// Reprend la diapositive indiquée dans l'URL au chargement (#slide-3)
var depart = parseInt((location.hash.match(/slide-(\d+)/) || [])[1], 10);
afficher(isNaN(depart) ? 0 : depart - 1);
})();
</script>
JS;
}
?>
</body>
</html>
<?php
} else {
// ---- Écran de saisie du code, ou écran "trop de tentatives" : chrome normal du site ----
$pageTitle = 'Mode présentation';
require_once('../elements/header.php');
if ($verrouille) {
$jusqua = ($etatActuel && !empty($etatActuel['locked_until']))
? date('d/m/Y à H:i', strtotime($etatActuel['locked_until']))
: '';
?>
<div class="presentation-lock-container">
<div class="presentation-lock-card presentation-lock-card--blocked">
<span class="presentation-lock-badge presentation-lock-badge--danger">
<i class="fa-solid fa-triangle-exclamation"></i> Accès bloqué
</span>
<h1>Trop de tentatives</h1>
<p>Le code a été saisi plusieurs fois de suite sans succès. L'accès par code PIN est bloqué pour cet appareil<?php echo $jusqua !== '' ? ' jusqu\'au ' . htmlspecialchars($jusqua) : ''; ?>.</p>
<p>Connecte-toi en administrateur pour débloquer l'accès immédiatement.</p>
<a class="btn btn-primary presentation-lock-btn" href="../pages/login.php?redirect=presentation">
<i class="fa-solid fa-right-to-bracket"></i> Se connecter en administrateur
</a>
</div>
</div>
<?php
} else {
?>
<div class="presentation-lock-container">
<div class="presentation-lock-card">
<span class="presentation-lock-badge"><i class="fa-solid fa-lock"></i> Accès protégé</span>
<h1>Mode présentation</h1>
<p>Entre le code à 9 chiffres pour accéder au contenu.</p>
<?php
if ($erreur !== '') {
echo '<div class="presentation-lock-error">' . htmlspecialchars($erreur) . '</div>';
}
?>
<form method="POST" action="" class="presentation-pin-form" novalidate>
<div class="pin-boxes" role="group" aria-label="Code à 9 chiffres">
<?php
// type="password" : les chiffres s'affichent en points par défaut, comme un
// mot de passe. Le bouton œil juste en dessous les révèle, mais seulement
// tant qu'il reste appuyé (voir le JS plus bas)
for ($i = 0; $i < 9; $i++) {
echo '<input type="password" class="pin-box" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="off" aria-label="Chiffre ' . ($i + 1) . ' sur 9">';
}
?>
</div>
<!-- Maintenir appuyé pour voir le code, relâcher pour le remasquer -->
<button type="button" class="pin-reveal" aria-pressed="false">
<i class="fa-solid fa-eye"></i> <span>Maintenir pour afficher</span>
</button>
<input type="hidden" name="pin" id="pin-value">
<input type="submit" value="Valider" class="btn btn-primary presentation-pin-submit">
</form>
<div class="pin-attempts">
<span>Tentatives restantes</span>
<div class="pin-attempts-dots">
<?php
for ($i = 0; $i < $maxTentatives; $i++) {
echo '<span class="' . ($i < $tentativesRestantes ? 'dot-filled' : 'dot-empty') . '"></span>';
}
?>
</div>
</div>
</div>
</div>
<script>
(function () {
var boxes = Array.prototype.slice.call(document.querySelectorAll('.pin-box'));
var hidden = document.getElementById('pin-value');
var submit = document.querySelector('.presentation-pin-submit');
function syncHidden() {
var valeur = boxes.map(function (b) { return b.value; }).join('');
hidden.value = valeur;
submit.disabled = valeur.length !== 9;
}
boxes.forEach(function (box, index) {
box.addEventListener('input', function () {
box.value = box.value.replace(/\D/g, '').slice(0, 1);
if (box.value && boxes[index + 1]) {
boxes[index + 1].focus();
}
syncHidden();
});
box.addEventListener('keydown', function (e) {
if (e.key === 'Backspace' && !box.value && boxes[index - 1]) {
boxes[index - 1].focus();
}
});
box.addEventListener('paste', function (e) {
var texte = (e.clipboardData || window.clipboardData).getData('text').replace(/\D/g, '');
if (texte.length === 0) return;
e.preventDefault();
texte.slice(0, 9).split('').forEach(function (chiffre, i) {
if (boxes[i]) boxes[i].value = chiffre;
});
syncHidden();
var prochain = boxes[Math.min(texte.length, 8)];
if (prochain) prochain.focus();
});
});
// Bouton œil : le code n'est visible QUE tant que le bouton reste appuyé. Dès qu'on
// relâche (ou que le doigt/la souris quitte le bouton, ou que l'onglet perd le focus),
// on repasse en points. Évite de laisser le code affiché par inadvertance à l'écran.
var oeil = document.querySelector('.pin-reveal');
var icone = oeil.querySelector('i');
function afficherCode(visible) {
boxes.forEach(function (box) {
box.type = visible ? 'text' : 'password';
});
oeil.setAttribute('aria-pressed', visible ? 'true' : 'false');
oeil.classList.toggle('is-revealing', visible);
icone.className = visible ? 'fa-solid fa-eye-slash' : 'fa-solid fa-eye';
}
['mousedown', 'touchstart'].forEach(function (evt) {
oeil.addEventListener(evt, function (e) {
// Empêche le navigateur mobile de déclencher aussi un mousedown en double,
// et évite de voler le focus au champ en cours de saisie
e.preventDefault();
afficherCode(true);
});
});
['mouseup', 'mouseleave', 'touchend', 'touchcancel', 'blur'].forEach(function (evt) {
oeil.addEventListener(evt, function () {
afficherCode(false);
});
});
// Accessibilité clavier : Espace ou Entrée maintenus révèlent le code de la même façon
oeil.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
afficherCode(true);
}
});
oeil.addEventListener('keyup', function (e) {
if (e.key === ' ' || e.key === 'Enter') {
afficherCode(false);
}
});
// Garde-fou : si l'utilisateur change d'onglet en gardant le bouton appuyé, le
// relâchement n'est jamais reçu par la page, donc on remasque de force
window.addEventListener('blur', function () {
afficherCode(false);
});
syncHidden();
if (boxes[0]) boxes[0].focus();
})();
</script>
<?php
}
require_once('../elements/footer.php');
}
+66
View File
@@ -0,0 +1,66 @@
<?php
// Page du tableau de synthèse (épreuve E4 du BTS SIO), affiché directement dans le site.
// Le PDF est un fichier statique du dépôt (dossier documents/), pas un PDF stocké en base
// comme ceux des projets/expériences : il n'y en a qu'un seul et il ne change quasiment
// jamais, une table dédiée n'apporterait rien ici.
// Titre de l'onglet
$pageTitle = 'Tableau de synthèse';
// Description pour le SEO et les aperçus de partage
$pageDescription = 'Tableau de synthèse des réalisations professionnelles d\'Esteban MOUSSU, épreuve E4 du BTS SIO option SISR.';
// On ajoute ici le header
require_once('./../elements/header.php');
// Chemin du PDF, écrit une seule fois : il sert à la fois à l'aperçu et au bouton
// d'ouverture en plein écran juste en dessous
$cheminPdf = '../documents/tableau-de-synthese-e4.pdf';
?>
<div class="page-header">
<h1>Tableau de synthèse</h1>
<p>Réalisations professionnelles présentées à l'épreuve E4 du BTS SIO, option SISR.</p>
</div>
<div class="synthese-page">
<!-- Barre au-dessus de l'aperçu : rappel du document et sorties possibles.
Le tableau est dense (en-têtes de colonnes en texte vertical), donc on met en
avant l'ouverture plein écran, qui donne le zoom de la visionneuse du navigateur -->
<div class="pdf-viewer-toolbar">
<span><i class="fa-solid fa-file-pdf"></i> Tableau de synthèse E4 (PDF, 1 page)</span>
<span class="synthese-actions">
<!-- Une seule action mise en avant : l'ouverture plein écran, celle qui rend
vraiment le tableau lisible. Le téléchargement reste en bouton discret,
même style que sur les fiches projets (voir pages/details.php) -->
<a class="btn btn-primary pdf-download-btn" href="<?php echo $cheminPdf; ?>" target="_blank" rel="noopener noreferrer">
<i class="fa-solid fa-up-right-from-square"></i> Plein écran
</a>
<a class="btn btn-ghost pdf-download-btn" href="<?php echo $cheminPdf; ?>" download>
<i class="fa-solid fa-download"></i> Télécharger
</a>
</span>
</div>
<!-- Aperçu intégré. L'attribut title sert aux lecteurs d'écran, et le contenu de
secours s'affiche si le navigateur ne sait pas afficher un PDF dans une iframe
(c'est le cas de plusieurs navigateurs mobiles) -->
<!-- #view=FitH ajuste le document à la largeur, pagemode=none masque le panneau de
vignettes qui mange la moitié de la place sur un écran étroit (respecté par
Firefox, ignoré sans dommage par les autres) -->
<iframe class="pdf-viewer synthese-viewer" src="<?php echo $cheminPdf; ?>#view=FitH&amp;pagemode=none"
title="Tableau de synthèse des réalisations professionnelles, épreuve E4 du BTS SIO">
<p class="synthese-repli">
Ton navigateur ne peut pas afficher le PDF directement.
<a href="<?php echo $cheminPdf; ?>" target="_blank" rel="noopener noreferrer">Ouvre-le dans un nouvel onglet</a>.
</p>
</iframe>
</div>
<?php
// Ajout du footer
require_once('../elements/footer.php');
?>
+12 -6
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once(__DIR__ . '/../functions/security.php');
require_once '../functions/databaseConnection.php';
require_once('../functions/sendLogs.php');
@@ -9,6 +9,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();
// La requête doit être un POST sinon on l'a refuse
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
@@ -16,21 +19,24 @@ if ($_SERVER['REQUEST_METHOD'] === '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 vide
if (!empty($name) && !empty($logo_url)) {
// 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, logo_url, link_url, position) VALUES (:category, :name, :logo_url, :link_url, :position)');
$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,
':logo_url' => $logo_url,
':icon' => $icon !== '' ? $icon : null,
':logo_url' => $logo_url !== '' ? $logo_url : null,
':link_url' => $link_url,
':position' => $position
]);
@@ -48,7 +54,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
exit;
}
} else {
echo "Veuillez remplir tous les champs obligatoires.";
echo "Veuillez remplir le nom et au moins une icône ou une image.";
exit;
}
} else {
+18 -4
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once(__DIR__ . '/../functions/security.php');
require_once '../functions/databaseConnection.php';
require_once('../functions/sendLogs.php');
require_once('../functions/pdfUpload.php');
@@ -10,6 +10,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();
// 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()) {
@@ -42,7 +45,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
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, status, tags, description, image_url, markdown, link_url, pdf_data, pdf_filename) VALUES (:title, :status, :tags, :description, :image_url, :markdown, :link_url, :pdf_data, :pdf_filename)');
// 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->bindValue(':title', $title, PDO::PARAM_STR);
@@ -52,10 +58,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$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->bindValue(':pdf_data', $pdfData, $pdfData === null ? PDO::PARAM_NULL : PDO::PARAM_LOB);
$stmt->bindValue(':pdf_filename', $pdfFilename, $pdfFilename === null ? PDO::PARAM_NULL : 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);
+18 -4
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once(__DIR__ . '/../functions/security.php');
require_once '../functions/databaseConnection.php';
require_once('../functions/sendLogs.php');
require_once('../functions/pdfUpload.php');
@@ -10,6 +10,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();
// 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()) {
@@ -42,7 +45,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!empty($title) && !empty($description) && !empty($duration) && !empty($image_url)) {
try {
// On prépare la requête SQL pour insérer la nouvelle école
$stmt = $dbh->prepare('INSERT INTO schools (title, status, tags, description, duration, image_url, markdown, link_url, pdf_data, pdf_filename) VALUES (:title, :status, :tags, :description, :duration, :image_url, :markdown, :link_url, :pdf_data, :pdf_filename)');
// 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);
@@ -53,10 +59,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$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->bindValue(':pdf_data', $pdfData, $pdfData === null ? PDO::PARAM_NULL : PDO::PARAM_LOB);
$stmt->bindValue(':pdf_filename', $pdfFilename, $pdfFilename === null ? PDO::PARAM_NULL : 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);
+4 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once(__DIR__ . '/../functions/security.php');
require_once '../functions/databaseConnection.php';
require_once('../functions/sendLogs.php');
@@ -9,6 +9,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();
// La requête doit être un POST sinon on l'a refuse
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+4 -1
View File
@@ -1,6 +1,6 @@
<?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
@@ -12,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
+18 -4
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once(__DIR__ . '/../functions/security.php');
require_once '../functions/databaseConnection.php';
require_once('../functions/sendLogs.php');
require_once('../functions/pdfUpload.php');
@@ -10,6 +10,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();
// 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()) {
@@ -43,7 +46,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
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, status, xptype, tags, description, duration, image_url, markdown, link_url, pdf_data, pdf_filename) VALUES (:title, :status, :xptype, :tags, :description, :duration, :image_url, :markdown, :link_url, :pdf_data, :pdf_filename)');
// 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->bindValue(':title', $title, PDO::PARAM_STR);
@@ -55,10 +61,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$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->bindValue(':pdf_data', $pdfData, $pdfData === null ? PDO::PARAM_NULL : PDO::PARAM_LOB);
$stmt->bindValue(':pdf_filename', $pdfFilename, $pdfFilename === null ? PDO::PARAM_NULL : 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);
+11 -3
View File
@@ -3,7 +3,7 @@
// 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');
@@ -38,6 +38,11 @@ if (!$user || !password_verify($password, $user['password'])) {
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'];
@@ -54,8 +59,11 @@ try {
// Log Discord : connexion réussie
sendLog("Connexion réussie", $user['username']);
//Redirection vers home.php puisque que l'utilisateur est forcément connecté
// Redirection après connexion : vers presentation.php si on vient de s'y faire bloquer
// (voir pages/login.php, seule valeur acceptée pour ne pas ouvrir une redirection
// arbitraire), vers home.php sinon
// (?login=1 déclenche la notification "Connexion réussie" sur la page)
header('Location: ./../pages/home.php?login=1');
$pageRetour = ($_POST['redirect'] ?? '') === 'presentation' ? 'presentation.php' : 'home.php';
header('Location: ./../pages/' . $pageRetour . '?login=1');
exit;
?>
+3 -2
View File
@@ -2,8 +2,8 @@
// 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');
@@ -21,6 +21,7 @@ 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;
}
+17 -7
View File
@@ -3,7 +3,7 @@
// Le fichier téléchargé peut ensuite être ré-importé via importData.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
@@ -15,22 +15,32 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
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', 'pdf_data', 'pdf_filename', 'created_at'],
'experiences' => ['id', 'title', 'status', 'xptype', 'tags', 'description', 'duration', 'image_url', 'markdown', 'link_url', 'pdf_data', 'pdf_filename', 'created_at'],
'schools' => ['id', 'title', 'status', 'tags', 'description', 'duration', 'image_url', 'markdown', 'link_url', 'pdf_data', 'pdf_filename', '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 = [
'projects' => ['pdf_data'],
'experiences' => ['pdf_data'],
'schools' => ['pdf_data'],
'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)
+17 -8
View File
@@ -3,7 +3,7 @@
// ATTENTION : les données actuelles des tables présentes dans le fichier sont ÉCRASÉES
// 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
@@ -15,6 +15,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();
// 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');
@@ -29,21 +32,27 @@ 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
// 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', 'pdf_data', 'pdf_filename', 'created_at'],
'experiences' => ['id', 'title', 'status', 'xptype', 'tags', 'description', 'duration', 'image_url', 'markdown', 'link_url', 'pdf_data', 'pdf_filename', 'created_at'],
'schools' => ['id', 'title', 'status', 'tags', 'description', 'duration', 'image_url', 'markdown', 'link_url', 'pdf_data', 'pdf_filename', '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 = [
'projects' => ['pdf_data'],
'experiences' => ['pdf_data'],
'schools' => ['pdf_data'],
'project_pdfs' => ['pdf_data'],
'experience_pdfs' => ['pdf_data'],
'school_pdfs' => ['pdf_data'],
];
try {
+4 -1
View File
@@ -1,6 +1,6 @@
<?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
@@ -12,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');
+4 -1
View File
@@ -1,6 +1,6 @@
<?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
@@ -12,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');
+4 -1
View File
@@ -1,6 +1,6 @@
<?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
@@ -12,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');
+4 -1
View File
@@ -1,6 +1,6 @@
<?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
@@ -12,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');
+4 -1
View File
@@ -1,6 +1,6 @@
<?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
@@ -12,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"];
+4 -1
View File
@@ -1,6 +1,6 @@
<?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
@@ -12,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');
+6 -1
View File
@@ -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;
}
+1 -1
View File
@@ -3,7 +3,7 @@
require_once('../../idDiscord.php');
// Ajout des logs Discord
session_start();
require_once(__DIR__ . '/../functions/security.php');
require_once('../functions/sendLogs.php');
// Envoi de formulaire de contact via WebHook discord
+29
View File
@@ -0,0 +1,29 @@
<?php
// On démarre la session
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.php');
// reinitialiserTentativesPresentation
require_once('../functions/presentationAuth.php');
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
header('Location: ../pages/home.php');
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
$ipHash = $_GET['ip'] ?? '';
if ($ipHash !== '') {
reinitialiserTentativesPresentation($ipHash);
sendLog('Déblocage manuel du mode présentation pour une IP');
}
// On renvoie l'utilisateur sur admin.php, directement sur la section Mode présentation
header('Location: ../pages/admin.php#admin-presentation');
exit;
+10 -5
View File
@@ -1,6 +1,6 @@
<?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
@@ -12,10 +12,13 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
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['logourl'])
(empty($_POST['icon']) && empty($_POST['logourl']))
) {
header('Location: ../pages/admin.php');
exit;
@@ -25,19 +28,21 @@ if ( // Si les champs sont vides on renvoie vers admin.php
$id = (int) $_POST['id'];
$category = ($_POST['category'] ?? '') === 'partner' ? 'partner' : 'tool';
$name = trim($_POST['name']);
$logourl = trim($_POST['logourl']);
$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, logo_url = :logo_url, link_url = :link_url, position = :position
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(':logo_url', $logourl, 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);
+51
View File
@@ -0,0 +1,51 @@
<?php
// On démarre la session
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
require_once('../functions/sendLogs.php');
// PRESENTATION_PIN_LENGTH
require_once('../functions/presentationAuth.php');
// Si l'utilisateur n'est pas admin il ne peut pas executer le reste du script
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
header('Location: ../pages/home.php');
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// La requête doit être un POST sinon on l'a refuse
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../pages/admin.php');
exit;
}
$pin = trim($_POST['pin'] ?? '');
// Le code doit faire exactement 9 chiffres, rien d'autre : sinon on ignore la demande
// plutôt que d'enregistrer un code invalide (on ne veut jamais bloquer l'accès par erreur)
if (!preg_match('/^\d{' . PRESENTATION_PIN_LENGTH . '}$/', $pin)) {
header('Location: ../pages/admin.php#admin-presentation');
exit;
}
try {
// On ne stocke jamais le code en clair, seulement son hash (comme un mot de passe)
$stmt = $dbh->prepare('INSERT INTO settings (name, value) VALUES (:name, :value) ON DUPLICATE KEY UPDATE value = VALUES(value)');
$stmt->execute([
':name' => 'presentation_pin_hash',
':value' => password_hash($pin, PASSWORD_DEFAULT)
]);
} catch (PDOException $e) {
die("Erreur lors du changement du code PIN : " . $e->getMessage());
}
// Log Discord
sendLog('Changement du code PIN du mode présentation');
// On renvoie l'utilisateur sur admin.php, directement sur la section Mode présentation
header('Location: ../pages/admin.php#admin-presentation');
exit;
+29 -13
View File
@@ -1,6 +1,6 @@
<?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
@@ -13,6 +13,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();
// 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()) {
@@ -61,15 +64,13 @@ if (is_array($pdfResultat)) {
}
try { // On essaie d'executer une requête qui est préparée
$sql = '
// 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, status = :status, tags = :tags, description = :description, image_url = :image_url, markdown = :markdown, link_url = :link_url';
if ($updatePdf) {
$sql .= ', pdf_data = :pdf_data, pdf_filename = :pdf_filename';
}
$sql .= ' WHERE id = :id';
$stmt = $dbh->prepare($sql);
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);
@@ -77,12 +78,27 @@ try { // On essaie d'executer une requête qui est préparée
$stmt->bindValue(':image_url', $imageurl, PDO::PARAM_STR);
$stmt->bindValue(':markdown', $markdown, PDO::PARAM_STR);
$stmt->bindValue(':link_url', $link_url, PDO::PARAM_STR);
if ($updatePdf) {
$stmt->bindValue(':pdf_data', $pdfData, $pdfData === null ? PDO::PARAM_NULL : PDO::PARAM_LOB);
$stmt->bindValue(':pdf_filename', $pdfFilename, $pdfFilename === null ? PDO::PARAM_NULL : 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());
}
+29 -13
View File
@@ -1,6 +1,6 @@
<?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
@@ -13,6 +13,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();
// 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()) {
@@ -62,15 +65,13 @@ if (is_array($pdfResultat)) {
}
try { // On essaie d'executer une requête qui est préparée
$sql = '
// 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';
if ($updatePdf) {
$sql .= ', pdf_data = :pdf_data, pdf_filename = :pdf_filename';
}
$sql .= ' WHERE id = :id';
$stmt = $dbh->prepare($sql);
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);
@@ -79,12 +80,27 @@ try { // On essaie d'executer une requête qui est préparée
$stmt->bindValue(':image_url', $imageurl, PDO::PARAM_STR);
$stmt->bindValue(':markdown', $markdown, PDO::PARAM_STR);
$stmt->bindValue(':link_url', $link_url, PDO::PARAM_STR);
if ($updatePdf) {
$stmt->bindValue(':pdf_data', $pdfData, $pdfData === null ? PDO::PARAM_NULL : PDO::PARAM_LOB);
$stmt->bindValue(':pdf_filename', $pdfFilename, $pdfFilename === null ? PDO::PARAM_NULL : 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());
}
+37 -4
View File
@@ -1,6 +1,6 @@
<?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
@@ -12,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();
// La requête doit être un POST sinon on l'a refuse
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../pages/admin.php');
@@ -20,11 +23,35 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
// 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
$reglages = [
// 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
@@ -41,7 +68,13 @@ try { // On essaie d'executer une requête qui est préparée pour chaque régla
}
// Log Discord
sendLog("Modification de la notification de la page d'accueil");
$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');
+4 -1
View File
@@ -1,6 +1,6 @@
<?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
@@ -12,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();
if ( // Si les champs sont vides on renvoie vers admin.php
!isset($_POST['id']) || !is_numeric($_POST['id']) ||
empty($_POST['title']) ||
+29 -13
View File
@@ -1,6 +1,6 @@
<?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
@@ -13,6 +13,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();
// 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()) {
@@ -65,15 +68,13 @@ if (is_array($pdfResultat)) {
}
try { // On essaie d'executer une requête qui est préparée
$sql = '
// 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, status = :status, xptype = :xptype, tags = :tags, description = :description, duration = :duration, image_url = :image_url, markdown = :markdown, link_url = :link_url';
if ($updatePdf) {
$sql .= ', pdf_data = :pdf_data, pdf_filename = :pdf_filename';
}
$sql .= ' WHERE id = :id';
$stmt = $dbh->prepare($sql);
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);
@@ -83,12 +84,27 @@ try { // On essaie d'executer une requête qui est préparée
$stmt->bindValue(':image_url', $imageurl, PDO::PARAM_STR);
$stmt->bindValue(':markdown', $markdown, PDO::PARAM_STR);
$stmt->bindValue(':link_url', $link_url, PDO::PARAM_STR);
if ($updatePdf) {
$stmt->bindValue(':pdf_data', $pdfData, $pdfData === null ? PDO::PARAM_NULL : PDO::PARAM_LOB);
$stmt->bindValue(':pdf_filename', $pdfFilename, $pdfFilename === null ? PDO::PARAM_NULL : 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());
}
+6 -4
View File
@@ -6,10 +6,12 @@
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' => 'projects',
'experience' => 'experiences',
'ecole' => 'schools',
'projet' => ['pdf' => 'project_pdfs', 'colonne' => 'project_id'],
'experience' => ['pdf' => 'experience_pdfs', 'colonne' => 'experience_id'],
'ecole' => ['pdf' => 'school_pdfs', 'colonne' => 'school_id'],
];
$type = $_GET['type'] ?? '';
@@ -21,7 +23,7 @@ if (!isset($tables[$type]) || !is_numeric($id)) {
}
try {
$stmt = $dbh->prepare('SELECT pdf_data, pdf_filename FROM `' . $tables[$type] . '` WHERE id = :id');
$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);
+261
View File
@@ -0,0 +1,261 @@
/* ---- MODE PRÉSENTATION (page autonome, plein écran) ----
Ce fichier ne s'occupe QUE de la lecture immersive une fois le code déverrouillé.
L'écran de saisie du code (avant déverrouillage) vit dans le site normal et est
stylé dans primary_css.css, comme le reste des pages (voir pages/presentation.php). */
/* Barre du haut : pas de nav, juste le rappel du mode + sortie. Même traitement
visuel que .site-header (position sticky + flou) pour rester cohérent avec le site */
.presentation-bar {
position: sticky;
top: 0;
z-index: 100;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 14px clamp(16px, 4vw, 48px);
background: var(--header-bg);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid var(--border);
}
.presentation-bar-tag {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 14px;
font-weight: 600;
}
.presentation-bar-tag i {
color: var(--accent);
}
.presentation-bar-actions {
display: flex;
align-items: center;
gap: 10px;
}
.presentation-exit {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 16px;
height: 42px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text-muted);
font-size: 14px;
font-weight: 600;
text-decoration: none;
transition: color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
.presentation-exit:hover {
color: var(--text);
border-color: var(--accent-border);
transform: translateY(-1px);
}
/* Barre de progression de lecture : ne s'anime que si le navigateur sait suivre le
scroll en CSS pur (pas de JS de scroll ici, volontairement, pour rester fluide).
Sans ce support, la barre reste simplement invisible : aucune régression visuelle. */
.presentation-progress {
position: sticky;
top: 70px;
z-index: 99;
height: 3px;
background: var(--accent);
transform-origin: left;
transform: scaleX(0);
opacity: 0;
}
@supports (animation-timeline: scroll()) {
.presentation-progress {
opacity: 1;
animation: presentation-progress-scroll linear;
animation-timeline: scroll(root);
}
@keyframes presentation-progress-scroll {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
}
/* Colonne de lecture : centrée, largeur confortable, grand respire au-dessus/en dessous */
.presentation-reader {
max-width: 760px;
margin: 0 auto;
padding: clamp(48px, 8vw, 96px) clamp(20px, 5vw, 24px) 120px;
}
.presentation-empty {
text-align: center;
color: var(--text-muted);
font-size: 16px;
padding: 80px 20px;
}
/* Le contenu markdown reprend .markdown-content (voir primary_css.css) mais en plus
grand : c'est le seul contenu de la page, il peut respirer davantage qu'inséré au
milieu d'une fiche projet */
.presentation-markdown {
margin-top: 0;
padding-top: 0;
border-top: none;
font-size: 18px;
line-height: 1.85;
}
.presentation-markdown h1 { font-size: clamp(30px, 5vw, 42px); }
.presentation-markdown h2 { font-size: clamp(24px, 3.5vw, 30px); }
.presentation-markdown h3 { font-size: clamp(20px, 3vw, 24px); }
.presentation-markdown img {
width: 100%;
margin: 28px 0;
box-shadow: var(--shadow-sm);
}
@media (max-width: 620px) {
.presentation-bar-tag span {
display: none;
}
.presentation-reader {
padding-top: 40px;
}
}
/* ---- MODE DIAPORAMA ----
Activé dès que le contenu du panel admin contient au moins une ligne "---" : le
texte est alors coupé en plusieurs diapositives (voir pages/presentation.php),
affichées une par une avec une navigation en bas d'écran plutôt qu'en défilement. */
.presentation-deck {
position: relative;
max-width: 760px;
margin: 0 auto;
padding: clamp(48px, 8vw, 96px) clamp(20px, 5vw, 24px) 140px;
min-height: calc(100dvh - 70px);
display: flex;
align-items: center;
/* Toute la diapositive est cliquable pour avancer/reculer (voir le JS) : le curseur
le signale, sauf sur les vrais liens/boutons qui gardent leur propre curseur */
cursor: pointer;
}
.slide {
display: none;
width: 100%;
}
.slide.is-active {
display: block;
animation: slide-enter 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slide-enter {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.slide.is-active {
animation: none;
}
}
/* La navigation reprend le même traitement "pilule flottante" que le bouton de sortie */
.slide-nav {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
z-index: 90;
display: flex;
align-items: center;
gap: 14px;
padding: 10px 18px;
background: var(--header-bg);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid var(--border);
border-radius: 999px;
box-shadow: var(--shadow);
}
.slide-nav-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
flex-shrink: 0;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 50%;
color: var(--text);
cursor: pointer;
transition: color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
.slide-nav-btn:hover:not(:disabled) {
color: var(--accent-light);
border-color: var(--accent-border);
transform: translateY(-1px);
}
.slide-nav-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.slide-progress {
width: 90px;
height: 4px;
background: var(--border);
border-radius: 999px;
overflow: hidden;
}
.slide-progress-bar {
height: 100%;
/* Valeur de repli si le JS n'a pas encore tourné : une diapositive sur le total
affiché par le compteur juste à côté (voir le style inline posé en JS ensuite) */
width: 20%;
background: var(--accent);
transition: width 0.3s ease;
}
.slide-counter {
min-width: 46px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 480px) {
.slide-nav {
gap: 10px;
padding: 8px 14px;
}
.slide-progress {
width: 54px;
}
}
+421 -14
View File
@@ -1,5 +1,3 @@
/* ---- LE CSS EST SOIT GÉNÉRÉ PAR INTEL ARTI SOIT RÉUTILISÉ D'UN AUTRE PROJET ---- */
/* ---- Variables globales du thème (sombre par défaut) ---- */
:root {
--bg: #0a0a10;
@@ -517,6 +515,44 @@ img {
flex-wrap: wrap;
}
/* ---- Bandeau de chiffres clés (page d'accueil) ---- */
.stats-strip {
max-width: 900px;
margin: 0 auto;
padding: 0 20px 70px;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 20px;
text-align: center;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 18px 34px;
min-width: 140px;
border-radius: var(--radius);
background: var(--surface);
border: 1px solid var(--border);
}
.stat-number {
font-size: clamp(28px, 4vw, 38px);
font-weight: 800;
letter-spacing: -0.02em;
color: var(--accent);
}
.stat-label {
font-size: 13.5px;
color: var(--text-muted);
font-weight: 600;
}
.btn {
display: inline-flex;
align-items: center;
@@ -1349,8 +1385,11 @@ img {
}
}
/* Pas de fond/bordure : les logos flottent directement sur le fond de la page,
comme un bandeau "trusted by" classique */
/* Carte derrière chaque logo : suit le thème actif comme le reste des cartes du site.
C'est possible parce que les logos posés dessus ne sont plus affichés tels quels mais
aplatis en silhouette monochrome adaptée au thème (voir la règle suivante) : sans ça,
un logo blanc sur fond transparent (3S Sécurité, TerraTech) disparaîtrait sur une
tuile claire, et un logo noir (Magyar, Gamm Vert) sur une tuile sombre */
.logo-tile {
flex-shrink: 0;
display: flex;
@@ -1359,33 +1398,88 @@ img {
width: 160px;
height: 90px;
padding: 18px 24px;
background: transparent;
border: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
text-decoration: none;
transition: transform 0.2s ease;
transition: transform 0.2s ease, border-color 0.2s ease, background-color 0.2s ease;
}
a.logo-tile:hover {
transform: translateY(-3px);
border-color: var(--accent-border);
background: var(--surface-2);
}
.logo-tile img {
/* Bandeau "Ils m'ont fait confiance" : les logos fournis par les entreprises ont des
teintes très variables (certains blancs, d'autres noirs, tous sur fond transparent).
brightness(0) les aplatit en silhouette noire, invert(1) la repasse en blanc pour le
thème sombre : lisible dans les deux thèmes, et rendu uniforme façon mur de partenaires.
Le bandeau des outils, lui, garde les vraies couleurs (voir .logo-marquee-tools) */
.logo-tile img,
.logo-tile i {
max-width: 100%;
max-height: 100%;
object-fit: contain;
filter: grayscale(1) opacity(0.6);
filter: brightness(0) invert(1) opacity(0.55);
transition: filter 0.25s ease;
}
.logo-tile:hover img {
filter: grayscale(0) opacity(1);
[data-theme="light"] .logo-tile img,
[data-theme="light"] .logo-tile i {
filter: brightness(0) opacity(0.45);
}
/* Sur mobile le survol n'existe pas : on affiche les logos en couleur directement */
/* Taille de la glyphe pour les icônes Devicon (les images gardent leur taille naturelle) */
/* La couleur suit le thème, sinon l'icône serait invisible sur la tuile en thème clair.
:not(.colored) laisse intactes les icônes Devicon affichées aux couleurs de la marque
dans le bandeau des outils (voir afficherVisuelItem dans functions/listSkills.php) */
.logo-tile i {
font-size: 44px;
line-height: 1;
}
.logo-tile i:not(.colored) {
color: #e8e8f2;
}
[data-theme="light"] .logo-tile i:not(.colored) {
color: #191924;
}
.logo-tile:hover img,
.logo-tile:hover i {
filter: brightness(0) invert(1) opacity(1);
}
[data-theme="light"] .logo-tile:hover img,
[data-theme="light"] .logo-tile:hover i {
filter: brightness(0) opacity(1);
}
/* Sur mobile le survol n'existe pas : on affiche les logos plus contrastés directement */
@media (hover: none) {
.logo-tile img {
filter: grayscale(0) opacity(0.85);
.logo-tile img,
.logo-tile i {
filter: brightness(0) invert(1) opacity(0.8);
}
[data-theme="light"] .logo-tile img,
[data-theme="light"] .logo-tile i {
filter: brightness(0) opacity(0.7);
}
}
/* Bandeau "Outils" : pas de niveaux de gris, on veut que les vrais logos des outils
soient reconnaissables immédiatement, sans avoir à survoler chaque tuile */
.logo-marquee-tools .logo-tile img,
.logo-marquee-tools .logo-tile i,
.logo-marquee-tools .logo-tile:hover img,
.logo-marquee-tools .logo-tile:hover i,
[data-theme="light"] .logo-marquee-tools .logo-tile img,
[data-theme="light"] .logo-marquee-tools .logo-tile i {
filter: none;
opacity: 1;
}
@media (max-width: 620px) {
@@ -1446,6 +1540,11 @@ a.logo-tile:hover {
object-fit: contain;
}
.logo-admin-preview i {
font-size: 40px;
color: var(--text);
}
.logo-admin-info {
padding: 14px 16px 4px;
}
@@ -1564,6 +1663,14 @@ a.logo-tile:hover {
text-align: center;
}
/* Vraie image de logo (format "img:URL") pour les outils absents des polices d'icônes */
.skill-item-visual {
width: 18px;
height: 18px;
object-fit: contain;
flex-shrink: 0;
}
/* Boutons admin dans les cartes de compétences */
.skill-category .admin-actions {
margin-top: 18px;
@@ -1955,6 +2062,18 @@ a.card-link::after {
font-size: 0.85em;
}
/* Texte d'introduction sous un titre de section admin (voir Logos partenaires) */
.admin-section-intro {
max-width: 1200px;
margin: 0 auto 16px;
padding: 0 clamp(16px, 4vw, 40px);
}
.admin-section-intro a {
color: var(--accent-light);
font-weight: 600;
}
/* Bloc repliable contenant chaque formulaire */
.admin-block {
max-width: 1200px;
@@ -2377,3 +2496,291 @@ main {
animation: none;
}
}
/* ---- MODE PRÉSENTATION : écran de saisie du code / écran "trop de tentatives" ----
Ces deux écrans vivent dans le chrome normal du site (header + footer), contrairement
à la lecture immersive une fois déverrouillé (voir styles/presentation.css) */
.presentation-lock-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 65vh;
padding: 40px 16px;
}
.presentation-lock-card {
background: linear-gradient(180deg, var(--surface) 0%, var(--bg-soft) 100%);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
padding: 44px 40px;
width: 460px;
max-width: 100%;
text-align: center;
}
.presentation-lock-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
margin-bottom: 20px;
border-radius: 999px;
background: var(--accent-soft);
border: 1px solid var(--accent-border);
color: var(--accent-light);
font-size: 13px;
font-weight: 700;
}
.presentation-lock-badge--danger {
background: rgba(229, 72, 77, 0.12);
border-color: rgba(229, 72, 77, 0.35);
color: #ff8a8d;
}
[data-theme="light"] .presentation-lock-badge--danger {
color: var(--danger-dark);
}
.presentation-lock-card h1 {
margin: 0 0 10px;
font-size: 26px;
font-weight: 800;
letter-spacing: -0.01em;
color: var(--text);
}
.presentation-lock-card p {
margin: 0 0 6px;
color: var(--text-soft);
font-size: 15px;
line-height: 1.6;
}
.presentation-lock-card p:last-of-type {
margin-bottom: 22px;
}
.presentation-lock-btn {
width: 100%;
justify-content: center;
}
.presentation-lock-error {
margin: 16px 0 0;
padding: 10px 14px;
border-radius: var(--radius-sm);
background: rgba(229, 72, 77, 0.12);
border: 1px solid rgba(229, 72, 77, 0.3);
color: #ff8a8d;
font-size: 14px;
}
[data-theme="light"] .presentation-lock-error {
color: var(--danger-dark);
}
/* Les 9 cases du code PIN */
.presentation-pin-form {
margin-top: 22px;
}
.pin-boxes {
display: flex;
justify-content: center;
gap: 8px;
margin-bottom: 22px;
}
.pin-box {
width: 38px;
height: 48px;
text-align: center;
background-color: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
color: var(--text);
font-family: var(--font);
font-size: 20px;
font-weight: 700;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.pin-box:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
/* Bouton "maintenir pour afficher" sous les cases du code : discret, il ne doit pas
attirer l'oeil autant que le bouton Valider */
.pin-reveal {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0 auto 20px;
padding: 7px 14px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 999px;
color: var(--text-muted);
font-family: var(--font);
font-size: 13px;
font-weight: 600;
cursor: pointer;
/* Le maintien affiche le code : on empêche la sélection du texte du bouton pendant
l'appui prolongé, sinon le navigateur surligne le libellé */
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
transition: color 0.2s ease, border-color 0.2s ease;
}
.pin-reveal:hover {
color: var(--text-soft);
border-color: var(--border-strong);
}
/* Pendant que le bouton est maintenu : accent orange pour montrer que le code est visible */
.pin-reveal.is-revealing {
color: var(--accent-light);
border-color: var(--accent-border);
background: var(--accent-soft);
}
.pin-reveal:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.presentation-pin-submit {
width: 100%;
}
.presentation-pin-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
filter: none;
transform: none;
}
/* Pastilles "tentatives restantes", même principe que sur le captcha */
.pin-attempts {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin-top: 24px;
color: var(--text-muted);
font-size: 13px;
}
.pin-attempts-dots {
display: flex;
gap: 6px;
}
.pin-attempts-dots span {
width: 9px;
height: 9px;
border-radius: 50%;
}
.pin-attempts-dots .dot-filled {
background: var(--accent);
}
.pin-attempts-dots .dot-empty {
background: var(--border-strong);
}
@media (max-width: 480px) {
.pin-box {
width: 30px;
height: 42px;
font-size: 17px;
}
.pin-boxes {
gap: 5px;
}
}
/* Petit bouton "Insérer une image" au-dessus du champ markdown du mode présentation
(panel admin) : insère la syntaxe markdown au curseur, sans avoir à s'en souvenir */
.markdown-toolbar {
margin-bottom: 8px;
}
.markdown-insert-image {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-soft);
font-family: var(--font);
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
transition: color 0.2s ease, border-color 0.2s ease;
}
.markdown-insert-image:hover {
color: var(--accent-light);
border-color: var(--accent-border);
}
/* ---- PAGE TABLEAU DE SYNTHÈSE ----
Le PDF est le contenu principal de la page : on lui donne plus de largeur et plus de
hauteur que l'aperçu inséré dans une fiche projet, parce que le tableau est dense et
qu'il devient vite illisible sur une petite surface */
.synthese-page {
max-width: 1200px;
margin: 20px auto clamp(48px, 7vw, 72px);
padding: 0 clamp(14px, 4vw, 40px);
}
/* On réutilise .pdf-viewer-toolbar (voir plus haut) mais sans le trait de séparation
hérité de .pdf-viewer-wrap, qui n'a pas lieu d'être en haut de page */
.synthese-page .pdf-viewer-toolbar {
margin-bottom: 14px;
}
.synthese-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.synthese-viewer {
height: 82vh;
min-height: 520px;
}
.synthese-repli {
color: var(--text-soft);
padding: 20px;
}
/* Sur mobile l'aperçu intégré d'un PDF est souvent inutilisable (plusieurs navigateurs
affichent juste une zone vide) : on le garde plus court, les deux boutons au-dessus
restant le vrai chemin d'accès au document */
@media (max-width: 700px) {
.synthese-viewer {
height: 60vh;
min-height: 360px;
}
.synthese-page .pdf-viewer-toolbar {
flex-direction: column;
align-items: flex-start;
}
}