Compare commits

...
9 Commits
Author SHA1 Message Date
Esteban e5bd31fc65 Patch gitignore 2026-08-08 19:36:29 +02:00
Esteban a4ccd7e2a9 Remove html file 2026-07-11 04:59:37 +02:00
Esteban e26dbd388f Changement d'adresse dynamique 2026-07-09 14:40:26 +02:00
Esteban a1c99f1de8 Suppression easteregg + fix captcha 2026-07-09 11:54:37 +02:00
Esteban c04a9b5ab8 Ajout d'un easteregg 2026-07-03 10:56:54 +02:00
Esteban c28fae7061 Patch php 2026-07-03 02:05:18 +02:00
Esteban 91060537c8 Fix lag 2026-07-03 02:02:13 +02:00
Esteban 5b76997895 Patch 2026-07-03 00:23:54 +02:00
Esteban 4c35c727d5 Sauvegarde 2026-07-03 00:23:37 +02:00
45 changed files with 965 additions and 263 deletions

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
# Config Claude Code
.claude/
# macOS
.DS_Store
**/.DS_Store
# Éditeurs
.vscode/
.idea/
*.iml
# Logs
*.log
logs/
+13 -10
View File
@@ -1,5 +1,5 @@
<?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');
@@ -180,16 +180,19 @@ $spamSubjects = [
['text' => 'CONFIRMEZ VOTRE IDENTITÉ POUR RECEVOIR VOTRE ARGENT', 'reason' => 'Promesse d’argent contre des données personnelles'],
];
// Liste des expéditeurs spam
// Liste des expéditeurs spam : mêmes formats d'affichage (nom, pas d'adresse brute) que les
// expéditeurs normaux ci-dessus, pour qu'il ne soit pas possible de repérer les mails
// frauduleux d'un simple coup d'œil sur l'expéditeur. La seule façon de les distinguer doit
// être de lire le contenu (sujet), comme un vrai email de phishing usurpant une identité connue.
$spamSenders = [
'service-urgent@compte-bloque-now.biz',
'cadeau-gratuit@super-offre-win.click',
'banque-alerte@verification-securite.top',
'colis-stop@paiement-obligatoire.help',
'admin-compte@action-immediate.world',
'support-prime@argent-rapide-fast.support',
'verification@motdepasse-danger.win',
'alerte@compte-fermeture.click'
'Sécurité du compte',
'Service client',
'Banque familiale',
'Livraison Colis',
'Support technique',
'Service facturation',
'Assurance Habitation',
'Mutuelle Santé'
];
// Liste des aperçus normaux
+63 -89
View File
@@ -1,16 +1,11 @@
-- On créer la base de donnée si elle n'existe déjà aps
CREATE DATABASE IF NOT EXISTS portfolio
CREATE DATABASE IF NOT EXISTS portfolio;
USE portfolio;
-- On créer un utilisateur avec un mot de passe fort qui est à changer après execution dus script (voir bitwarden)
CREATE USER 'srv_portfolio'@'%' IDENTIFIED BY 'mdp';
-- On met les droits au compte sur la base portfolio à l'utilisateur
-- Mot de passe à changer après exécution du script (voir Bitwarden)
CREATE USER IF NOT EXISTS 'srv_portfolio'@'%' IDENTIFIED BY 'mdp';
GRANT ALL ON portfolio.* TO 'srv_portfolio'@'%';
-- On actualise les permissions
FLUSH PRIVILEGES;
-- Création de la table user : (Généré par Beekeeper Studio : équivalent à dbGate)
CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(50) NOT NULL,
@@ -18,63 +13,75 @@ CREATE TABLE IF NOT EXISTS users (
role ENUM('admin', 'user') NOT NULL DEFAULT 'user',
created_at DATETIME NULL DEFAULT NULL,
last_login DATETIME NULL DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE INDEX idx_username (username)
);
-- Création de la table projects : (Généré par Beekeeper Studio)
INSERT IGNORE INTO users (id, created_at, last_login, password, role, username)
VALUES (11, '2026-06-08 11:10:21', '2026-06-08 13:10:41', '$2y$12$x01fDqrHQEYIyj7MDHa72eRyl2j/n5cNq9sv3.FIiQCaRsB50Q4iK', 'admin', 'admin');
CREATE TABLE IF NOT EXISTS projects (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
title VARCHAR(100) NOT NULL,
status VARCHAR(100) NULL,
tags VARCHAR(255) NULL,
description TEXT NOT NULL,
image_url VARCHAR(255) NOT NULL,
markdown LONGTEXT NULL,
link_url VARCHAR(255) NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
-- Création de la table projects : (Généré par Beekeeper Studio)
CREATE TABLE IF NOT EXISTS experiences (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
title VARCHAR(100) NOT NULL,
status VARCHAR(100) NULL,
xptype VARCHAR(100) NOT NULL,
tags VARCHAR(255) NULL,
description TEXT NOT NULL,
duration VARCHAR(100) NOT NULL,
image_url VARCHAR(255) NOT NULL,
markdown LONGTEXT NULL,
link_url VARCHAR(255) NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
-- Compte de test (PLUS A JOUR METTRE UN HASH)
-- INSERT INTO users (username, password, role)
-- VALUES ('admin', 'admin', 'admin');
insert into `users` (`created_at`, `id`, `last_login`, `password`, `role`, `username`)
values ('2026-06-08 11:10:21', 11, '2026-06-08 13:10:41', '$2y$12$x01fDqrHQEYIyj7MDHa72eRyl2j/n5cNq9sv3.FIiQCaRsB50Q4iK', 'admin', 'admin');
CREATE TABLE IF NOT EXISTS schools (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
title VARCHAR(100) NOT NULL,
status VARCHAR(100) NULL,
tags VARCHAR(255) NULL,
description TEXT NOT NULL,
duration VARCHAR(100) NOT NULL,
image_url VARCHAR(255) NOT NULL,
markdown LONGTEXT NULL,
link_url VARCHAR(255) NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
-- Ajout d'une collonne status pour les projets et les expériences pour pouvoir différencier les projets/expériences en cours, terminés ou à venir
ALTER TABLE projects
ADD COLUMN status VARCHAR(100) NULL AFTER title;
ALTER TABLE experiences
ADD COLUMN status VARCHAR(100) NULL AFTER title;
-- Table de réglages du site (clé / valeur) : utilisée pour la notification de la page d'accueil
-- Réglages du site (clé / valeur), utilisés pour la notification de la page d'accueil et le captcha
CREATE TABLE IF NOT EXISTS settings (
name VARCHAR(50) NOT NULL,
value TEXT NULL,
PRIMARY KEY (name)
);
-- Valeurs par défaut de la notification (modifiables ensuite depuis le panel Admin)
INSERT INTO settings (name, value) VALUES
('site_notice_title', 'Des bugs peuvent encore se produire.'),
('site_notice_text', 'Si le CSS ou les images s''affichent mal, pensez à vider le cache de votre navigateur (Ctrl + F5 ou Cmd + Shift + R) pour voir les dernières modifications.'),
('site_notice_enabled', '1')
('site_notice_enabled', '1'),
('captcha_enabled', '1'),
('contact_email', 'esteban.moussu@ik.me'),
('contact_email_enabled', '1'),
('contact_address_line1', '20 Grande rue'),
('contact_address_line2', '52500 Broncourt'),
('contact_address_enabled', '1')
ON DUPLICATE KEY UPDATE value = VALUES(value);
-- Table des compétences : une ligne = une catégorie (carte sur la page Compétences)
-- items : un item par ligne, au format "classes-icone-fontawesome|Texte" (l'icône est optionnelle : "Texte" seul fonctionne aussi)
-- items : un item par ligne, au format "classes-icone|Texte" (icône optionnelle)
CREATE TABLE IF NOT EXISTS skills (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
title VARCHAR(100) NOT NULL,
@@ -85,7 +92,6 @@ CREATE TABLE IF NOT EXISTS skills (
PRIMARY KEY (id)
);
-- Compétences actuelles (reprises du CV)
INSERT INTO skills (title, icon, items, position) VALUES
('Systèmes d''exploitation', 'fa-solid fa-computer', 'fa-brands fa-microsoft|Windows (Serveur/XP/8/10/11)\nfa-brands fa-linux|Linux (Debian/Ubuntu)\nfa-brands fa-apple|macOS', 1),
('Réseaux', 'fa-solid fa-network-wired', 'fa-solid fa-location-dot|Adressage IP\nfa-solid fa-diagram-project|VLAN\nfa-solid fa-shield-halved|Pare-feu (iptables/ufw)\nfa-solid fa-globe|DNS (Bind9)\nfa-solid fa-server|DHCP (ISC DHCP)\nfa-solid fa-arrows-left-right|Proxy (Squid)', 2),
@@ -94,75 +100,43 @@ INSERT INTO skills (title, icon, items, position) VALUES
('Développement web', 'fa-solid fa-code', 'fa-brands fa-html5|HTML\nfa-brands fa-css3-alt|CSS\nfa-brands fa-js|JavaScript\nfa-brands fa-php|PHP', 5),
('Virtualisation & Outils', 'fa-solid fa-layer-group', 'fa-solid fa-server|Proxmox\nfa-solid fa-box|VirtualBox\nfa-brands fa-git-alt|Git (GitHub, Gitea)', 6);
-- Table des écoles / formations : affichée sous les expériences professionnelles sur la page Expériences
CREATE TABLE IF NOT EXISTS schools (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
title VARCHAR(100) NOT NULL,
status VARCHAR(100) NULL,
tags VARCHAR(255) NULL,
description TEXT NOT NULL,
duration VARCHAR(100) NOT NULL,
image_url VARCHAR(255) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
-- markdown : contenu de la page détaillée (LONGTEXT = ~4 Go, taille pratiquement illimitée)
-- link_url : lien externe utilisé à la place du markdown si celui-ci est vide
-- Les deux sont facultatifs : si les deux sont vides, la carte n'est pas cliquable
ALTER TABLE projects
ADD COLUMN markdown LONGTEXT NULL,
ADD COLUMN link_url VARCHAR(255) NULL;
ALTER TABLE experiences
ADD COLUMN markdown LONGTEXT NULL,
ADD COLUMN link_url VARCHAR(255) NULL;
ALTER TABLE schools
ADD COLUMN markdown LONGTEXT NULL,
ADD COLUMN link_url VARCHAR(255) NULL;
-- ============================================================
-- Juillet 2026 (4) : bandeaux de logos sur la page d'accueil
-- ("Outils que je sais manipuler" et "Ils m'ont fait confiance"),
-- gérés depuis le panel Admin
-- ============================================================
-- Une seule table pour les deux bandeaux, distingués par la colonne category :
-- - 'tool' => bandeau "Outils que je sais manipuler"
-- - 'partner' => bandeau "Ils m'ont fait confiance"
-- link_url est facultatif : si renseigné, le logo devient cliquable (nouvel onglet)
-- category : 'tool' (bandeau "Outils que je sais manipuler") ou 'partner' (bandeau "Ils m'ont fait confiance")
-- icon : classes CSS Devicon, prioritaire sur logo_url si les deux sont renseignés (au moins un des deux requis)
CREATE TABLE IF NOT EXISTS logo_banners (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
category ENUM('tool', 'partner') NOT NULL,
name VARCHAR(100) NOT NULL,
logo_url VARCHAR(255) NOT NULL,
icon VARCHAR(100) NULL,
logo_url VARCHAR(255) NULL,
link_url VARCHAR(255) NULL,
position INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
-- Table créée vide volontairement : les logos sont ajoutés depuis le panel Admin
-- (section Logos) pour éviter d'afficher des images devinées/potentiellement cassées
-- 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
);
-- ============================================================
-- Juillet 2026 (3) : PDF en drag & drop pour les projets,
-- expériences et écoles, lu depuis un mini lecteur intégré
-- ============================================================
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
);
-- pdf_data : contenu binaire du PDF (LONGBLOB = ~4 Go, largement suffisant)
-- pdf_filename : nom original du fichier (affiché dans le panel admin et le lecteur)
-- Priorité d'affichage sur la page de détails : markdown > pdf > lien externe
ALTER TABLE projects
ADD COLUMN pdf_data LONGBLOB NULL,
ADD COLUMN pdf_filename VARCHAR(255) NULL;
ALTER TABLE experiences
ADD COLUMN pdf_data LONGBLOB NULL,
ADD COLUMN pdf_filename VARCHAR(255) NULL;
ALTER TABLE schools
ADD COLUMN pdf_data LONGBLOB NULL,
ADD COLUMN pdf_filename VARCHAR(255) NULL;
CREATE TABLE IF NOT EXISTS school_pdfs (
school_id INT UNSIGNED NOT NULL,
pdf_filename VARCHAR(255) NOT NULL,
pdf_data LONGBLOB NOT NULL,
PRIMARY KEY (school_id),
CONSTRAINT fk_school_pdfs_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE
);
+14
View File
@@ -0,0 +1,14 @@
; Config PHP pour le conteneur Docker (image php:8.2-apache)
; Copiée dans /usr/local/etc/php/conf.d/ par le Dockerfile généré par scriptsite.sh
; Augmente les limites d'upload pour permettre le dépôt de PDF depuis le panel admin
; post_max_size doit toujours être >= upload_max_filesize
upload_max_filesize = 20M
post_max_size = 20M
; Coupe l'affichage des erreurs PHP dans le HTML en production (sécurité : ne pas exposer
; les chemins serveur aux visiteurs) et les envoie dans les logs Apache/Docker à la place.
; Bonus : évite les warnings HTML qui provoquaient "headers already sent" une fois affichés
; avant le session_start()/header() des scripts.
display_errors = Off
log_errors = On
+2 -1
View File
@@ -52,7 +52,8 @@ if (isset($_GET['success']) && $_GET['success'] === '1') {
var cibles = document.querySelectorAll(
'.project-card, .quick-link-card, .skill-category, .contact-card, ' +
'.login-card, .project-form-card, .cgu-card, .logo-admin-item, ' +
'.page-header, .quick-links-header, .skills-header, .projects-title, .logo-banner-header'
'.page-header, .quick-links-header, .skills-header, .projects-title, .logo-banner-header, ' +
'.stat-item'
);
var observer = new IntersectionObserver(function (entries) {
+29 -1
View File
@@ -6,7 +6,7 @@
// Ajout ici d'un démarrage de session : header étant dans toutes les pages
session_start();
require_once(__DIR__ . '/../functions/security.php');
// Ajout ici la vérification du captcha
require_once('../scripts/requireCaptcha.php');
@@ -20,6 +20,15 @@ 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']);
?>
@@ -31,6 +40,22 @@ $currentPage = basename($_SERVER['PHP_SELF']);
<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">
@@ -52,6 +77,9 @@ $currentPage = basename($_SERVER['PHP_SELF']);
<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>
+24 -8
View File
@@ -12,11 +12,13 @@ function listeLogos($category = null) {
global $dbh;
try {
// SELECT * : la colonne icon est optionnelle (le bandeau continue de fonctionner
// avec les logos déjà en place même si la migration SQL n'a pas encore été exécutée)
if ($category !== null) {
$stmt = $dbh->prepare('SELECT id, category, name, logo_url, link_url, position FROM logo_banners WHERE category = :category ORDER BY position ASC, id ASC');
$stmt = $dbh->prepare('SELECT * FROM logo_banners WHERE category = :category ORDER BY position ASC, id ASC');
$stmt->bindValue(':category', $category, PDO::PARAM_STR);
} else {
$stmt = $dbh->prepare('SELECT id, category, name, logo_url, link_url, position FROM logo_banners ORDER BY category ASC, position ASC, id ASC');
$stmt = $dbh->prepare('SELECT * FROM logo_banners ORDER BY category ASC, position ASC, id ASC');
}
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
@@ -30,7 +32,7 @@ function recupererLogoParId($id) {
global $dbh;
try {
$stmt = $dbh->prepare('SELECT id, category, name, logo_url, link_url, position FROM logo_banners WHERE id = :id');
$stmt = $dbh->prepare('SELECT * FROM logo_banners WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetch(PDO::FETCH_ASSOC);
@@ -39,6 +41,20 @@ function recupererLogoParId($id) {
}
}
// Fonction interne qui affiche le contenu visuel d'une tuile : une icône Devicon si elle est
// renseignée (prioritaire), sinon l'image. Ne retourne rien si aucun des deux n'est présent.
function afficherLogoVisuel($logo) {
$icone = trim($logo['icon'] ?? '');
$image = trim($logo['logo_url'] ?? '');
$nom = htmlspecialchars($logo['name']);
if ($icone !== '') {
echo '<i class="' . htmlspecialchars($icone) . '"></i>';
} elseif ($image !== '') {
echo '<img src="' . htmlspecialchars($image) . '" alt="' . $nom . '" loading="lazy">';
}
}
// Fonction qui affiche le bandeau défilant d'une catégorie de logos sur la page publique
// N'affiche rien si la catégorie est vide (la section entière est masquée côté page d'accueil)
function afficherLogos($category) {
@@ -58,7 +74,6 @@ function afficherLogos($category) {
for ($repetition = 0; $repetition < 2; $repetition++) {
foreach ($logos as $logo) {
$nom = htmlspecialchars($logo['name']);
$image = htmlspecialchars($logo['logo_url']);
$lien = trim($logo['link_url'] ?? '');
if ($lien !== '') {
@@ -67,7 +82,7 @@ function afficherLogos($category) {
echo '<div class="logo-tile" title="' . $nom . '">';
}
echo '<img src="' . $image . '" alt="' . $nom . '" loading="lazy">';
afficherLogoVisuel($logo);
echo $lien !== '' ? '</a>' : '</div>';
}
}
@@ -91,18 +106,19 @@ function afficherLogosAdmin() {
foreach ($logos as $logo) {
$id = (int) $logo['id'];
$nom = htmlspecialchars($logo['name']);
$image = htmlspecialchars($logo['logo_url']);
$badge = $logo['category'] === 'partner' ? 'Entreprise' : 'Outil';
echo '<div class="logo-admin-item">';
echo '<div class="logo-admin-preview"><img src="' . $image . '" alt="' . $nom . '"></div>';
echo '<div class="logo-admin-preview">';
afficherLogoVisuel($logo);
echo '</div>';
echo '<div class="logo-admin-info">';
echo '<span class="tag">' . $badge . '</span>';
echo '<p>' . $nom . '</p>';
echo '</div>';
echo '<div class="admin-actions">';
echo '<a class="btn-submit btn-edit" href="./admin.php?edit_logo=' . $id . '#admin-logos">Modifier</a>';
echo '<a class="btn-submit btn-delete" href="../scripts/removeLogo.php?id=' . $id . '" onclick="return confirm(\'Voulez-vous vraiment supprimer ce logo ?\');">Supprimer</a>';
echo '<a class="btn-submit btn-delete" href="../scripts/removeLogo.php?id=' . $id . '&csrf_token=' . urlencode(csrfToken()) . '" onclick="return confirm(\'Voulez-vous vraiment supprimer ce logo ?\');">Supprimer</a>';
echo '</div>';
echo '</div>';
}
+21 -6
View File
@@ -10,10 +10,17 @@ function listeProjets() {
// Essayer
try {
// Requête SQL qui récupère titre, tags, description, image_url
// SELECT * : les colonnes markdown / link_url sont optionnelles (la page fonctionne
// même si la migration SQL n'a pas encore été exécutée)
$stmt = $dbh->prepare('SELECT * FROM projects ORDER BY id DESC');
// On liste les colonnes explicitement (pas de SELECT *) : le PDF (pdf_data) est
// stocké à part dans project_pdfs (voir migration Juillet 2026 (6)), on ne récupère
// ici que son nom de fichier via LEFT JOIN, sans jamais charger son contenu binaire
// (jusqu'à 15 Mo par PDF) en mémoire pour une simple liste de projets
$stmt = $dbh->prepare('
SELECT p.id, p.title, p.status, p.tags, p.description, p.image_url, p.markdown, p.link_url, p.created_at,
pp.pdf_filename
FROM projects p
LEFT JOIN project_pdfs pp ON pp.project_id = p.id
ORDER BY p.id DESC
');
// Execution de la requête SQL
$stmt->execute();
// Retourne toutes les données collectés dans un ARRAY
@@ -29,7 +36,15 @@ function recupererProjetParId($id) {
global $dbh;
try {
$stmt = $dbh->prepare('SELECT * FROM projects WHERE id = :id');
// Même principe que listeProjets() : le PDF est joint pour son nom de fichier
// uniquement, jamais son contenu binaire
$stmt = $dbh->prepare('
SELECT p.id, p.title, p.status, p.tags, p.description, p.image_url, p.markdown, p.link_url, p.created_at,
pp.pdf_filename
FROM projects p
LEFT JOIN project_pdfs pp ON pp.project_id = p.id
WHERE p.id = :id
');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetch(PDO::FETCH_ASSOC);
@@ -164,7 +179,7 @@ function afficherProjetsAdmin() {
// Boutons qui vont permettrent la modification / Suppresion
echo '<div class="admin-actions">';
echo '<a class="btn-submit btn-edit" href="./admin.php?edit_project=' . $id . '#admin-projets">Modifier</a>';
echo '<a class="btn-submit btn-delete" href="../scripts/removeProject.php?id=' . $id . '" onclick="return confirm(\'Voulez-vous vraiment supprimer ce projet ?\');">Supprimer</a>';
echo '<a class="btn-submit btn-delete" href="../scripts/removeProject.php?id=' . $id . '&csrf_token=' . urlencode(csrfToken()) . '" onclick="return confirm(\'Voulez-vous vraiment supprimer ce projet ?\');">Supprimer</a>';
echo '</div>';
echo '</div>';
+21 -5
View File
@@ -10,9 +10,17 @@ function listeEcoles() {
global $dbh;
try {
// SELECT * : les colonnes markdown / link_url sont optionnelles (la page fonctionne
// même si la migration SQL n'a pas encore été exécutée)
$stmt = $dbh->prepare('SELECT * FROM schools ORDER BY id DESC');
// On liste les colonnes explicitement (pas de SELECT *) : le PDF (pdf_data) est
// stocké à part dans school_pdfs (voir migration Juillet 2026 (6)), on ne récupère
// ici que son nom de fichier via LEFT JOIN, sans jamais charger son contenu binaire
// (jusqu'à 15 Mo par PDF) en mémoire pour une simple liste d'écoles
$stmt = $dbh->prepare('
SELECT s.id, s.title, s.status, s.tags, s.description, s.duration, s.image_url, s.markdown, s.link_url, s.created_at,
sp.pdf_filename
FROM schools s
LEFT JOIN school_pdfs sp ON sp.school_id = s.id
ORDER BY s.id DESC
');
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
@@ -25,7 +33,15 @@ function recupererEcoleParId($id) {
global $dbh;
try {
$stmt = $dbh->prepare('SELECT * FROM schools WHERE id = :id');
// Même principe que listeEcoles() : le PDF est joint pour son nom de fichier
// uniquement, jamais son contenu binaire
$stmt = $dbh->prepare('
SELECT s.id, s.title, s.status, s.tags, s.description, s.duration, s.image_url, s.markdown, s.link_url, s.created_at,
sp.pdf_filename
FROM schools s
LEFT JOIN school_pdfs sp ON sp.school_id = s.id
WHERE s.id = :id
');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetch(PDO::FETCH_ASSOC);
@@ -98,7 +114,7 @@ function afficherEcoleCard($ecole, $admin = false) {
if ($admin) {
echo '<div class="admin-actions">';
echo '<a class="btn-submit btn-edit" href="./admin.php?edit_school=' . $id . '#admin-ecoles">Modifier</a>';
echo '<a class="btn-submit btn-delete" href="../scripts/removeSchool.php?id=' . $id . '" onclick="return confirm(\'Voulez-vous vraiment supprimer cette école ?\');">Supprimer</a>';
echo '<a class="btn-submit btn-delete" href="../scripts/removeSchool.php?id=' . $id . '&csrf_token=' . urlencode(csrfToken()) . '" onclick="return confirm(\'Voulez-vous vraiment supprimer cette école ?\');">Supprimer</a>';
echo '</div>';
}
+116 -2
View File
@@ -32,6 +32,41 @@ function recupererSkillParId($id) {
}
}
// Icônes Devicon dont la couleur de marque "colored" est noire ou quasi-noire (vérifié dans
// devicon.min.css : linux #000, apple #000, bash #293138, ansible #1a1918) : invisibles sur
// la tuile 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) {
@@ -53,7 +88,9 @@ function afficherSkillCard($skill, $admin = false) {
if (strpos($item, '|') !== false) {
list($itemIcone, $label) = array_map('trim', explode('|', $item, 2));
}
echo '<li><i class="' . htmlspecialchars($itemIcone) . '"></i> ' . htmlspecialchars($label) . '</li>';
echo '<li>';
afficherVisuelItem($itemIcone, 'skill-item-visual');
echo ' ' . htmlspecialchars($label) . '</li>';
}
echo '</ul>';
@@ -62,7 +99,7 @@ function afficherSkillCard($skill, $admin = false) {
if ($admin) {
echo '<div class="admin-actions">';
echo '<a class="btn-submit btn-edit" href="./admin.php?edit_skill=' . $id . '#admin-competences">Modifier</a>';
echo '<a class="btn-submit btn-delete" href="../scripts/removeSkill.php?id=' . $id . '" onclick="return confirm(\'Voulez-vous vraiment supprimer cette catégorie de compétences ?\');">Supprimer</a>';
echo '<a class="btn-submit btn-delete" href="../scripts/removeSkill.php?id=' . $id . '&csrf_token=' . urlencode(csrfToken()) . '" onclick="return confirm(\'Voulez-vous vraiment supprimer cette catégorie de compétences ?\');">Supprimer</a>';
echo '</div>';
}
@@ -96,3 +133,80 @@ function afficherSkillsAdmin() {
}
echo '</div>';
}
// Items exclus du bandeau de la home au cas par cas, en plus du filtre automatique
// (icône Devicon "réelle" mais jugée pas assez reconnaissable seule, sans le texte à côté)
const OUTILS_EXCLUS_DU_BANDEAU = ['Bash'];
// Fonction qui récupère tous les items de compétences représentant un vrai outil identifiable
// (icône Devicon ou image "img:"), toutes catégories confondues. Les items en texte seul
// SONT EXCLUS, ainsi que les icônes Font Awesome génériques (fa-solid/fa-brands) : dans un
// bandeau qui n'affiche pas le texte à côté (juste une info-bulle au survol), une icône
// générique comme un pictogramme "carte" pour "Adressage IP" ou "carnet de contact" pour
// "LDAP" ne représente aucun outil reconnaissable — ça reste illisible pour un visiteur qui
// ne survole pas. Ces items restent affichés normalement sur la page Compétences (avec leur
// texte à côté, donc sans ambiguïté), seul le bandeau de la home les filtre.
function listeIconesCompetences() {
$categories = listeSkills();
$resultats = [];
foreach ($categories as $categorie) {
$items = array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $categorie['items'])));
foreach ($items as $item) {
if (strpos($item, '|') === false) {
continue;
}
list($icone, $label) = array_map('trim', explode('|', $item, 2));
if ($icone === '' || $label === '') {
continue;
}
// Seuls les vrais logos (Devicon ou image) sont assez reconnaissables sans texte
$estUnVraiLogo = strpos($icone, 'devicon-') === 0 || strpos($icone, 'img:') === 0;
if (!$estUnVraiLogo) {
continue;
}
if (in_array($label, OUTILS_EXCLUS_DU_BANDEAU, true)) {
continue;
}
$resultats[] = ['icon' => $icone, 'label' => $label];
}
}
return $resultats;
}
// Fonction qui affiche le bandeau défilant des outils, directement à partir des icônes
// de la page Compétences (voir listeIconesCompetences). Réutilise les mêmes classes CSS
// que les bandeaux de logo_banners pour un rendu visuel identique (voir primary_css.css).
// "logo-marquee-tools" désactive le filtre grayscale/survol : contrairement aux logos
// d'entreprises, on veut que les vrais outils soient reconnaissables immédiatement.
function afficherOutilsCompetences() {
$outils = listeIconesCompetences();
if (empty($outils)) {
return;
}
echo '<div class="logo-marquee logo-marquee-tools">';
echo '<div class="logo-track">';
// On répète deux fois la liste : le défilement CSS boucle sur -50%, ce qui donne
// un effet de scroll infini fluide et sans coupure
for ($repetition = 0; $repetition < 2; $repetition++) {
foreach ($outils as $outil) {
$label = htmlspecialchars($outil['label']);
echo '<div class="logo-tile" title="' . $label . '">';
afficherVisuelItem($outil['icon'], '', true);
echo '</div>';
}
}
echo '</div>';
echo '</div>';
}
+27
View File
@@ -0,0 +1,27 @@
<?php
// Ajout de connexion a la bdd
require_once('../functions/databaseConnection.php');
// Fonction qui récupère les chiffres clés affichés sur la home (bandeau de stats)
// Chaque compteur retombe sur 0 en cas d'erreur (table pas encore créée...) plutôt
// que de bloquer la page d'accueil
function recupererStatsHome() {
global $dbh;
$stats = ['projets' => 0, 'experiences' => 0];
try {
$stats['projets'] = (int) $dbh->query('SELECT COUNT(*) FROM projects')->fetchColumn();
} catch (PDOException $e) {
// Table pas encore créée : on laisse le compteur à 0
}
try {
$stats['experiences'] = (int) $dbh->query('SELECT COUNT(*) FROM experiences')->fetchColumn();
} catch (PDOException $e) {
// Table pas encore créée : on laisse le compteur à 0
}
return $stats;
}
+1 -1
View File
@@ -19,7 +19,7 @@ function afficherUsersAdmin()
echo '<p><strong>Créé le :</strong> ' . htmlspecialchars($user["created_at"]) . '</p>';
echo '<div class="admin-actions">';
echo '<a href="../scripts/removeUser.php?id=' . $user["id"] . '" class="btn-submit btn-delete" onclick="return confirm(\'Supprimer cet utilisateur ?\')">Supprimer</a>';
echo '<a href="../scripts/removeUser.php?id=' . $user["id"] . '&csrf_token=' . urlencode(csrfToken()) . '" class="btn-submit btn-delete" onclick="return confirm(\'Supprimer cet utilisateur ?\')">Supprimer</a>';
echo '</div>';
echo '</div>';
+21 -7
View File
@@ -13,10 +13,17 @@ function listeXP() {
// Essayer
try {
// Requête SQL qui récupère titre, type, tags, description, durée, image_url
// SELECT * : les colonnes markdown / link_url sont optionnelles (la page fonctionne
// même si la migration SQL n'a pas encore été exécutée)
$stmt = $dbh->prepare('SELECT * FROM experiences ORDER BY id DESC');
// On liste les colonnes explicitement (pas de SELECT *) : le PDF (pdf_data) est
// stocké à part dans experience_pdfs (voir migration Juillet 2026 (6)), on ne
// récupère ici que son nom de fichier via LEFT JOIN, sans jamais charger son contenu
// binaire (jusqu'à 15 Mo par PDF) en mémoire pour une simple liste d'expériences
$stmt = $dbh->prepare('
SELECT e.id, e.title, e.status, e.xptype, e.tags, e.description, e.duration, e.image_url, e.markdown, e.link_url, e.created_at,
ep.pdf_filename
FROM experiences e
LEFT JOIN experience_pdfs ep ON ep.experience_id = e.id
ORDER BY e.id DESC
');
// Execution de la requête SQL
$stmt->execute();
// Retourne toutes les données collectés dans un ARRAY
@@ -32,8 +39,15 @@ function recupererXPParId($id) {
global $dbh;
try {
// Préparation de la requête préparée
$stmt = $dbh->prepare('SELECT * FROM experiences WHERE id = :id');
// Même principe que listeXP() : le PDF est joint pour son nom de fichier
// uniquement, jamais son contenu binaire
$stmt = $dbh->prepare('
SELECT e.id, e.title, e.status, e.xptype, e.tags, e.description, e.duration, e.image_url, e.markdown, e.link_url, e.created_at,
ep.pdf_filename
FROM experiences e
LEFT JOIN experience_pdfs ep ON ep.experience_id = e.id
WHERE e.id = :id
');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
@@ -195,7 +209,7 @@ function afficherXPAdmin() {
// Boutons qui vont permettrent la modification / Suppresion
echo '<div class="admin-actions">';
echo '<a class="btn-submit btn-edit" href="./admin.php?edit_xp=' . $id . '#admin-experiences">Modifier</a>';
echo '<a class="btn-submit btn-delete" href="../scripts/removeXP.php?id=' . $id . '" onclick="return confirm(\'Voulez-vous vraiment supprimer cette expérience ?\');">Supprimer</a>';
echo '<a class="btn-submit btn-delete" href="../scripts/removeXP.php?id=' . $id . '&csrf_token=' . urlencode(csrfToken()) . '" onclick="return confirm(\'Voulez-vous vraiment supprimer cette expérience ?\');">Supprimer</a>';
echo '</div>';
echo '</div>';
+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.");
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
// 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');
+104 -12
View File
@@ -52,6 +52,16 @@ $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
?>
@@ -70,7 +80,9 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<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>
@@ -87,6 +99,7 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<!-- enctype multipart/form-data : nécessaire pour l'upload du PDF -->
<!-- en envoie les données à addProject.php -->
<form method="POST" enctype="multipart/form-data" action="<?php echo $projetAModifier ? '../scripts/updateProject.php' : '../scripts/addProject.php'; ?>">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<?php if ($projetAModifier): ?>
<input type="hidden" name="id" value="<?php echo (int) $projetAModifier['id']; ?>">
@@ -151,6 +164,7 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<div class="project-form-card">
<!-- en envoie les données à addXP.php -->
<form method="POST" enctype="multipart/form-data" action="<?php echo $xpAModifier ? '../scripts/updateXP.php' : '../scripts/addXP.php'; ?>">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<?php if ($xpAModifier): ?>
<input type="hidden" name="id" value="<?php echo (int) $xpAModifier['id']; ?>">
@@ -221,6 +235,7 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<div class="project-form-card">
<!-- on envoie les données à addSchool.php -->
<form method="POST" enctype="multipart/form-data" action="<?php echo $ecoleAModifier ? '../scripts/updateSchool.php' : '../scripts/addSchool.php'; ?>">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<?php if ($ecoleAModifier): ?>
<input type="hidden" name="id" value="<?php echo (int) $ecoleAModifier['id']; ?>">
@@ -288,6 +303,7 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<div class="project-form-card">
<!-- on envoie les données à addSkill.php -->
<form method="POST" action="<?php echo $skillAModifier ? '../scripts/updateSkill.php' : '../scripts/addSkill.php'; ?>">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<?php if ($skillAModifier): ?>
<input type="hidden" name="id" value="<?php echo (int) $skillAModifier['id']; ?>">
@@ -319,30 +335,39 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<!-- ========== SECTION LOGOS (bandeaux page d'accueil) ========== -->
<section id="admin-logos" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-images"></i> Logos (page d'accueil)</h2>
<h2 class="admin-section-title"><i class="fa-solid fa-images"></i> Logos partenaires (page d'accueil)</h2>
<p class="admin-data-text admin-section-intro">
Le bandeau "Outils que je sais manipuler" est désormais dérivé automatiquement de la
page <a href="#admin-competences">Compétences</a> (icône dans le champ "icone|texte" de chaque item).
Cette section ne gère plus que le bandeau "Ils m'ont fait confiance".
</p>
<details class="admin-block" <?php echo $logoAModifier ? 'open' : ''; ?>>
<summary><?php echo $logoAModifier ? 'Modifier le logo : ' . htmlspecialchars($logoAModifier['name']) : '+ Nouveau logo'; ?></summary>
<summary><?php echo $logoAModifier ? 'Modifier le logo : ' . htmlspecialchars($logoAModifier['name']) : '+ Nouvelle entreprise'; ?></summary>
<div class="project-form-card">
<!-- on envoie les données à addLogo.php -->
<form method="POST" action="<?php echo $logoAModifier ? '../scripts/updateLogo.php' : '../scripts/addLogo.php'; ?>">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<?php if ($logoAModifier): ?>
<input type="hidden" name="id" value="<?php echo (int) $logoAModifier['id']; ?>">
<?php endif; ?>
<label>Bandeau *</label>
<select name="category">
<option value="tool" <?php echo (!$logoAModifier || $logoAModifier['category'] === 'tool') ? 'selected' : ''; ?>>Outils que je sais manipuler</option>
<option value="partner" <?php echo ($logoAModifier && $logoAModifier['category'] === 'partner') ? 'selected' : ''; ?>>Ils m'ont fait confiance</option>
</select>
<!-- Seul le bandeau "Ils m'ont fait confiance" est géré ici -->
<input type="hidden" name="category" value="partner">
<label>Nom (outil ou entreprise) *</label>
<input type="text" name="name" required placeholder="Docker, Proxmox, Nom de l'entreprise..." value="<?php echo $logoAModifier ? htmlspecialchars($logoAModifier['name']) : ''; ?>">
<label>Nom de l'entreprise *</label>
<input type="text" name="name" required placeholder="Nom de l'entreprise..." value="<?php echo $logoAModifier ? htmlspecialchars($logoAModifier['name']) : ''; ?>">
<label>Lien du logo *</label>
<input type="url" name="logourl" placeholder="https://..." required value="<?php echo $logoAModifier ? htmlspecialchars($logoAModifier['logo_url']) : ''; ?>">
<label>
Icône Devicon (facultatif, rarement utile pour une entreprise — parcourir sur
<a href="https://devicon.dev" target="_blank" rel="noopener noreferrer">devicon.dev</a>)
</label>
<input type="text" name="icon" placeholder="devicon-docker-plain colored" value="<?php echo $logoAModifier ? htmlspecialchars($logoAModifier['icon'] ?? '') : ''; ?>">
<label>Lien de l'image (utilisé si pas d'icône)</label>
<input type="url" name="logourl" placeholder="https://..." value="<?php echo $logoAModifier ? htmlspecialchars($logoAModifier['logo_url'] ?? '') : ''; ?>">
<label>Lien externe (optionnel, rend le logo cliquable)</label>
<input type="url" name="linkurl" placeholder="https://..." value="<?php echo $logoAModifier ? htmlspecialchars($logoAModifier['link_url'] ?? '') : ''; ?>">
@@ -360,6 +385,45 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<?php afficherLogosAdmin(); ?>
</section>
<!-- ========== SECTION CONTACT ========== -->
<section id="admin-contact" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-address-card"></i> Coordonnées de contact</h2>
<div class="project-container">
<div class="project-form-card">
<!-- on envoie les données à updateSettings.php -->
<form method="POST" action="../scripts/updateSettings.php">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<input type="hidden" name="settings_group" value="contact">
<label>Adresse email</label>
<input type="email" name="contact_email" value="<?php echo htmlspecialchars($contactEmail); ?>">
<!-- Checkbox pour afficher / masquer l'email sur la page contact -->
<label class="checkbox-row">
<input type="checkbox" name="contact_email_enabled" value="1" <?php echo $contactEmailEnabled === '1' ? 'checked' : ''; ?>>
Afficher l'email sur la page contact
</label>
<label>Adresse postale - ligne 1 (rue)</label>
<input type="text" name="contact_address_line1" value="<?php echo htmlspecialchars($contactAddressLine1); ?>">
<label>Adresse postale - ligne 2 (code postal / ville)</label>
<input type="text" name="contact_address_line2" value="<?php echo htmlspecialchars($contactAddressLine2); ?>">
<!-- Checkbox pour afficher / masquer l'adresse postale sur la page contact -->
<label class="checkbox-row">
<input type="checkbox" name="contact_address_enabled" value="1" <?php echo $contactAddressEnabled === '1' ? 'checked' : ''; ?>>
Afficher l'adresse postale sur la page contact
</label>
<input type="submit" name="submitsettings" value="Enregistrer les coordonnées" class="btn-submit">
</form>
</div>
</div>
</section>
<!-- ========== SECTION NOTIFICATION ========== -->
<section id="admin-notification" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-bell"></i> Notification de la page d'accueil</h2>
@@ -368,6 +432,8 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<div class="project-form-card">
<!-- on envoie les données à updateSettings.php -->
<form method="POST" action="../scripts/updateSettings.php">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<input type="hidden" name="settings_group" value="notice">
<label>Titre (affiché en gras)</label>
<input type="text" name="notice_title" value="<?php echo htmlspecialchars($noticeTitle); ?>">
@@ -388,6 +454,30 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
</div>
</section>
<!-- ========== SECTION SÉCURITÉ ========== -->
<section id="admin-securite" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-shield-halved"></i> Sécurité</h2>
<div class="project-container">
<div class="project-form-card">
<!-- on envoie les données à updateSettings.php -->
<form method="POST" action="../scripts/updateSettings.php">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<input type="hidden" name="settings_group" value="captcha">
<!-- Checkbox pour activer / désactiver le test anti-robot à l'entrée du site -->
<label class="checkbox-row">
<input type="checkbox" name="captcha_enabled" value="1" <?php echo $captchaEnabled === '1' ? 'checked' : ''; ?>>
Activer le test anti-robot (captcha) à l'entrée du site
</label>
<input type="submit" name="submitsettings" value="Enregistrer" class="btn-submit">
</form>
</div>
</div>
</section>
<!-- ========== SECTION UTILISATEURS ========== -->
<section id="admin-utilisateurs" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-users"></i> Utilisateurs</h2>
@@ -398,6 +488,7 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<div class="project-form-card">
<!-- en envoie les données addUser.php -->
<form method="POST" action="../scripts/addUser.php">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<label>Nom du compte</label>
<input type="text" name="user" required>
@@ -432,7 +523,7 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
Télécharge une sauvegarde JSON de toutes les données du site
(projets, expériences, écoles, compétences, notification, utilisateurs).
</p>
<a class="btn-submit admin-export-btn" href="../scripts/exportData.php">
<a class="btn-submit admin-export-btn" href="../scripts/exportData.php?csrf_token=<?php echo urlencode(csrfToken()); ?>">
<i class="fa-solid fa-download"></i> Exporter les données (JSON)
</a>
@@ -446,6 +537,7 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
</p>
<form method="POST" action="../scripts/importData.php" enctype="multipart/form-data"
onsubmit="return confirm('ATTENTION : les données actuelles du site vont être écrasées par celles du fichier. Continuer ?');">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<input type="file" name="jsonfile" accept=".json,application/json" required>
<input type="submit" value="Importer les données" class="btn-submit btn-delete">
</form>
+17 -3
View File
@@ -6,6 +6,16 @@ $pageTitle = 'Contact';
// On ajoute ici le header
require_once("../elements/header.php");
// Réglages de l'email et de l'adresse (modifiables et masquables depuis l'admin)
require_once('../functions/listSettings.php');
$contactEmail = recupererSetting('contact_email') ?? 'esteban.moussu@ik.me';
$contactEmailEnabled = (recupererSetting('contact_email_enabled') ?? '1') === '1';
$contactAddressLine1 = recupererSetting('contact_address_line1') ?? '20 Grande rue';
$contactAddressLine2 = recupererSetting('contact_address_line2') ?? '52500 Broncourt';
$contactAddressEnabled = (recupererSetting('contact_address_enabled') ?? '1') === '1';
?>
<div class="contact-page">
@@ -14,9 +24,13 @@ require_once("../elements/header.php");
<div class="contact-info">
<p class="contact-title">Contact</p>
<p class="contact-name">Esteban MOUSSU</p>
<p class="contact-line"><i class="fa-solid fa-envelope"></i> esteban.moussu@ik.me</p>
<p class="contact-line"><i class="fa-solid fa-location-dot"></i> 20 Grande rue</p>
<p class="contact-line">52500 Broncourt</p>
<?php 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 -->
+14 -7
View File
@@ -8,10 +8,12 @@
require_once('../functions/databaseConnection.php');
// Types autorisés : liste blanche pour empêcher toute injection de nom de table
// (le PDF est stocké dans une table associée depuis la migration Juillet 2026 (6) : on la
// joint juste pour le nom de fichier, jamais pour son contenu binaire)
$types = [
'projet' => ['table' => 'projects', 'retour' => './projects.php', 'label' => 'Projet'],
'experience' => ['table' => 'experiences', 'retour' => './xp.php', 'label' => 'Expérience'],
'ecole' => ['table' => 'schools', 'retour' => './xp.php', 'label' => 'École'],
'projet' => ['table' => 'projects', 'pdf' => 'project_pdfs', 'pdf_colonne' => 'project_id', 'retour' => './projects.php', 'label' => 'Projet'],
'experience' => ['table' => 'experiences', 'pdf' => 'experience_pdfs', 'pdf_colonne' => 'experience_id', 'retour' => './xp.php', 'label' => 'Expérience'],
'ecole' => ['table' => 'schools', 'pdf' => 'school_pdfs', 'pdf_colonne' => 'school_id', 'retour' => './xp.php', 'label' => 'École'],
];
$type = $_GET['type'] ?? '';
@@ -28,15 +30,20 @@ $infos = $types[$type];
// Récupération de l'élément demandé
// (pdf_data n'est volontairement pas sélectionné : le PDF est servi à part par viewPdf.php
// dans une <iframe>, inutile de charger son contenu binaire en mémoire ici)
$colonnesCommunes = 'id, title, status, tags, description, image_url, markdown, link_url, pdf_filename';
$colonnesCommunes = 'e.id, e.title, e.status, e.tags, e.description, e.image_url, e.markdown, e.link_url, p.pdf_filename';
$colonnesParType = [
'projet' => $colonnesCommunes,
'experience' => $colonnesCommunes . ', xptype, duration',
'ecole' => $colonnesCommunes . ', duration',
'experience' => $colonnesCommunes . ', e.xptype, e.duration',
'ecole' => $colonnesCommunes . ', e.duration',
];
try {
$stmt = $dbh->prepare('SELECT ' . $colonnesParType[$type] . ' FROM `' . $infos['table'] . '` WHERE id = :id');
$stmt = $dbh->prepare(
'SELECT ' . $colonnesParType[$type] . '
FROM `' . $infos['table'] . '` e
LEFT JOIN `' . $infos['pdf'] . '` p ON p.`' . $infos['pdf_colonne'] . '` = e.id
WHERE e.id = :id'
);
$stmt->bindValue(':id', (int) $id, PDO::PARAM_INT);
$stmt->execute();
$element = $stmt->fetch(PDO::FETCH_ASSOC);
+35 -5
View File
@@ -12,10 +12,19 @@ require_once('./../elements/header.php');
// La notification est maintenant dynamique : modifiable depuis le panel Admin (table settings)
require_once('../functions/listSettings.php');
// Bandeaux de logos ("Outils que je sais manipuler" / "Ils m'ont fait confiance"),
// gérés depuis le panel Admin (table logo_banners)
// Bandeau "Ils m'ont fait confiance", géré depuis le panel Admin (table logo_banners)
require_once('../functions/listLogos.php');
// Bandeau "Outils que je sais manipuler" : dérivé directement des icônes de la page
// Compétences, pas de saisie séparée (voir listeIconesCompetences)
require_once('../functions/listSkills.php');
// Chiffres clés (bandeau de stats sous le hero) : nombre réel de projets/expériences
// en BDD, et nombre de technologies reconnaissables (réutilise listeIconesCompetences)
require_once('../functions/listStats.php');
$statsHome = recupererStatsHome();
$statsHome['technologies'] = count(listeIconesCompetences());
// Affichage de la notification (si elle est activée dans le panel Admin)
afficherNotice();
?>
@@ -39,13 +48,34 @@ afficherNotice();
</a>
</section>
<?php if (!empty(listeLogos('tool'))): ?>
<!-- Bandeau défilant des outils maîtrisés, géré depuis le panel Admin -->
<?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 afficherLogos('tool'); ?>
<?php afficherOutilsCompetences(); ?>
</section>
<?php endif; ?>
+12 -6
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once(__DIR__ . '/../functions/security.php');
require_once '../functions/databaseConnection.php';
require_once('../functions/sendLogs.php');
@@ -9,6 +9,9 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// La requête doit être un POST sinon on l'a refuse
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
@@ -16,21 +19,24 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// La catégorie est restreinte à 'tool' ou 'partner' (sinon on retombe sur 'tool')
$category = ($_POST['category'] ?? '') === 'partner' ? 'partner' : 'tool';
$name = trim($_POST['name'] ?? '');
$icon = trim($_POST['icon'] ?? '');
$logo_url = trim($_POST['logourl'] ?? '');
$link_url = trim($_POST['linkurl'] ?? '');
$position = (int) ($_POST['position'] ?? 0);
// On vérifie que les champs obligatoires ne sont pas vide
if (!empty($name) && !empty($logo_url)) {
// On vérifie que les champs obligatoires ne sont pas vides : il faut un nom, et
// au moins l'un des deux visuels (icône Devicon ou image)
if (!empty($name) && (!empty($icon) || !empty($logo_url))) {
try {
// On prépare la requête SQL pour insérer le nouveau logo
$stmt = $dbh->prepare('INSERT INTO logo_banners (category, name, logo_url, link_url, position) VALUES (:category, :name, :logo_url, :link_url, :position)');
$stmt = $dbh->prepare('INSERT INTO logo_banners (category, name, icon, logo_url, link_url, position) VALUES (:category, :name, :icon, :logo_url, :link_url, :position)');
// On execute la requete préparée
$stmt->execute([
':category' => $category,
':name' => $name,
':logo_url' => $logo_url,
':icon' => $icon !== '' ? $icon : null,
':logo_url' => $logo_url !== '' ? $logo_url : null,
':link_url' => $link_url,
':position' => $position
]);
@@ -48,7 +54,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
exit;
}
} else {
echo "Veuillez remplir tous les champs obligatoires.";
echo "Veuillez remplir le nom et au moins une icône ou une image.";
exit;
}
} else {
+18 -4
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once(__DIR__ . '/../functions/security.php');
require_once '../functions/databaseConnection.php';
require_once('../functions/sendLogs.php');
require_once('../functions/pdfUpload.php');
@@ -10,6 +10,9 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// Le fichier envoyé dépasse post_max_size : $_POST/$_FILES sont vides, on le détecte
// tout de suite pour éviter un échec silencieux
if (postDepasseLimite()) {
@@ -42,7 +45,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!empty($title) && !empty($description) && !empty($image_url)) {
try {
// On prépare la requête SQL pour insérer le nouveau projet
$stmt = $dbh->prepare('INSERT INTO projects (title, status, tags, description, image_url, markdown, link_url, pdf_data, pdf_filename) VALUES (:title, :status, :tags, :description, :image_url, :markdown, :link_url, :pdf_data, :pdf_filename)');
// Le PDF (pdf_data) n'est plus stocké ici : il vit dans project_pdfs (table
// associée, voir migration Juillet 2026 (6)), pour que les listes de projets
// n'aient jamais à charger son contenu binaire en mémoire
$stmt = $dbh->prepare('INSERT INTO projects (title, status, tags, description, image_url, markdown, link_url) VALUES (:title, :status, :tags, :description, :image_url, :markdown, :link_url)');
// On execute la requete préparée
$stmt->bindValue(':title', $title, PDO::PARAM_STR);
@@ -52,10 +58,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$stmt->bindValue(':image_url', $image_url, PDO::PARAM_STR);
$stmt->bindValue(':markdown', $markdown, PDO::PARAM_STR);
$stmt->bindValue(':link_url', $link_url, PDO::PARAM_STR);
$stmt->bindValue(':pdf_data', $pdfData, $pdfData === null ? PDO::PARAM_NULL : PDO::PARAM_LOB);
$stmt->bindValue(':pdf_filename', $pdfFilename, $pdfFilename === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
$stmt->execute();
// Si un PDF a été envoyé, on l'enregistre à part dans project_pdfs
if ($pdfData !== null) {
$nouvelId = (int) $dbh->lastInsertId();
$stmtPdf = $dbh->prepare('INSERT INTO project_pdfs (project_id, pdf_filename, pdf_data) VALUES (:project_id, :pdf_filename, :pdf_data)');
$stmtPdf->bindValue(':project_id', $nouvelId, PDO::PARAM_INT);
$stmtPdf->bindValue(':pdf_filename', $pdfFilename, PDO::PARAM_STR);
$stmtPdf->bindValue(':pdf_data', $pdfData, PDO::PARAM_LOB);
$stmtPdf->execute();
}
// Log Discord
sendLog("Ajout du projet : " . $title);
+18 -4
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once(__DIR__ . '/../functions/security.php');
require_once '../functions/databaseConnection.php';
require_once('../functions/sendLogs.php');
require_once('../functions/pdfUpload.php');
@@ -10,6 +10,9 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// Le fichier envoyé dépasse post_max_size : $_POST/$_FILES sont vides, on le détecte
// tout de suite pour éviter un échec silencieux
if (postDepasseLimite()) {
@@ -42,7 +45,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!empty($title) && !empty($description) && !empty($duration) && !empty($image_url)) {
try {
// On prépare la requête SQL pour insérer la nouvelle école
$stmt = $dbh->prepare('INSERT INTO schools (title, status, tags, description, duration, image_url, markdown, link_url, pdf_data, pdf_filename) VALUES (:title, :status, :tags, :description, :duration, :image_url, :markdown, :link_url, :pdf_data, :pdf_filename)');
// Le PDF (pdf_data) n'est plus stocké ici : il vit dans school_pdfs (table
// associée, voir migration Juillet 2026 (6)), pour que les listes d'écoles
// n'aient jamais à charger son contenu binaire en mémoire
$stmt = $dbh->prepare('INSERT INTO schools (title, status, tags, description, duration, image_url, markdown, link_url) VALUES (:title, :status, :tags, :description, :duration, :image_url, :markdown, :link_url)');
// On execute la requete préparée
$stmt->bindValue(':title', $title, PDO::PARAM_STR);
@@ -53,10 +59,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$stmt->bindValue(':image_url', $image_url, PDO::PARAM_STR);
$stmt->bindValue(':markdown', $markdown, PDO::PARAM_STR);
$stmt->bindValue(':link_url', $link_url, PDO::PARAM_STR);
$stmt->bindValue(':pdf_data', $pdfData, $pdfData === null ? PDO::PARAM_NULL : PDO::PARAM_LOB);
$stmt->bindValue(':pdf_filename', $pdfFilename, $pdfFilename === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
$stmt->execute();
// Si un PDF a été envoyé, on l'enregistre à part dans school_pdfs
if ($pdfData !== null) {
$nouvelId = (int) $dbh->lastInsertId();
$stmtPdf = $dbh->prepare('INSERT INTO school_pdfs (school_id, pdf_filename, pdf_data) VALUES (:school_id, :pdf_filename, :pdf_data)');
$stmtPdf->bindValue(':school_id', $nouvelId, PDO::PARAM_INT);
$stmtPdf->bindValue(':pdf_filename', $pdfFilename, PDO::PARAM_STR);
$stmtPdf->bindValue(':pdf_data', $pdfData, PDO::PARAM_LOB);
$stmtPdf->execute();
}
// Log Discord
sendLog("Ajout de l'école : " . $title);
+4 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once(__DIR__ . '/../functions/security.php');
require_once '../functions/databaseConnection.php';
require_once('../functions/sendLogs.php');
@@ -9,6 +9,9 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// La requête doit être un POST sinon on l'a refuse
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+4 -1
View File
@@ -1,6 +1,6 @@
<?php
// Démarrage de la session pour vérifier les droits de l'utilisateur
session_start();
require_once(__DIR__ . '/../functions/security.php');
// Ajout de connexion à la BDD
require_once("../functions/databaseConnection.php");
// Ajout des logs Discord
@@ -12,6 +12,9 @@ if (!isset($_SESSION["role"]) || $_SESSION["role"] != "admin") {
exit();
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// Si le formulaire a été soumis
if ($_SERVER["REQUEST_METHOD"] == "POST") {
// Récupération des données du formulaire
+18 -4
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once(__DIR__ . '/../functions/security.php');
require_once '../functions/databaseConnection.php';
require_once('../functions/sendLogs.php');
require_once('../functions/pdfUpload.php');
@@ -10,6 +10,9 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// Le fichier envoyé dépasse post_max_size : $_POST/$_FILES sont vides, on le détecte
// tout de suite pour éviter un échec silencieux
if (postDepasseLimite()) {
@@ -43,7 +46,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!empty($title) && !empty($description) && !empty($image_url)) {
try {
// On prépare la requête SQL pour insérer la nouvelle experience
$stmt = $dbh->prepare('INSERT INTO experiences (title, status, xptype, tags, description, duration, image_url, markdown, link_url, pdf_data, pdf_filename) VALUES (:title, :status, :xptype, :tags, :description, :duration, :image_url, :markdown, :link_url, :pdf_data, :pdf_filename)');
// Le PDF (pdf_data) n'est plus stocké ici : il vit dans experience_pdfs (table
// associée, voir migration Juillet 2026 (6)), pour que les listes d'expériences
// n'aient jamais à charger son contenu binaire en mémoire
$stmt = $dbh->prepare('INSERT INTO experiences (title, status, xptype, tags, description, duration, image_url, markdown, link_url) VALUES (:title, :status, :xptype, :tags, :description, :duration, :image_url, :markdown, :link_url)');
// On execute la requete préparée
$stmt->bindValue(':title', $title, PDO::PARAM_STR);
@@ -55,10 +61,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$stmt->bindValue(':image_url', $image_url, PDO::PARAM_STR);
$stmt->bindValue(':markdown', $markdown, PDO::PARAM_STR);
$stmt->bindValue(':link_url', $link_url, PDO::PARAM_STR);
$stmt->bindValue(':pdf_data', $pdfData, $pdfData === null ? PDO::PARAM_NULL : PDO::PARAM_LOB);
$stmt->bindValue(':pdf_filename', $pdfFilename, $pdfFilename === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
$stmt->execute();
// Si un PDF a été envoyé, on l'enregistre à part dans experience_pdfs
if ($pdfData !== null) {
$nouvelId = (int) $dbh->lastInsertId();
$stmtPdf = $dbh->prepare('INSERT INTO experience_pdfs (experience_id, pdf_filename, pdf_data) VALUES (:experience_id, :pdf_filename, :pdf_data)');
$stmtPdf->bindValue(':experience_id', $nouvelId, PDO::PARAM_INT);
$stmtPdf->bindValue(':pdf_filename', $pdfFilename, PDO::PARAM_STR);
$stmtPdf->bindValue(':pdf_data', $pdfData, PDO::PARAM_LOB);
$stmtPdf->execute();
}
// Log Discord
sendLog("Ajout de l'expérience : " . $title);
+6 -1
View File
@@ -3,7 +3,7 @@
// Verification de l'utilisateur voir si ces ids sont bon ou pas
// On démarre une session au cas où le script soit exécuté directement par URL
session_start();
require_once(__DIR__ . '/../functions/security.php');
// Ajout de connexion a la bdd
require_once('../functions/databaseConnection.php');
@@ -38,6 +38,11 @@ if (!$user || !password_verify($password, $user['password'])) {
exit;
}
// Régénère l'ID de session à la connexion : empêche la fixation de session
// (un attaquant qui aurait imposé un ID de session à la victime avant sa
// connexion ne peut plus récupérer une session authentifiée avec cet ID)
session_regenerate_id(true);
// Si la connexion est ok, on entregistre l'utilisateur dans $_SESSION
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
+3 -2
View File
@@ -2,8 +2,8 @@
// Lorsque l'utilisateur est renvoyé à cette page/fonction, il est déconnecté
// Démarrage de session
session_start();
// Démarrage de session (cookie durci)
require_once(__DIR__ . '/../functions/security.php');
// Ajout des logs Discord
require_once('../functions/sendLogs.php');
@@ -21,6 +21,7 @@ session_destroy();
// Si le captcha avait déjà été validé, on recrée une session propre
// pour conserver uniquement cette information
if ($captchaAlreadyValid) {
configureSecureSessionCookies();
session_start();
$_SESSION['captcha_valid'] = true;
}
+17 -7
View File
@@ -3,7 +3,7 @@
// Le fichier téléchargé peut ensuite être ré-importé via importData.php
// On démarre la session
session_start();
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
@@ -15,22 +15,32 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// Liste blanche des tables et colonnes exportées (empêche toute injection via noms de colonnes)
// Depuis la migration Juillet 2026 (6), les PDF vivent dans des tables associées
// (project_pdfs, experience_pdfs, school_pdfs) plutôt que dans projects/experiences/schools :
// elles sont placées juste après leur table parente (l'ordre compte pour l'import, à cause
// des clés étrangères ON DELETE CASCADE)
$tables = [
'settings' => ['name', 'value'],
'skills' => ['id', 'title', 'icon', 'items', 'position', 'created_at'],
'projects' => ['id', 'title', 'status', 'tags', 'description', 'image_url', 'markdown', 'link_url', 'pdf_data', 'pdf_filename', 'created_at'],
'experiences' => ['id', 'title', 'status', 'xptype', 'tags', 'description', 'duration', 'image_url', 'markdown', 'link_url', 'pdf_data', 'pdf_filename', 'created_at'],
'schools' => ['id', 'title', 'status', 'tags', 'description', 'duration', 'image_url', 'markdown', 'link_url', 'pdf_data', 'pdf_filename', 'created_at'],
'projects' => ['id', 'title', 'status', 'tags', 'description', 'image_url', 'markdown', 'link_url', 'created_at'],
'project_pdfs' => ['project_id', 'pdf_filename', 'pdf_data'],
'experiences' => ['id', 'title', 'status', 'xptype', 'tags', 'description', 'duration', 'image_url', 'markdown', 'link_url', 'created_at'],
'experience_pdfs' => ['experience_id', 'pdf_filename', 'pdf_data'],
'schools' => ['id', 'title', 'status', 'tags', 'description', 'duration', 'image_url', 'markdown', 'link_url', 'created_at'],
'school_pdfs' => ['school_id', 'pdf_filename', 'pdf_data'],
'users' => ['id', 'username', 'password', 'role', 'created_at', 'last_login'],
];
// Colonnes binaires : le JSON ne peut pas contenir de binaire brut, on les encode en base64
// (+33% de taille mais nécessaire pour que le PDF survive à un export/import complet)
$colonnesBinaires = [
'projects' => ['pdf_data'],
'experiences' => ['pdf_data'],
'schools' => ['pdf_data'],
'project_pdfs' => ['pdf_data'],
'experience_pdfs' => ['pdf_data'],
'school_pdfs' => ['pdf_data'],
];
// Métadonnées de l'export (pratique pour s'y retrouver dans les sauvegardes)
+17 -8
View File
@@ -3,7 +3,7 @@
// ATTENTION : les données actuelles des tables présentes dans le fichier sont ÉCRASÉES
// On démarre la session
session_start();
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
@@ -15,6 +15,9 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// La requête doit être un POST avec un fichier sinon on refuse
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !isset($_FILES['jsonfile']) || $_FILES['jsonfile']['error'] !== UPLOAD_ERR_OK) {
header('Location: ../pages/admin.php');
@@ -29,21 +32,27 @@ if (!is_array($donnees)) {
die("Erreur : le fichier n'est pas un JSON valide.");
}
// Même liste blanche que l'export : seules ces tables / colonnes peuvent être importées
// Même liste blanche que l'export : seules ces tables / colonnes peuvent être importées.
// L'ORDRE COMPTE : chaque table de PDF associée (project_pdfs...) doit être importée
// juste après sa table parente (projects...), car sa clé étrangère exige que la ligne
// parente existe déjà (voir migration Juillet 2026 (6))
$tables = [
'settings' => ['name', 'value'],
'skills' => ['id', 'title', 'icon', 'items', 'position', 'created_at'],
'projects' => ['id', 'title', 'status', 'tags', 'description', 'image_url', 'markdown', 'link_url', 'pdf_data', 'pdf_filename', 'created_at'],
'experiences' => ['id', 'title', 'status', 'xptype', 'tags', 'description', 'duration', 'image_url', 'markdown', 'link_url', 'pdf_data', 'pdf_filename', 'created_at'],
'schools' => ['id', 'title', 'status', 'tags', 'description', 'duration', 'image_url', 'markdown', 'link_url', 'pdf_data', 'pdf_filename', 'created_at'],
'projects' => ['id', 'title', 'status', 'tags', 'description', 'image_url', 'markdown', 'link_url', 'created_at'],
'project_pdfs' => ['project_id', 'pdf_filename', 'pdf_data'],
'experiences' => ['id', 'title', 'status', 'xptype', 'tags', 'description', 'duration', 'image_url', 'markdown', 'link_url', 'created_at'],
'experience_pdfs' => ['experience_id', 'pdf_filename', 'pdf_data'],
'schools' => ['id', 'title', 'status', 'tags', 'description', 'duration', 'image_url', 'markdown', 'link_url', 'created_at'],
'school_pdfs' => ['school_id', 'pdf_filename', 'pdf_data'],
'users' => ['id', 'username', 'password', 'role', 'created_at', 'last_login'],
];
// Colonnes binaires : encodées en base64 par exportData.php, à redécoder ici
$colonnesBinaires = [
'projects' => ['pdf_data'],
'experiences' => ['pdf_data'],
'schools' => ['pdf_data'],
'project_pdfs' => ['pdf_data'],
'experience_pdfs' => ['pdf_data'],
'school_pdfs' => ['pdf_data'],
];
try {
+4 -1
View File
@@ -1,6 +1,6 @@
<?php
// On démarre la session
session_start();
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
@@ -12,6 +12,9 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
//L'id doit être collecté sinon l'utilisateur ne peux pas executer le reste du script
if (!isset($_GET['id']) || !is_numeric($_GET['id'])) {
header('Location: ../pages/admin.php');
+4 -1
View File
@@ -1,6 +1,6 @@
<?php
// On démarre la session
session_start();
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
@@ -12,6 +12,9 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
//L'id doit être collecté sinon l'utilisateur ne peux pas executer le reste du script
if (!isset($_GET['id']) || !is_numeric($_GET['id'])) {
header('Location: ../pages/admin.php');
+4 -1
View File
@@ -1,6 +1,6 @@
<?php
// On démarre la session
session_start();
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
@@ -12,6 +12,9 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
//L'id doit être collecté sinon l'utilisateur ne peux pas executer le reste du script
if (!isset($_GET['id']) || !is_numeric($_GET['id'])) {
header('Location: ../pages/admin.php');
+4 -1
View File
@@ -1,6 +1,6 @@
<?php
// On démarre la session
session_start();
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
@@ -12,6 +12,9 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
//L'id doit être collecté sinon l'utilisateur ne peux pas executer le reste du script
if (!isset($_GET['id']) || !is_numeric($_GET['id'])) {
header('Location: ../pages/admin.php');
+4 -1
View File
@@ -1,6 +1,6 @@
<?php
// Démarrage de la session pour vérifier les droits de l'utilisateur
session_start();
require_once(__DIR__ . '/../functions/security.php');
// Ajout de connexion à la BDD
require_once("../functions/databaseConnection.php");
// Ajout des logs Discord
@@ -12,6 +12,9 @@ if (!isset($_SESSION["role"]) || $_SESSION["role"] != "admin") {
exit();
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// Récupération de l'ID passé en paramètre GET
$id = $_GET["id"];
+4 -1
View File
@@ -1,6 +1,6 @@
<?php
// On démarre la session
session_start();
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
@@ -12,6 +12,9 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
//L'id doit être collecté sinon l'utilisateur ne peux pas executer le reste du script
if (!isset($_GET['id']) || !is_numeric($_GET['id'])) {
header('Location: ../pages/admin.php');
+6 -1
View File
@@ -1,6 +1,11 @@
<?php
if (!isset($_SESSION['captcha_valid']) || $_SESSION['captcha_valid'] !== true) {
require_once(__DIR__ . '/../functions/listSettings.php');
// Le captcha peut être désactivé depuis le panel Admin (section Sécurité)
$captchaEnabled = recupererSetting('captcha_enabled') ?? '1';
if ($captchaEnabled === '1' && (!isset($_SESSION['captcha_valid']) || $_SESSION['captcha_valid'] !== true)) {
header('Location: ../captcha.php');
exit;
}
+1 -1
View File
@@ -3,7 +3,7 @@
require_once('../../idDiscord.php');
// Ajout des logs Discord
session_start();
require_once(__DIR__ . '/../functions/security.php');
require_once('../functions/sendLogs.php');
// Envoi de formulaire de contact via WebHook discord
+10 -5
View File
@@ -1,6 +1,6 @@
<?php
// On démarre la session
session_start();
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
@@ -12,10 +12,13 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
if ( // Si les champs sont vides on renvoie vers admin.php
!isset($_POST['id']) || !is_numeric($_POST['id']) ||
empty($_POST['name']) ||
empty($_POST['logourl'])
(empty($_POST['icon']) && empty($_POST['logourl']))
) {
header('Location: ../pages/admin.php');
exit;
@@ -25,19 +28,21 @@ if ( // Si les champs sont vides on renvoie vers admin.php
$id = (int) $_POST['id'];
$category = ($_POST['category'] ?? '') === 'partner' ? 'partner' : 'tool';
$name = trim($_POST['name']);
$logourl = trim($_POST['logourl']);
$icon = trim($_POST['icon'] ?? '');
$logourl = trim($_POST['logourl'] ?? '');
$linkurl = trim($_POST['linkurl'] ?? '');
$position = (int) ($_POST['position'] ?? 0);
try { // On essaie d'executer une requête qui est préparée
$stmt = $dbh->prepare('
UPDATE logo_banners
SET category = :category, name = :name, logo_url = :logo_url, link_url = :link_url, position = :position
SET category = :category, name = :name, icon = :icon, logo_url = :logo_url, link_url = :link_url, position = :position
WHERE id = :id
');
$stmt->bindValue(':category', $category, PDO::PARAM_STR);
$stmt->bindValue(':name', $name, PDO::PARAM_STR);
$stmt->bindValue(':logo_url', $logourl, PDO::PARAM_STR);
$stmt->bindValue(':icon', $icon !== '' ? $icon : null, $icon !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':logo_url', $logourl !== '' ? $logourl : null, $logourl !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':link_url', $linkurl, PDO::PARAM_STR);
$stmt->bindValue(':position', $position, PDO::PARAM_INT);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
+29 -13
View File
@@ -1,6 +1,6 @@
<?php
// On démarre la session
session_start();
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
@@ -13,6 +13,9 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// Le fichier envoyé dépasse post_max_size : $_POST/$_FILES sont vides, on le détecte
// tout de suite (sinon le script redirige silencieusement vers admin.php sans explication)
if (postDepasseLimite()) {
@@ -61,15 +64,13 @@ if (is_array($pdfResultat)) {
}
try { // On essaie d'executer une requête qui est préparée
$sql = '
// Le PDF (pdf_data) n'est plus stocké dans projects : il vit dans project_pdfs (table
// associée, voir migration Juillet 2026 (6)), géré à part juste après
$stmt = $dbh->prepare('
UPDATE projects
SET title = :title, status = :status, tags = :tags, description = :description, image_url = :image_url, markdown = :markdown, link_url = :link_url';
if ($updatePdf) {
$sql .= ', pdf_data = :pdf_data, pdf_filename = :pdf_filename';
}
$sql .= ' WHERE id = :id';
$stmt = $dbh->prepare($sql);
SET title = :title, status = :status, tags = :tags, description = :description, image_url = :image_url, markdown = :markdown, link_url = :link_url
WHERE id = :id
');
$stmt->bindValue(':title', $title, PDO::PARAM_STR);
$stmt->bindValue(':status', $status, PDO::PARAM_STR);
$stmt->bindValue(':tags', $tags, PDO::PARAM_STR);
@@ -77,12 +78,27 @@ try { // On essaie d'executer une requête qui est préparée
$stmt->bindValue(':image_url', $imageurl, PDO::PARAM_STR);
$stmt->bindValue(':markdown', $markdown, PDO::PARAM_STR);
$stmt->bindValue(':link_url', $link_url, PDO::PARAM_STR);
if ($updatePdf) {
$stmt->bindValue(':pdf_data', $pdfData, $pdfData === null ? PDO::PARAM_NULL : PDO::PARAM_LOB);
$stmt->bindValue(':pdf_filename', $pdfFilename, $pdfFilename === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
}
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute(); // Sinon on renvoie l'erreur
if ($updatePdf) {
if ($pdfData !== null) {
// Nouveau PDF envoyé : on remplace l'éventuel PDF existant (clé primaire = project_id)
$stmtPdf = $dbh->prepare('
INSERT INTO project_pdfs (project_id, pdf_filename, pdf_data) VALUES (:id, :pdf_filename, :pdf_data)
ON DUPLICATE KEY UPDATE pdf_filename = VALUES(pdf_filename), pdf_data = VALUES(pdf_data)
');
$stmtPdf->bindValue(':id', $id, PDO::PARAM_INT);
$stmtPdf->bindValue(':pdf_filename', $pdfFilename, PDO::PARAM_STR);
$stmtPdf->bindValue(':pdf_data', $pdfData, PDO::PARAM_LOB);
$stmtPdf->execute();
} else {
// Case "Supprimer ce PDF" cochée, sans nouveau fichier envoyé
$stmtPdf = $dbh->prepare('DELETE FROM project_pdfs WHERE project_id = :id');
$stmtPdf->bindValue(':id', $id, PDO::PARAM_INT);
$stmtPdf->execute();
}
}
} catch (PDOException $e) {
die("Erreur lors de la modification du projet :" . $e->getMessage());
}
+29 -13
View File
@@ -1,6 +1,6 @@
<?php
// On démarre la session
session_start();
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
@@ -13,6 +13,9 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// Le fichier envoyé dépasse post_max_size : $_POST/$_FILES sont vides, on le détecte
// tout de suite (sinon le script redirige silencieusement vers admin.php sans explication)
if (postDepasseLimite()) {
@@ -62,15 +65,13 @@ if (is_array($pdfResultat)) {
}
try { // On essaie d'executer une requête qui est préparée
$sql = '
// Le PDF (pdf_data) n'est plus stocké dans schools : il vit dans school_pdfs (table
// associée, voir migration Juillet 2026 (6)), géré à part juste après
$stmt = $dbh->prepare('
UPDATE schools
SET title = :title, status = :status, tags = :tags, description = :description, duration = :duration, image_url = :image_url, markdown = :markdown, link_url = :link_url';
if ($updatePdf) {
$sql .= ', pdf_data = :pdf_data, pdf_filename = :pdf_filename';
}
$sql .= ' WHERE id = :id';
$stmt = $dbh->prepare($sql);
SET title = :title, status = :status, tags = :tags, description = :description, duration = :duration, image_url = :image_url, markdown = :markdown, link_url = :link_url
WHERE id = :id
');
$stmt->bindValue(':title', $title, PDO::PARAM_STR);
$stmt->bindValue(':status', $status, PDO::PARAM_STR);
$stmt->bindValue(':tags', $tags, PDO::PARAM_STR);
@@ -79,12 +80,27 @@ try { // On essaie d'executer une requête qui est préparée
$stmt->bindValue(':image_url', $imageurl, PDO::PARAM_STR);
$stmt->bindValue(':markdown', $markdown, PDO::PARAM_STR);
$stmt->bindValue(':link_url', $link_url, PDO::PARAM_STR);
if ($updatePdf) {
$stmt->bindValue(':pdf_data', $pdfData, $pdfData === null ? PDO::PARAM_NULL : PDO::PARAM_LOB);
$stmt->bindValue(':pdf_filename', $pdfFilename, $pdfFilename === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
}
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute(); // Sinon on renvoie l'erreur
if ($updatePdf) {
if ($pdfData !== null) {
// Nouveau PDF envoyé : on remplace l'éventuel PDF existant (clé primaire = school_id)
$stmtPdf = $dbh->prepare('
INSERT INTO school_pdfs (school_id, pdf_filename, pdf_data) VALUES (:id, :pdf_filename, :pdf_data)
ON DUPLICATE KEY UPDATE pdf_filename = VALUES(pdf_filename), pdf_data = VALUES(pdf_data)
');
$stmtPdf->bindValue(':id', $id, PDO::PARAM_INT);
$stmtPdf->bindValue(':pdf_filename', $pdfFilename, PDO::PARAM_STR);
$stmtPdf->bindValue(':pdf_data', $pdfData, PDO::PARAM_LOB);
$stmtPdf->execute();
} else {
// Case "Supprimer ce PDF" cochée, sans nouveau fichier envoyé
$stmtPdf = $dbh->prepare('DELETE FROM school_pdfs WHERE school_id = :id');
$stmtPdf->bindValue(':id', $id, PDO::PARAM_INT);
$stmtPdf->execute();
}
}
} catch (PDOException $e) {
die("Erreur lors de la modification de l'école :" . $e->getMessage());
}
+28 -2
View File
@@ -1,6 +1,6 @@
<?php
// On démarre la session
session_start();
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
@@ -12,6 +12,9 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// La requête doit être un POST sinon on l'a refuse
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../pages/admin.php');
@@ -20,11 +23,29 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
// On récupère les données pour les mettres en variables locales
// La checkbox n'est envoyée que si elle est cochée
// 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
@@ -41,7 +62,12 @@ try { // On essaie d'executer une requête qui est préparée pour chaque régla
}
// Log Discord
sendLog("Modification de la notification de la page d'accueil");
$libellesGroupes = [
'captcha' => 'Modification du réglage du captcha',
'contact' => 'Modification des coordonnées de contact',
'notice' => "Modification de la notification de la page d'accueil"
];
sendLog($libellesGroupes[$groupeReglages] ?? "Modification des réglages du site");
// On renvoie l'utilisateur sur admin.php
header('Location: ../pages/admin.php');
+4 -1
View File
@@ -1,6 +1,6 @@
<?php
// On démarre la session
session_start();
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
@@ -12,6 +12,9 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
if ( // Si les champs sont vides on renvoie vers admin.php
!isset($_POST['id']) || !is_numeric($_POST['id']) ||
empty($_POST['title']) ||
+29 -13
View File
@@ -1,6 +1,6 @@
<?php
// On démarre la session
session_start();
require_once(__DIR__ . '/../functions/security.php');
// On ajoute la DB
require_once('../functions/databaseConnection.php');
// On ajoute les logs Discord
@@ -13,6 +13,9 @@ if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
exit;
}
// Vérification du jeton CSRF : bloque les requêtes forgées depuis un autre site
requireCsrfToken();
// Le fichier envoyé dépasse post_max_size : $_POST/$_FILES sont vides, on le détecte
// tout de suite (sinon le script redirige silencieusement vers admin.php sans explication)
if (postDepasseLimite()) {
@@ -65,15 +68,13 @@ if (is_array($pdfResultat)) {
}
try { // On essaie d'executer une requête qui est préparée
$sql = '
// Le PDF (pdf_data) n'est plus stocké dans experiences : il vit dans experience_pdfs
// (table associée, voir migration Juillet 2026 (6)), géré à part juste après
$stmt = $dbh->prepare('
UPDATE experiences
SET title = :title, status = :status, xptype = :xptype, tags = :tags, description = :description, duration = :duration, image_url = :image_url, markdown = :markdown, link_url = :link_url';
if ($updatePdf) {
$sql .= ', pdf_data = :pdf_data, pdf_filename = :pdf_filename';
}
$sql .= ' WHERE id = :id';
$stmt = $dbh->prepare($sql);
SET title = :title, status = :status, xptype = :xptype, tags = :tags, description = :description, duration = :duration, image_url = :image_url, markdown = :markdown, link_url = :link_url
WHERE id = :id
');
$stmt->bindValue(':title', $title, PDO::PARAM_STR);
$stmt->bindValue(':status', $status, PDO::PARAM_STR);
$stmt->bindValue(':xptype', $xptype, PDO::PARAM_STR);
@@ -83,12 +84,27 @@ try { // On essaie d'executer une requête qui est préparée
$stmt->bindValue(':image_url', $imageurl, PDO::PARAM_STR);
$stmt->bindValue(':markdown', $markdown, PDO::PARAM_STR);
$stmt->bindValue(':link_url', $link_url, PDO::PARAM_STR);
if ($updatePdf) {
$stmt->bindValue(':pdf_data', $pdfData, $pdfData === null ? PDO::PARAM_NULL : PDO::PARAM_LOB);
$stmt->bindValue(':pdf_filename', $pdfFilename, $pdfFilename === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
}
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute(); // Sinon on renvoie l'erreur
if ($updatePdf) {
if ($pdfData !== null) {
// Nouveau PDF envoyé : on remplace l'éventuel PDF existant (clé primaire = experience_id)
$stmtPdf = $dbh->prepare('
INSERT INTO experience_pdfs (experience_id, pdf_filename, pdf_data) VALUES (:id, :pdf_filename, :pdf_data)
ON DUPLICATE KEY UPDATE pdf_filename = VALUES(pdf_filename), pdf_data = VALUES(pdf_data)
');
$stmtPdf->bindValue(':id', $id, PDO::PARAM_INT);
$stmtPdf->bindValue(':pdf_filename', $pdfFilename, PDO::PARAM_STR);
$stmtPdf->bindValue(':pdf_data', $pdfData, PDO::PARAM_LOB);
$stmtPdf->execute();
} else {
// Case "Supprimer ce PDF" cochée, sans nouveau fichier envoyé
$stmtPdf = $dbh->prepare('DELETE FROM experience_pdfs WHERE experience_id = :id');
$stmtPdf->bindValue(':id', $id, PDO::PARAM_INT);
$stmtPdf->execute();
}
}
} catch (PDOException $e) {
die("Erreur lors de la modification de l experience :" . $e->getMessage());
}
+6 -4
View File
@@ -6,10 +6,12 @@
require_once('../functions/databaseConnection.php');
// Liste blanche des types autorisés : empêche toute injection de nom de table
// (le PDF est stocké dans une table associée depuis la migration Juillet 2026 (6),
// pour ne plus jamais charger son contenu binaire lors des listes de projets/xp/écoles)
$tables = [
'projet' => 'projects',
'experience' => 'experiences',
'ecole' => 'schools',
'projet' => ['pdf' => 'project_pdfs', 'colonne' => 'project_id'],
'experience' => ['pdf' => 'experience_pdfs', 'colonne' => 'experience_id'],
'ecole' => ['pdf' => 'school_pdfs', 'colonne' => 'school_id'],
];
$type = $_GET['type'] ?? '';
@@ -21,7 +23,7 @@ if (!isset($tables[$type]) || !is_numeric($id)) {
}
try {
$stmt = $dbh->prepare('SELECT pdf_data, pdf_filename FROM `' . $tables[$type] . '` WHERE id = :id');
$stmt = $dbh->prepare('SELECT pdf_data, pdf_filename FROM `' . $tables[$type]['pdf'] . '` WHERE `' . $tables[$type]['colonne'] . '` = :id');
$stmt->bindValue(':id', (int) $id, PDO::PARAM_INT);
$stmt->execute();
$row = $stmt->fetch(PDO::FETCH_ASSOC);
+96 -8
View File
@@ -517,6 +517,44 @@ img {
flex-wrap: wrap;
}
/* ---- Bandeau de chiffres clés (page d'accueil) ---- */
.stats-strip {
max-width: 900px;
margin: 0 auto;
padding: 0 20px 70px;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 20px;
text-align: center;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 18px 34px;
min-width: 140px;
border-radius: var(--radius);
background: var(--surface);
border: 1px solid var(--border);
}
.stat-number {
font-size: clamp(28px, 4vw, 38px);
font-weight: 800;
letter-spacing: -0.02em;
color: var(--accent);
}
.stat-label {
font-size: 13.5px;
color: var(--text-muted);
font-weight: 600;
}
.btn {
display: inline-flex;
align-items: center;
@@ -1349,8 +1387,10 @@ img {
}
}
/* Pas de fond/bordure : les logos flottent directement sur le fond de la page,
comme un bandeau "trusted by" classique */
/* Carte derrière chaque logo, avec un fond SOMBRE FIXE (pas var(--surface), qui devient
blanc en thème clair) : certains logos PNG ont un fond noir non transparent intégré à
l'image, qui doit rester sur un fond sombre constant pour ne pas ressortir moche
(carré noir visible) quel que soit le thème actif sur le reste du site */
.logo-tile {
flex-shrink: 0;
display: flex;
@@ -1359,17 +1399,21 @@ img {
width: 160px;
height: 90px;
padding: 18px 24px;
background: transparent;
border: none;
background: #14141c;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius);
text-decoration: none;
transition: transform 0.2s ease;
transition: transform 0.2s ease, border-color 0.2s ease, background-color 0.2s ease;
}
a.logo-tile:hover {
transform: translateY(-3px);
border-color: var(--accent-border);
background: #1c1c26;
}
.logo-tile img {
.logo-tile img,
.logo-tile i {
max-width: 100%;
max-height: 100%;
object-fit: contain;
@@ -1377,17 +1421,36 @@ a.logo-tile:hover {
transition: filter 0.25s ease;
}
.logo-tile:hover img {
/* Taille de la glyphe pour les icônes Devicon (les images gardent leur taille naturelle) */
/* Couleur fixe (pas var(--text), qui devient sombre en thème clair et rendrait
l'icône invisible sur le fond de tuile qui lui reste volontairement sombre) */
.logo-tile i {
font-size: 44px;
line-height: 1;
color: #e8e8f2;
}
.logo-tile:hover img,
.logo-tile:hover i {
filter: grayscale(0) opacity(1);
}
/* Sur mobile le survol n'existe pas : on affiche les logos en couleur directement */
@media (hover: none) {
.logo-tile img {
.logo-tile img,
.logo-tile i {
filter: grayscale(0) opacity(0.85);
}
}
/* Bandeau "Outils" : pas de niveaux de gris, on veut que les vrais logos des outils
soient reconnaissables immédiatement, sans avoir à survoler chaque tuile */
.logo-marquee-tools .logo-tile img,
.logo-marquee-tools .logo-tile i {
filter: none;
opacity: 1;
}
@media (max-width: 620px) {
.logo-tile {
width: 130px;
@@ -1446,6 +1509,11 @@ a.logo-tile:hover {
object-fit: contain;
}
.logo-admin-preview i {
font-size: 40px;
color: var(--text);
}
.logo-admin-info {
padding: 14px 16px 4px;
}
@@ -1564,6 +1632,14 @@ a.logo-tile:hover {
text-align: center;
}
/* Vraie image de logo (format "img:URL") pour les outils absents des polices d'icônes */
.skill-item-visual {
width: 18px;
height: 18px;
object-fit: contain;
flex-shrink: 0;
}
/* Boutons admin dans les cartes de compétences */
.skill-category .admin-actions {
margin-top: 18px;
@@ -1955,6 +2031,18 @@ a.card-link::after {
font-size: 0.85em;
}
/* Texte d'introduction sous un titre de section admin (voir Logos partenaires) */
.admin-section-intro {
max-width: 1200px;
margin: 0 auto 16px;
padding: 0 clamp(16px, 4vw, 40px);
}
.admin-section-intro a {
color: var(--accent-light);
font-weight: 600;
}
/* Bloc repliable contenant chaque formulaire */
.admin-block {
max-width: 1200px;