From f27cca08ff786ceff1870147f6d6bad14d71117f Mon Sep 17 00:00:00 2001 From: polo Date: Fri, 23 Oct 2026 00:47:32 +0200 Subject: =?UTF-8?q?dossiers=20avec=20majuscule,=20namespace=20pour=20toute?= =?UTF-8?q?s=20les=20classes,=20autoload=20psr-4,=20UserDataService::creat?= =?UTF-8?q?eZip=20d=C3=A9plac=C3=A9=20dans=20Backup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Service/Backup.php | 272 ++++++++++++++++++++++++++++ src/Service/Captcha.php | 57 ++++++ src/Service/Config.php | 90 ++++++++++ src/Service/EmailService.php | 106 +++++++++++ src/Service/FormValidation.php | 216 ++++++++++++++++++++++ src/Service/Installation.php | 109 ++++++++++++ src/Service/Router.php | 393 +++++++++++++++++++++++++++++++++++++++++ src/Service/Security.php | 112 ++++++++++++ src/Service/URL.php | 90 ++++++++++ src/Service/User.php | 25 +++ src/Service/session.php | 94 ++++++++++ 11 files changed, 1564 insertions(+) create mode 100644 src/Service/Backup.php create mode 100644 src/Service/Captcha.php create mode 100644 src/Service/Config.php create mode 100644 src/Service/EmailService.php create mode 100644 src/Service/FormValidation.php create mode 100644 src/Service/Installation.php create mode 100644 src/Service/Router.php create mode 100644 src/Service/Security.php create mode 100644 src/Service/URL.php create mode 100644 src/Service/User.php create mode 100644 src/Service/session.php (limited to 'src/Service') diff --git a/src/Service/Backup.php b/src/Service/Backup.php new file mode 100644 index 0000000..546e81e --- /dev/null +++ b/src/Service/Backup.php @@ -0,0 +1,272 @@ +format('Y-m-d') . '_' . $type . '.sql'; + + // les versions de mysql sont comme ci: 8.0.36 + // celles de mariadb sont comme ça: 10.11.6-MariaDB + $version = $entityManager->getConnection()->fetchOne('SELECT VERSION()'); + $engine = stripos($version, 'mariadb') !== false ? 'mariadb-dump' : 'mysqldump'; + + $tmp = tempnam('../var', 'tmp_db_codes_'); // crée un fichier avec un nom aléatoire et des droits 600 (concurrence) + // attention, si le dossier de destination n'est pas disponible, le fichier est placé avec les fichiers temporaires + file_put_contents($tmp, + "[client]\n + user=" . Config::$user . "\n + password=" . Config::$password . "\n + host=" . Config::$db_host . "\n"); + + $command = new Process([ + $engine, + '--defaults-extra-file=' . $tmp, // pour ne pas enregistrer les codes dans l'historique de la console ou dans les processus de l'OS + '--single-transaction', + '--quick', // évite d'exploser la RAM si beaucoup de données + '--result-file=' . $file_path, + Config::$database + ]); + + try{ + // unlink et chmod permettent que le serveur et l'utilisateur appelant bin/mysqldump.php réussissent + if(file_exists($file_path)){ + unlink($file_path); + } + $command->mustRun(); // comme run() mais lance une ProcessFailedException + + //$file_path = self::gzipCompress($file_path); // '.gz' ajouté à la fin + + chmod($file_path, 0666); + return $file_path; + } + finally{ + // exécuté même quand situé après "return" + unlink($tmp); + self::cleanBackups(); + } + } + + // compression gzip (gros gain de place sur le serveur), nécessite l'extension zlib + static public function gzipCompress(string $file_path): string + { + try{ + file_put_contents( + $file_path . '.gz', + gzencode(file_get_contents($file_path), 5), // plus rapide que 9 et taille identique d'après mes essais + ); + unlink($file_path); + $file_path .= '.gz'; + } + finally{ + return $file_path; + } + } + + static public function getBackupList(): array + { + $files = scandir(Backup::$backup_dir); // affiche un warning si échoue (à cacher en prod) + if(!$files){ + throw new \RuntimeException("Le serveur a rencontré une erreur:
Accès aux backups impossible faute de permissions."); + } + + $backup_array = []; + foreach($files as $file){ + if($file[0] === '.'){ + continue; + } + $backup_array[] = $file; + } + return $backup_array; + } + + static public function cleanBackups(): void + { + $files = glob(self::$backup_dir . '/*.sql'); + //usort($files, fn($a, $b) => filemtime($b) <=> filemtime($a)); // filemtime = date de dernière modification + arsort($files); + + // tri par nom de BDD puis par date + $sorted_files = []; + $list_by_database = []; // pour le nettoyage 2 + foreach($files as $file){ + $exploded = explode('_', basename($file)); + $sorted_files[$exploded[0]][$exploded[1]][] = $file; + $list_by_database[$exploded[0]][] = $file; + } + + $date = new \DateTime; + foreach($sorted_files as $db_name => $from_one_database){ + // on garde une "quantité à garder" par BDD + if(count($from_one_database) > self::$amount_to_keep){ + // nettoyage 1 + foreach($from_one_database as $date_key => $with_same_date){ + // pas touche à aujourd'hui + if($date_key != $date->format('Y-m-d')){ + self::cleanBackupsByPriority($with_same_date); + } + } + // nettoyage 2 + $files_to_delete = array_slice($list_by_database[$db_name], self::$amount_to_keep); + foreach($files_to_delete as $file){ + unlink($file); + } + } + } + } + + // conserver un seul backup par jour choisi dans cet ordre de préférence: console => before-restore => download => auto + // cet ordre correspond à: volonté de l'utilisateur => état du jour avant changement => volonté de changement => automatique sans contrôle + static private function cleanBackupsByPriority(array $files): void + { + $priorities = [ + 'console' => 1, + 'before-restore' => 2, + 'uploaded' => 3, + 'auto' => 4, + ]; + $best_priority = PHP_INT_MAX; + + // recherche du fichier à conserver + $to_keep = null; + foreach($files as $file){ + foreach($priorities as $keyword => $priority){ + if(str_contains(basename($file), $keyword) && $priority < $best_priority){ + $best_priority = $priority; + $to_keep = $file; + break; + } + } + } + // suppression des autres + foreach($files as $file){ + if($file !== $to_keep){ + unlink($file); + } + } + } + + static public function restoreDatabase(EntityManager $entityManager, string $file_name, array $exclusion_list = []): void + { + // création d'un backup de sécurité non écrasable + $date = new \DateTime; + if(!file_exists(self::$backup_dir . '/' . Config::$database . '_' . $date->format('Y-m-d') . '_before-restore.sql')){ + Backup::mySQLdump($entityManager, 'before-restore'); + } + + $version = $entityManager->getConnection()->fetchOne('SELECT VERSION()'); + $engine = stripos($version, 'mariadb') !== false ? 'mariadb' : 'mysql'; + + $tables = self::getDBListOfTables($entityManager, $exclusion_list); // choisir les tables à effacer et restaurer, exclure "user" + + $tmp_db_codes = tempnam('../var', 'tmp_db_codes_'); // crée un fichier avec un nom aléatoire et des droits 600 (concurrence) + file_put_contents($tmp_db_codes, + "[client]\n + user=" . Config::$user . "\n + password=" . Config::$password . "\n + host=" . Config::$db_host . "\n"); + + //$file_name = self::gzipExtract($file_name); // '.gz' ajouté à la fin + + $command = new Process([ + $engine, // mariadb ou mysql + '--defaults-extra-file=' . $tmp_db_codes, // pour ne pas enregistrer les codes dans l'historique de la console ou dans les processus de l'OS + Config::$database + ]); + $command->setInput(file_get_contents(Backup::$backup_dir . '/' . $file_name)); // l'entrée < + + // utiliser une liste de tables à exclure dynamique + try{ + // tout effacer + $tables_with_backquotes = array_map(fn($t) => '`' . $t . '`', $tables); + if(!empty($tables_with_backquotes)){ + $sql = "SET FOREIGN_KEY_CHECKS=0; DROP TABLE " . implode(', ', $tables_with_backquotes) . "; SET FOREIGN_KEY_CHECKS=1;"; + $entityManager->getConnection()->executeStatement($sql); + } + + // copie des tables à ne pas restaurer + foreach($exclusion_list as $excluded){ + $entityManager->getConnection()->executeStatement('RENAME TABLE `' . TABLE_PREFIX . $excluded . '` TO `' . TABLE_PREFIX . $excluded . '_dont_touch`;'); + } + + // restaurer + $command->mustRun(); // comme run() mais lance une ProcessFailedException + + // remettre table user comme avant + foreach($exclusion_list as $excluded){ + $entityManager->getConnection()->executeStatement('DROP TABLE `' . TABLE_PREFIX . $excluded . '`;'); + $entityManager->getConnection()->executeStatement('RENAME TABLE `' . TABLE_PREFIX . $excluded . '_dont_touch` TO `' . TABLE_PREFIX . $excluded . '`;'); + } + } + finally{ + // exécuté même quand situé après "return" + unlink($tmp_db_codes); + } + } + + static private function getDBListOfTables(EntityManager $entityManager, array $exclusion_list = []): array + { + $tables = $entityManager->getConnection()->createSchemaManager()->listTableNames(); + foreach($tables as $key => $elem){ + foreach($exclusion_list as $excluded){ + if(!is_string($excluded)){ // cas pas du tout censé arriver! + throw new \LogicException("Un nom de table doit etre une chaîne de caractères."); + } + if($elem === TABLE_PREFIX . $excluded){ + unset($tables[$key]); + break; // petite optimisation + } + } + } + return $tables; + } + + static public function createZip(string $zip_name, array $source_directories, array $pattern_to_target_in_user_data = []): string + { + $file_path = '../var/' . $zip_name; + $Zip = new \ZipArchive(); + + if($Zip->open($file_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== TRUE){ // ou ZipArchive::FL_OVERWRITE, à comparer à ZipArchive::OVERWRITE + throw new \RuntimeException("Création ou ouverture du fichier demandé impossible à cause d'une erreur de permissions sur le serveur."); + } + + // recherche récursive dans les dossiers dans $source_directories, c'est comme le paramètre "-r" dans la console + $counter = 0; + foreach($source_directories as $path){ + $directory = new \RecursiveDirectoryIterator($path); + $iterator = new \RecursiveIteratorIterator($directory); + + foreach($iterator as $info){ + if($info->getFilename() != "." && $info->getFilename() != ".." && $info->getFilename() != '.htaccess'){ // chemins inutiles . et .. et le .htaccess + $Zip->addGlob($info->getPathname(), 0, array('')); + $counter++; + } + } + } + // recherche à la racine avec des pattern de noms de fichiers (optionnel) + foreach($pattern_to_target_in_user_data as $one_pattern){ + $Zip->addGlob($one_pattern, 0, array('')); + } + + $Zip->close(); + if($counter > 0){ + //chmod($file_path, 0666); + return $zip_name; + } + else{ + throw new \RuntimeException("Téléchargement des fichiers impossible, aucun fichier n'a été trouvé sur le serveur."); + } + } +} \ No newline at end of file diff --git a/src/Service/Captcha.php b/src/Service/Captcha.php new file mode 100644 index 0000000..28c7f71 --- /dev/null +++ b/src/Service/Captcha.php @@ -0,0 +1,57 @@ + on pourrait appliquer le pattern "singleton" (justification = le captcha devient une sorte de ressource partagée) + +declare(strict_types=1); + +namespace App\Service; + +class Captcha +{ + private int $a; + private int $b; + + public function __construct(){ + $this->a = rand(2, 9); + $this->b = rand(2, 9); + } + + public function getA(): string + { + return $this->toLettersFrench($this->a); + } + public function getB(): string + { + return $this->toLettersFrench($this->b); + } + public function getSolution(): int + { + return ($this->a * $this->b); + } + + private function toLettersFrench(int $number): string + { + return match($number){ + 2 => 'deux', + 3 => 'trois', + 4 => 'quatre', + 5 => 'cinq', + 6 => 'six', + 7 => 'sept', + 8 => 'huit', + 9 => 'neuf', + default => '', // erreur + }; + } + + // (à déplacer dans FormValidation?) + static public function controlInput(string $input = '0'): int + { + // un POST est une chaîne qu'on doit convertir en nombre dans deux conditions: + // test de format: $input est un nombre + // test d'intégrité: supprimer les décimales avec (int) ne change pas la valeur du nombre + return is_numeric($input) && $input == (int)$input ? (int)$input : 0; + } +} \ No newline at end of file diff --git a/src/Service/Config.php b/src/Service/Config.php new file mode 100644 index 0000000..fc4d127 --- /dev/null +++ b/src/Service/Config.php @@ -0,0 +1,90 @@ +Le fichier config/config.ini n'existe pas ou n'est pas lisible.

"; + } + define('TABLE_PREFIX', self::$table_prefix); + } + + // renseigner les variables internes de Config + static private function hydrate(array $raw_data): void + { + foreach($raw_data as $field => $value) + { + if($value != '') // valeur par défaut + { + if(isset(self::$$field)) // le champ existe dans Config + { + // problème du slash à la fin du nom d'un dossier + $value = self::slashAtEndOfPath($field, $value); + self::$$field = $value; + } + else + { + echo "debug: le fichier config.ini comporte une erreur, le champ: " . $field . " est incorrect,\nl'information contenue sur cette ligne ne sera pas utilisée\n"; + } + } + /*else + { + echo "debug: le champ " . $field . " est vide, la valeur par défaut " . self::$$field . " sera utilisée.\n"; + }*/ + } + } + + + // pour que les chemins finissent toujours par un / + static private function slashAtEndOfPath(string $field, string $value): string + { + foreach(self::$path_vars as $item) + { + if($field === $item){ + return !str_ends_with($value, '/') ? $value . '/' : $value; + } + } + return $value; + } +} diff --git a/src/Service/EmailService.php b/src/Service/EmailService.php new file mode 100644 index 0000000..38b4cf8 --- /dev/null +++ b/src/Service/EmailService.php @@ -0,0 +1,106 @@ + exceptions + $mail->CharSet = 'UTF-8'; + + $smtp_host = $form_data->getData()['smtp_host'] ?? Config::$smtp_host; + $smtp_secure = $form_data->getData()['smtp_secure'] ?? Config::$smtp_secure; + $smtp_username = $form_data->getData()['smtp_username'] ?? Config::$smtp_username; + $smtp_password = $form_data->getData()['smtp_password'] ?? Config::$smtp_password; + + $email_from = $form_data->getData()['email_from'] ?? Config::$email_from; // adresse de l'expéditeur affichée + $email_from = empty($email_from) ? $smtp_username : $email_from; // si vide + $email_from_name = $form_data->getData()['email_from_name'] ?? Config::$email_from_name; // = nom de l'expéditeur affiché + $email_dest = $form_data->getData()['email_dest'] ?? Config::$email_dest; + $email_dest_name = $form_data->getData()['email_dest_name'] ?? Config::$email_dest_name; // = destinataire formulaire + + try{ + // Paramètres du serveur + $mail->isSMTP(); + $mail->Host = $smtp_host; + $mail->SMTPAuth = true; + $mail->Port = 25; + + if($mail->SMTPAuth){ + $mail->Username = $smtp_username; // e-mail + $mail->Password = $smtp_password; + $mail->SMTPSecure = $smtp_secure; // tls (starttls) ou ssl (smtps) + if($mail->SMTPSecure === 'tls'){ + $mail->Port = 587; + } + elseif($mail->SMTPSecure === 'ssl'){ + $mail->Port = 465; + } + } + //var_dump($mail->smtpConnect());die; // test de connexion + + // Expéditeur et destinataire + // $email_from, $email_from_name et $email_dest_name sont modifiables uniquement dans le config.ini pour l'instant + $mail->setFrom(strtolower($email_from), $email_from_name); + $mail->addAddress(strtolower($email_dest), $email_dest_name); + + // Contenu + $mail->isHTML(true); + if($test_email){ + $mail->Subject = "TEST d'un envoi d'e-mail depuis le site web"; + } + else{ + $mail->Subject = 'Message envoyé par: ' . $name . ' (' . $email . ') depuis le site web'; + } + $mail->Body = $message; + $mail->AltBody = $message; + + $mail->send(); + + // copie en BDD + if(!$test_email && ($form_data->getData()['keep_emails'] ?? self::KEEP_EMAILS_DEFAULT)){ + $db_email = new Email($name, $email, $email_dest, $message, $form_data); + $entityManager->persist($db_email); + self::updateLastContactDate($entityManager, $email); + $entityManager->flush(); + } + + return true; + } + catch(\Exception $e){ + echo "Le message n'a pas pu être envoyé. Erreur : {$e}
{$mail->ErrorInfo}"; + return false; + } + } + + static public function updateLastContactDate(EntityManager $entityManager, string $sender): void + { + foreach($entityManager->getRepository('App\Entity\Email')->findAll() as $email){ + $email->getSenderAddress() === $sender ? $email->updateLastContactDate() : null; + } + } + + // peut être appelée par bin/clean_emails_cron.php + static public function cleanEmails(EntityManager $entityManager): void + { + $emails = $entityManager->getRepository('App\Entity\Email')->findAll(); + foreach($emails as $email){ + if($email->getDeletionDate() < new \DateTime()){ + $entityManager->remove($email); + } + } + $entityManager->flush(); + } +} \ No newline at end of file diff --git a/src/Service/FormValidation.php b/src/Service/FormValidation.php new file mode 100644 index 0000000..d91aa12 --- /dev/null +++ b/src/Service/FormValidation.php @@ -0,0 +1,216 @@ + 'emailSendStrategy', + self::EmailParam => 'emailParamStrategy', + self::CreateUser => 'createUserStrategy', + self::Connection => 'connectionStrategy', + self::UsernameUpdate => 'usernameUpdateStrategy', + self::PasswordUpdate => 'passwordUpdateStrategy' + }; + } +} + +class FormValidation +{ + private array $data; // tableau associatif (probablement $_POST) + private \Closure $callStrategy; + private ?string $error; + + public function __construct(array $data, ValidationStrategy $strategy){ + $this->data = $data; + $method = $strategy->method(); + $this->callStrategy = fn() => $this->$method(); + } + + public function validate(): bool + { + $this->error = ($this->callStrategy)(); // appel de la callback + return empty($this->errors); + } + + public function getError(): string + { + return $this->error; + } + + public function getField(string $field): string + { + return $this->data[$field] ?? ''; + } + + // méthodes de validation + private function captchaValidate(bool $clean_session = true): ?string + { + $captcha_solution = (isset($_SESSION['captcha']) && is_int($_SESSION['captcha'])) ? $_SESSION['captcha'] : 0; + $captcha_try = isset($this->data['captcha']) ? Captcha::controlInput($this->data['captcha']) : 0; + if($clean_session){ + unset($_SESSION['captcha']); + } + + if($captcha_try == 0){ + return 'error_non_valid_captcha'; + } + elseif($captcha_solution == 0){ // ne peut pas arriver, si? + return 'captcha_server_error'; + } + elseif($captcha_try !== $captcha_solution){ + return 'bad_solution_captcha'; + } + return null; + } + + // erreurs à la création des mots de passe + static private function removeSpacesTabsCRLF(string $chaine): string + { + $cibles = [' ', "\t", "\n", "\r"]; // doubles quotes !! + return(str_replace($cibles, '', $chaine)); + } + + + // stratégies + private function emailSendStrategy(): ?string + { + if($error = $this->captchaValidate(false)){ + return $error; + } + + if(empty($this->data['name']) || empty($this->data['email']) || empty($this->data['message']) + || !isset($this->data['hidden']) || !empty($this->data['hidden'])){ + return 'missing_fields'; + } + + elseif(!filter_var(trim($this->data['email']), FILTER_VALIDATE_EMAIL)){ + return 'bad_email_address'; + } + + $this->data['name'] = htmlspecialchars(trim($this->data['name'])); + $this->data['email'] = htmlspecialchars(trim($this->data['email'])); + $this->data['message'] = htmlspecialchars($this->data['message']); + + return null; + } + private function emailParamStrategy(): ?string + { + if(empty($this->data['id']) || empty($this->data['what_param']) || empty($this->data['value']) + || !isset($this->data['hidden']) || !empty($this->data['hidden'])){ + return 'missing_fields'; + } + + if(!in_array($this->data['what_param'], ['smtp_host', 'smtp_secure', 'smtp_username', 'smtp_password', 'email_dest'])){ + return 'unknown_parameter'; + } + elseif($this->data['what_param'] === 'smtp_username' || $this->data['what_param'] === 'email_dest'){ + if(!filter_var($this->data['value'], FILTER_VALIDATE_EMAIL)){ + return 'invalide_email_address'; + } + } + return null; + } + private function createUserStrategy(): ?string + { + if($error = $this->captchaValidate()){ + return $error; + } + + // test mauvais paramètres + if(empty($this->data['login']) || empty($this->data['password']) || empty($this->data['password_confirmation']) + || !isset($this->data['create_user_hidden']) || !empty($this->data['create_user_hidden'])) + { + return 'bad_login_or_password'; + } + + if($this->data['password'] !== $this->data['password_confirmation']){ + return 'different_passwords'; + } + + if($this->data['login'] !== self::removeSpacesTabsCRLF(htmlspecialchars($this->data['login'])) + || $this->data['password'] !== self::removeSpacesTabsCRLF(htmlspecialchars($this->data['password']))){ + return 'forbidden_characters'; + } + return null; + } + private function connectionStrategy(): ?string + { + if($error = $this->captchaValidate()){ + return $error; + } + + if(empty($this->data['login']) || empty($this->data['password']) + || !isset($this->data['connection_hidden']) || !empty($this->data['connection_hidden'])) + { + return 'bad_login_or_password'; + } + return null; + } + private function usernameUpdateStrategy(): ?string + { + if($error = $this->captchaValidate()){ + return $error; + } + + if(empty($this->data['login']) || empty($this->data['password']) || empty($this->data['new_login']) + || !isset($this->data['modify_username_hidden']) || !empty($this->data['modify_username_hidden'])) + { + return 'bad_login_or_password'; + } + + $new_login = self::removeSpacesTabsCRLF(htmlspecialchars($this->data['new_login'])); + if($new_login !== $this->data['new_login']){ + return 'forbidden_characters'; + } + + if($this->data['login'] !== $_SESSION['user']['username']){ + return 'bad_login_or_password'; + } + if($this->data['login'] === $new_login){ + return 'same_username_as_before'; + } + return null; + } + private function passwordUpdateStrategy(): ?string + { + if($error = $this->captchaValidate()){ + return $error; + } + + if(empty($this->data['login']) || empty($this->data['password']) || empty($this->data['new_password']) + || !isset($this->data['modify_password_hidden']) || !empty($this->data['modify_password_hidden'])) + { + return 'bad_login_or_password'; + } + + $new_password = self::removeSpacesTabsCRLF(htmlspecialchars($this->data['new_password'])); + if($new_password !== $this->data['new_password']){ + return 'forbidden_characters'; + } + + if($this->data['login'] !== $_SESSION['user']['username']){ + return 'bad_login_or_password'; + } + if($this->data['password'] === $new_password){ + return 'same_password_as_before'; + } + return null; + } +} \ No newline at end of file diff --git a/src/Service/Installation.php b/src/Service/Installation.php new file mode 100644 index 0000000..ed9f20a --- /dev/null +++ b/src/Service/Installation.php @@ -0,0 +1,109 @@ +L'extension " . $extension . " est manquante.

"); + $flag = true; + } + } + if(!class_exists(\DOMDocument::class)){ // théoriquement plus fiable que extension_loaded() + echo("

L'extension dom est manquante.

"); + $flag = true; + } + + /*if(!extension_loaded('imagick') && !extension_loaded('gd')){ + echo("

il manque une de ces extensions au choix pour le traitement des images: imagick (de préférence) ou gd.

"); + $flag = true; + }*/ + // si imagick n'est pas disponible, essayer gd (reste encore à coder) + + if($flag){ + echo '

Réalisez les actions nécéssaires sur le serveur ou contactez l\'administrateur du site.
+ Quand le problème sera résolu, il vous suffira de recharger la page.

'; + die; + } + } + + static public function checkFilesAndFoldersRights(): void + { + // -- droits des fichiers et dossiers -- + $droits_dossiers = 0777; + + $flag = false; + if(!file_exists('user_data')){ + try{ + mkdir('user_data/'); + chmod('user_data/', $droits_dossiers); + } + catch(\Exception $e){ + echo '

Le dossier public/user_data introuvable et le serveur n\'a pas la permission de le créer.
+ Pour faire ça bien:
sudo -u "serveur web" mkdir /chemin/du/site/public/user_data

'; + echo $e; + $flag = true; + } + } + if(!file_exists('../var')){ + try{ + mkdir('../var'); + chmod('../var', $droits_dossiers); + } + catch(\Exception $e){ + echo $e; + $flag = true; + } + } + if(!file_exists('../var/backups')){ + try{ + mkdir('../var/backups'); + chmod('../var/backups', $droits_dossiers); // autoriser à la fois le serveur et les scripts dans bin/ + } + catch(\Exception $e){ + echo $e; + $flag = true; + } + } + + // droits 600 pour celui-ci + if(!file_exists('../config/config.ini')){ + // aide à la création du config.ini + echo '

Le fichier config/config.ini est introuvable.

'; + echo '

Il doit obligatoirement contenir les codes de la base de données, le protocole http ou https (et éventuellement le port) utilisé pour créer les liens internes.
+ Un modèle est disponible, il s\'agit du fichier config/config-template.ini

+

Ce fichier a une importance critique. Si vous le pouvez faites en sorte que le serveur en soit le propriétaire et donner lui des droits 600.

'; + $flag = true; + } + /*else{ + // propriétaire du fichier + if(fileowner('../config/config.ini') != posix_geteuid()){ + echo "

le fichier config/config.ini n'appartient pas au serveur.

"; + } + else{ + // droits du config.ini + if(substr(sprintf('%o', fileperms('../config/config.ini')), -4) != 600){ + echo '

Attention, le

'; + //chmod('../config/config.ini', $droits_fichiers); + } + } + }*/ + if($flag){ + die; + } + + // tester les liens internes + // + + // le test de connexion à la BDD est dans le doctrine bootstrap + } +} \ No newline at end of file diff --git a/src/Service/Router.php b/src/Service/Router.php new file mode 100644 index 0000000..98cecc5 --- /dev/null +++ b/src/Service/Router.php @@ -0,0 +1,393 @@ + 1er test, méthode http GET? POST? +=> 2ème test, type de contenu: +"application/x-www-form-urlencoded" = formulaire +"application/json" = requête AJAX avec fetch() +"multipart/form-data" = upload d'image par tinymce +$_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest' requête AJAX xhs, non utilisée +=> 3ème test, comme le 2ème test mais uniquement si IS_ADMIN est vrai */ + +/* classes de réponses pour les contrôleurs +Response($html, Response::HTTP_OK) page html +JsonResponse(['success' => true, 'data' => $data]) ajax +RedirectResponse('index.php?page=login') redirection +BinaryFileResponse($filePath) téléchargement +StreamedResponse(function () {echo "ligne 1\n";echo "ligne 2\n";}) gros fichier */ + +// relire ça à l'occaz: +// https://symfony.com/doc/current/introduction/from_flat_php_to_symfony.html + +declare(strict_types=1); + +namespace App\Service; + +use Symfony\Component\HttpFoundation\Request; +use Doctrine\ORM\EntityManager; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Response; +use App\Service\User; +use App\View\AbstractBuilder; +use App\Model\Model; +use App\Model\Menu; +use App\Controller\UserController; +use App\Controller\ArticleController; +use App\Controller\CalendarController; +use App\Controller\MaintenanceController; +use App\Controller\ViewController; +use App\Controller\ContactFormController; +use App\Controller\ImageUploadController; +use App\Controller\HeadFootController; +use App\Controller\MenuAndPathsController; +use App\Controller\PageManagementController; +use App\Controller\FileUploadController; + +class Router{ + private Request $request; + private EntityManager $entityManager; + private string $route = ''; // défaut page d'accueil + + public function __construct(Request $request, EntityManager $entityManager){ + $this->request = $request; + $this->entityManager = $entityManager; + + if(!User::existUsers($entityManager)){ // table "user" vide + $this->route = 'no_user'; + } + } + + public function dispatch(): Response + { + if($this->request->getMethod() === 'GET'){ + // table "user" vide + if($this->route === 'no_user'){ + ob_start(); + require AbstractBuilder::VIEWS_PATH . 'user_create.php'; + return new Response(ob_get_clean()); + } + + // bouton déconnexion (méthode GET parce que l'utilisateur ne modifie plus de données à partir de là) + if($this->request->query->get('action') === 'deconnection'){ + return UserController::disconnect(); // retourne un RedirectResponse + } + + // articles suivants + if($this->request->query->get('fetch') === 'next_articles'){ + return ArticleController::fetch($this->entityManager, $this->request); // retourne un JsonResponse + } + + // données du calendrier + // création du calendrier ou changement de dates affichées (boutons flèches mais pas changement de vue) + if($this->request->query->get('action') === 'get_events' + && $this->request->query->has('start') && $this->request->query->has('end') && empty($this->request->getPayload()->all())){ // getPayload ne récupère pas que des POST + return CalendarController::getData($this->entityManager); + } + + // pages interdites + if(!IS_ADMIN && in_array(CURRENT_PAGE, ['menu_paths', 'new_page', 'user_edit', 'emails', 'maintenance'])){ + return new RedirectResponse((string)new URL); + } + + if(IS_ADMIN){ + if($this->request->query->get('action') === 'get_mysqldump'){ + return MaintenanceController::getLastDump($this->entityManager); + } + if($this->request->query->get('action') === 'get_all_media'){ + return MaintenanceController::getAllMedia(); + } + } + + // affichage d'une page + return ViewController::getWebPage($this->entityManager, $this->request); + } + + + elseif($this->request->getMethod() === 'POST'){ + /* -- contrôleurs appelables par tout le monde -- */ + + // table "user" vide + if($this->route === 'no_user'){ + return UserController::createAdminUser($this->entityManager); + } + + // requête JSON avec fetch() + if($this->request->headers->get('Content-Type') === 'application/json') + { + $json = json_decode($this->request->getContent(), true); // = json_decode(file_get_contents('php://input'), true); + + // formulaire de contact + if($this->request->query->get('action') === 'send_email'){ + return ContactFormController::sendVisitorEmail($this->entityManager, $json); + } + /*else{ + return new JsonResponse(['success' => false, 'error' => 'tu fais quoi là mec?'], JsonResponse::HTTP_BAD_REQUEST); + }*/ + } + + // envoi formulaire HTML + elseif($this->request->headers->get('Content-Type') === 'application/x-www-form-urlencoded'){ + // tentative de connexion + if($this->request->query->get('action') === 'connection'){ + return UserController::connect($this->entityManager); + } + /*else{ + return new RedirectResponse((string)new URL(['error' => 'tu fais quoi là mec?'])); + }*/ + } + + if(IS_ADMIN){ + /* -- requêtes AJAX -- */ + + // requêtes JSON avec fetch() + if($this->request->headers->get('Content-Type') === 'application/json'){ + $json = json_decode($this->request->getContent(), true); // = json_decode(file_get_contents('php://input'), true); + + if($this->request->query->has('action')){ + /* -- manipulation des articles -- */ + if($this->request->query->get('action') === 'editor_submit' && isset($json['id']) && isset($json['content'])){ + return ArticleController::editorSubmit($this->entityManager, $json); + } + elseif($this->request->query->get('action') === 'delete_article' && isset($json['id'])){ + return ArticleController::deleteArticle($this->entityManager, $this->request); // version AJAX + } + elseif($this->request->query->get('action') === 'switch_positions' && isset($json['id1']) && isset($json['id2'])){ + return ArticleController::switchPositions($this->entityManager, $json); + } + elseif($this->request->query->get('action') === 'date_submit' && isset($json['id']) && isset($json['date'])){ + return ArticleController::dateSubmit($this->entityManager, $json); + } + + switch($this->request->query->get('action')){ + /* -- bloc Formulaire -- */ + case 'keep_emails': + return ContactFormController::keepEmails($this->entityManager, $json); + case 'set_retention_period': + return ContactFormController::setEmailsRetentionPeriod($this->entityManager, $json); + case 'set_email_param': + return ContactFormController::setEmailParam($this->entityManager, $json); + case 'test_email': + return ContactFormController::sendTestEmail($this->entityManager, $json); + + /* -- page emails -- */ + case 'delete_email': + return ContactFormController::deleteEmail($this->entityManager, $json); + case 'toggle_sensitive_email': + return ContactFormController::toggleSensitiveEmail($this->entityManager, $json); + + /* -- upload d'image dans tinymce par copier-coller -- */ + // collage de HTML contenant une ou plusieurs balises + case 'upload_image_url': + return ImageUploadController::uploadImageHtml(); + // collage d'une image (code base64 dans le presse-papier) non encapsulée dans du HTML + case 'upload_image_base64': + return ImageUploadController::uploadImageBase64(); + + /* -- requêtes spécifiques au calendrier -- */ + case 'new_event': + return CalendarController::newEvent($json, $this->entityManager); + case 'update_event': + return CalendarController::updateEvent($json, $this->entityManager); + case 'remove_event': + return CalendarController::removeEvent($json, $this->entityManager); + + /* -- mode maintenance -- */ + case 'get_logs': + return MaintenanceController::getLogs($this->entityManager); + case 'erase_logs': + return MaintenanceController::eraseLogs($this->entityManager); + + default: + return new JsonResponse(['success' => false, 'error' => 'bad parameters'], JsonResponse::HTTP_BAD_REQUEST); + } + } + + /* -- site entier (header, footer, favicon) -- */ + elseif($this->request->query->has('head_foot_text')){ + return HeadFootController::setTextData($this->entityManager, $this->request->query->get('head_foot_text'), $json); + } + elseif($this->request->query->has('head_foot_social_check')){ + return HeadFootController::displaySocialNetwork($this->entityManager, $this->request->query->get('head_foot_social_check'), $json); + } + + /* -- page Menu et chemins -- */ + elseif($this->request->query->has('menu_edit')){ + // ne suit pas la règle, faire ça dans un contrôleur? + Model::$menu = new Menu($this->entityManager); // récupération des données + + // flèche gauche <=: position = position du parent + 1, parent = grand-parent, recalculer les positions + if($this->request->query->get('menu_edit') === 'move_one_level_up' && isset($json['id'])){ + return MenuAndPathsController::MoveOneLevelUp($this->entityManager, $json); + } + // flèche droite =>: position (léments de a fraterie + 1, l'élément précédent devient le parent + elseif($this->request->query->get('menu_edit') === 'move_one_level_down' && isset($json['id'])){ + return MenuAndPathsController::MoveOneLevelDown($this->entityManager, $json); + } + elseif($this->request->query->get('menu_edit') === 'switch_positions' && isset($json['id1']) && isset($json['id2'])){ + return MenuAndPathsController::switchPositions($this->entityManager, $json); + } + elseif($this->request->query->get('menu_edit') === 'display_in_menu' && isset($json['id']) && isset($json['checked'])){ + return MenuAndPathsController::displayInMenu($this->entityManager, $json); + } + elseif($this->request->query->get('menu_edit') === 'url_edit' && isset($json['id']) && isset($json['field']) && isset($json['input_data'])){ + return MenuAndPathsController::editUrl($this->entityManager, $json); + } + else{ + return new JsonResponse(['success' => false, 'error' => 'bad parameters'], JsonResponse::HTTP_BAD_REQUEST); // code 400 + } + } + + /* -- mode Modification d'une page -- */ + // partie "page" + elseif($this->request->query->has('page_edit')){ + switch($this->request->query->get('page_edit')){ + case 'page_title': + return PageManagementController::setPageTitle($this->entityManager, $json); + case 'page_description': + return PageManagementController::setPageDescription($this->entityManager, $json); + default: + return new JsonResponse(['success' => false, 'error' => 'bad parameters'], JsonResponse::HTTP_BAD_REQUEST); // code 400 + } + } + + // partie "blocs" + elseif($this->request->query->has('bloc_edit')){ + switch($this->request->query->get('bloc_edit')){ + case 'rename_page_bloc': + return PageManagementController::renameBloc($this->entityManager, $json); + case 'switch_blocs_positions': + return PageManagementController::SwitchBlocsPositions($this->entityManager, $json, $this->request->query->get('page')); + case 'change_articles_order': + return PageManagementController::changeArticlesOrder($this->entityManager, $json); + case 'change_presentation': + return PageManagementController::changePresentation($this->entityManager, $json); + case 'change_cols_min_width': + return PageManagementController::changeColsMinWidth($this->entityManager, $json); + case 'change_pagination_limit': + return PageManagementController::changePaginationLimit($this->entityManager, $json); + default: + return new JsonResponse(['success' => false, 'error' => 'bad parameters'], JsonResponse::HTTP_BAD_REQUEST); // code 400 + } + } + + else{ + return new JsonResponse(['success' => false, 'error' => 'bad parameters'], JsonResponse::HTTP_BAD_REQUEST); // code 400 + } + } + + /* -- upload avec FormData OU formulaire HTML avec fichier -- */ + elseif(str_starts_with($this->request->headers->get('Content-Type'), 'multipart/form-data')){ // = $_SERVER['CONTENT_TYPE'] + // dans tinymce avec le plugin (bouton "insérer une image" de l'éditeur ou glisser-déposer) + if($this->request->query->get('action') === 'upload_image_tinymce'){ + return ImageUploadController::imageUploadTinyMce(); + } + // dans tinymce, des quatre méthodes: bouton "link", drag & drop, html, base64 + elseif($this->request->query->get('action') === 'upload_file_tinymce'){ + return FileUploadController::fileUploadTinyMce(); + } + elseif($this->request->query->has('head_foot_image')){ + return HeadFootController::uploadAsset($this->entityManager, $this->request->query->get('head_foot_image')); + } + + /* -- page Maintenance -- */ + elseif($this->request->query->get('action') === 'restore_database' && $this->request->request->get('hidden') === '' + && $this->request->files->has('uploaded_sql')){ + return MaintenanceController::downloadSQL($this->entityManager, $this->request); + } + else{ + // choix ici entre répondre en JSON ou par une redirection, choix du JSON pour pouvoir passer un message + return new JsonResponse(['success' => false, 'error' => 'bad parameters'], JsonResponse::HTTP_BAD_REQUEST); // code 400 + } + } + + /* -- formulaire HTML sans fichier -- */ + elseif($this->request->headers->get('Content-Type') === 'application/x-www-form-urlencoded'){ + if($this->request->query->get('action') === 'delete_article' && $this->request->query->has('id')){ + return ArticleController::deleteArticle($this->entityManager, $this->request); // version formulaire + } + + /* -- nouvelle page -- */ + elseif($this->request->request->get('page_name') !== null + && $this->request->request->get('page_name_path') !== null + && $this->request->request->get('page_location') !== null + && $this->request->request->get('page_description') !== null + && $this->request->request->get('new_page_hidden') === ''){ + return PageManagementController::newPage($this->entityManager, $this->request->request); + } + + /* -- suppression d'une page -- */ + elseif($this->request->request->get('page_id') !== null + && $this->request->request->get('submit_hidden') === ''){ + return PageManagementController::deletePage($this->entityManager, $this->request->request->get('page_id')); + } + + + /* -- mode Modification d'une page -- */ + // modification du chemins en snake_case + elseif($this->request->request->get('page_menu_path') !== null + && $this->request->request->get('page_id') !== null + && $this->request->request->get('page_name_path_hidden') === ''){ + return PageManagementController::updatePageMenuPath($this->entityManager, $this->request->request->get('page_menu_path')); + } + // ajout d'un bloc dans une page + elseif($this->request->request->get('bloc_title') !== null + && $this->request->request->get('bloc_select') !== null + && $this->request->request->get('bloc_title_hidden') === ''){ // contrôle anti-robot avec input hidden + return PageManagementController::addBloc($this->entityManager, $this->request); + } + // suppression d'un bloc de page + elseif($this->request->request->get('delete_bloc_id') !== null + && $this->request->request->get('delete_bloc_hidden') === ''){ // contrôle anti-robot avec input hidden + return PageManagementController::deleteBloc($this->entityManager, $this->request); + } + + + /* -- page Menu et chemins -- */ + // création d'une entrée de menu avec une URL + elseif($this->request->request->has("label_input") && $this->request->request->has("url_input") && $this->request->request->has("location")){ + return MenuAndPathsController::newUrlMenuEntry($this->entityManager); + } + // suppression d'une entrée de menu avec une URL + elseif($this->request->request->has('delete') && $this->request->request->has('x') && $this->request->request->has('y')){ // 2 params x et y sont là parce qu'on a cliqué sur une image + return MenuAndPathsController::deleteUrlMenuEntry($this->entityManager); + } + + + /* -- page Mon compte -- */ + elseif($this->request->query->get('action') === 'update_username'){ + return UserController::updateUsername($this->entityManager); + } + elseif($this->request->query->get('action') === 'update_password'){ + return UserController::updatePassword($this->entityManager); + } + + /* -- page Maintenance -- */ + elseif($this->request->query->get('action') === 'restore_database' && $this->request->get('hidden') === '' && $this->request->request->has('selected_sql')){ + return MaintenanceController::handleBackupSelection($this->entityManager, $this->request); + } + + // redirection page d'accueil + return new RedirectResponse((string)new URL(['error' => 'paramètres inconnus'])); + } + + // requêtes XMLHttpRequest + elseif($this->request->isXmlHttpRequest()){ + return new JsonResponse(['success' => false]); // noyer le poisson en laissant penser que le site gère les requêtes XHR + } + + // POST admin ne matchant pas + return new Response('bad parameters', Response::HTTP_BAD_REQUEST); // code 400 + } + + // POST non-admin ne matchant pas + return new Response('bad parameters', Response::HTTP_BAD_REQUEST); // code 400 + } + + // méthode HTTP inconnue + else{ + return new RedirectResponse((string)new URL(['error' => 'tu fais quoi là mec?'])); + } + } +} \ No newline at end of file diff --git a/src/Service/Security.php b/src/Service/Security.php new file mode 100644 index 0000000..0fbd8b7 --- /dev/null +++ b/src/Service/Security.php @@ -0,0 +1,112 @@ +1, // protection contre les élements et attributs dangereux + + // liste blanche d'éléments HTML + 'elements'=> 'h1, h2, h3, h4, h5, h6, p, br, s, em, span, strong, a, ul, ol, li, sup, sub, code, blockquote, div, pre, table, caption, colgroup, col, tbody, tr, th, td, figure, img, figcaption, iframe, small', + + // liste noire d'attributs HTML + 'deny_attribute'=> 'id, class' // on garde 'style' + ); + // faire qu'un certain élément puisse n'avoir que certains attributs, regarder la doc + private static $specHtmLawed = ''; + + // obtenir du HTML non dangereur sans appliquer htmlspecialchars + public static function secureHTML(string $chaine): string + { + return trim(htmLawed($chaine, self::$configHtmLawed, self::$specHtmLawed)); + } + + public static function secureFileName(string $chaine): string + { + // sécuriser un nom avec chemin avec basename? + //$chaine = basename($chaine); + + /* + - caractères interdits sous windows / \ : * ? " < > | + - mac autorise les / + - mac interdit : + - linux autorise tout sauf les / + - imagemagick ne supporte pas les : + + - 'espace' fonctionne + - / remplacé par firefox en : + - \ retire ce qui est devant le \ + - * fonctionne + - ? permet le téléchargement mais pas l'affichage + - " ne fonctionne pas, remplacé par %22, filtrer %22 + - < > fonctionnent + - | fonctionne + - = fonctionne, mais je filtre parce qu'on en trouve dans une URL + - ' ` fonctionnent + - % fonctionne + - (){}[] fonctionnent + - ^ fonctionne + - # ne fonctionne pas + - ~ fonctionne + - & fonctionne + - ^ pas encore testé + */ + + // => on remplace tout par des _ + // filtrer / et \ semble inutile + + /*$cibles = [' ', '/', '\\', ':', '*', '?', '<', '>', '|', '=', "'", '`', '"', '%22', '#']; + $chaine = str_replace($cibles, '_', $chaine); // nécéssite l'extension mbstring + $chaine = mb_strtolower($chaine); + return($chaine);*/ + + $chaine = preg_replace('/[^a-zA-Z0-9_-]/', '_', $chaine); // ne garder que les lettres, chiffres, tirets et underscores + $chaine = preg_replace('/_+/', '_', $chaine); // doublons d'underscores + return trim($chaine, '_'); + + // les problèmes avec \ persistent !! + // => javascript + // malheureusement document.getElementById('upload').files[0].name = chaine; ne marche pas! interdit! + // javascript ne doit pas pouvoir accéder au système de fichiers + // solutions: + // - au lieu de fournir une chaine (le chemin du fichier), donner un objet à files[0].name + // - créer une copie du fichier et l'envoyer à la place + // - envoyer le fichier en AJAX + // - envoyer le nom du fichier à part puis renommer en PHP + } +} + +// lien sans http:// +function fixLinks($data) +{ + // 1/ + // si une adresse est de type "domaine.fr" sans le http:// devant, le comportement des navigateurs est de rechercher un fichier comme si mon adresse commençait par file:// + // tomber ainsi sur une page d'erreur est parfaitement déroutant + + // regex pour détecter les balises
et ajouter http:// au début des liens si nécessaire + $pattern = '#(]+href=")((?!https?://)[^>]+>)#'; + //$data = preg_replace($pattern, '$1http://$2', $data); + + // 2/ + // cas où la regex fait mal son boulot: + // l'erreur 404 est gérée par le .htaccess + // et le visiteur est redirigé à la page "menu" + // (ça ne règle pas le problème mais c'est mieux) + + // 3/ + // quand l'éditeur est ouvert (avant de valider l'article), + // le lien qu'on vient de créer apparaît dans l'infobulle, + // cliquer dessus ouvre un onglet sur une erreur 404 + // solution partielle avec le .htaccess + // + // solution? fermer ce nouvel onglet avec echo ''; + // comment déclencher le JS? en faisant qu'une erreur 404 causée pour cette raison soit particulière? + + return($data); +} diff --git a/src/Service/URL.php b/src/Service/URL.php new file mode 100644 index 0000000..035f572 --- /dev/null +++ b/src/Service/URL.php @@ -0,0 +1,90 @@ +params = $gets; + if($anchor != ''){ + $this->setAnchor($anchor); + } + } + + // setters statiques + static public function setProtocol(string $protocol = 'http'): void + { + self::$protocol = $protocol === 'https' ? 'https://' : 'http://'; + } + static public function setPort(int|string $port = 80): void + { + if((int)$port === 443){ + self::$protocol = 'https://'; + self::$port = ''; + } + elseif((int)$port === 80){ + self::$protocol = 'http://'; + self::$port = ''; + } + else{ + self::$port = ':' . (string)$port; + } + } + static public function setHost(string $host): void + { + self::$host = $host; + } + static public function setPath(string $path): void + { + self::$path = '/' . ltrim($path, '/'); + } + + //setters normaux + public function addParams(array $gets): void + { + // array_merge est préféré à l'opérateur d'union +, si une clé existe déjà la valeur est écrasée + $this->params = array_merge($this->params, $gets); + } + public function setAnchor(string $anchor = ''): void + { + if($anchor != ''){ + $this->anchor = '#' . ltrim($anchor, '#'); + } + else{ + $this->anchor = ''; + } + } + + private function makeParams(): string + { + $output = ''; + $first = true; + + foreach($this->params as $key => $value) { + if($first){ + $output .= '?'; + $first = false; + } + else{ + $output .= '&'; + } + $output .= $key . '=' . $value; + } + return $output; + } + + public function __toString(): string + { + return self::$protocol . self::$host . self::$port . self::$path . $this->makeParams() . $this->anchor; + } +} \ No newline at end of file diff --git a/src/Service/User.php b/src/Service/User.php new file mode 100644 index 0000000..bd4c315 --- /dev/null +++ b/src/Service/User.php @@ -0,0 +1,25 @@ +createQuery("SELECT u FROM App\Entity\User u") + ->setMaxResults(1) + ->getOneOrNullResult()) + { + unset($_SESSION['user']); + return false; + } + else{ + return true; + } + } +} \ No newline at end of file diff --git a/src/Service/session.php b/src/Service/session.php new file mode 100644 index 0000000..39c6ba2 --- /dev/null +++ b/src/Service/session.php @@ -0,0 +1,94 @@ + 'session_invalide'])); + die; + } + + // MAJ de la session avec CERTAINES données + $_SESSION['user']['username'] = $user->getLogin(); + $_SESSION['user']['role'] = $user->getRole(); + + $is_admin = $user->getRole() === 'admin'; + } + + define('IS_ADMIN', $is_admin); + + // si on a un jour besoin d'une variable globale au lieu d'une constante + //$GLOBALS['is_admin'] = $is_admin; // version modifiable 1 + /*function isAdmin(): bool { // version modifiable 2 + return $_SESSION['user']['role'] ?? null === 'admin'; + }*/ + + + // => système de cache à ajouter pour ne pas lire la BDD à chaque fois + //remplacer ce qui est en haut + /*$user = $_SESSION['user'] ?? null; + if (!$user) { + // visiteur + } + // Vérification périodique (ex: toutes les 5 minutes) + if (time() - $user['last_check'] > 300) { + $user = UserController::getUserById($user['id'], $entityManager); + if (!$user) { + session_destroy(); + header('Location: /login.php'); + exit; + } + // cache pour ne pas avoir à lire la BDD à chaque page + $_SESSION['user'] = [ + 'id' => $user['id'], + 'role' => $user['role'], + 'username' => $user['username'], + 'last_check' => time() + ]; + $user = $_SESSION['user']; + } + $is_admin = ($user['role'] === 'admin');*/ + + + // améliorations possibles: ajouter expiration automatique + protection contre vol de session (IP / user-agent) sans casser ton app. +} + +// nettoyage complet +/*function cleanSession(){ + unset($_SESSION['user']); // mémoire vive + session_destroy(); // fichier côté serveur + setcookie('PHPSESSID', '', time() - 86400, '/'); // cookie de session +}*/ \ No newline at end of file -- cgit v1.2.3