Compare commits

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

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

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

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

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

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

After

Width:  |  Height:  |  Size: 25 KiB

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