From 3b369122645b07b290f7fcc7bccb4787745cd5ea Mon Sep 17 00:00:00 2001 From: polo Date: Tue, 24 Mar 2026 22:39:29 +0100 Subject: =?UTF-8?q?mode=20maintenance,=20optimisation=20moins=20de=20contr?= =?UTF-8?q?=C3=B4les=20en=20mode=20run,=20dossier=20service=20et=20d=C3=A9?= =?UTF-8?q?placement=20fichiers,=20sessions=20et=20entit=C3=A9=20User=20pr?= =?UTF-8?q?=C3=A9par=C3=A9es=20=C3=A0=20l'impl=C3=A9mentation=20hypoth?= =?UTF-8?q?=C3=A9tique=20des=20r=C3=B4les,=20entit=C3=A9=20AppMetadata,=20?= =?UTF-8?q?meilleure=20s=C3=A9curit=C3=A9=20de=20fillStartingDatabase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/service/AppMode.php | 56 ++++++ src/service/Captcha.php | 55 ++++++ src/service/Config.php | 86 +++++++++ src/service/EmailService.php | 102 +++++++++++ src/service/FormValidation.php | 215 +++++++++++++++++++++++ src/service/Installation.php | 209 ++++++++++++++++++++++ src/service/Security.php | 110 ++++++++++++ src/service/URL.php | 88 ++++++++++ src/service/router.php | 384 +++++++++++++++++++++++++++++++++++++++++ src/service/session.php | 86 +++++++++ 10 files changed, 1391 insertions(+) create mode 100644 src/service/AppMode.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/Security.php create mode 100644 src/service/URL.php create mode 100644 src/service/router.php create mode 100644 src/service/session.php (limited to 'src/service') diff --git a/src/service/AppMode.php b/src/service/AppMode.php new file mode 100644 index 0000000..60b58bd --- /dev/null +++ b/src/service/AppMode.php @@ -0,0 +1,56 @@ +getRepository(AppMetadata::class)->find('mode'); + if(!$metadata){ + self::$mode = 'maintenance'; + } + else{ + self::$mode = $metadata->getValue(); + } + } + + public static function is(string $mode): bool + { + return self::$mode === $mode; + } + + public static function get(): string + { + return self::$mode; + } + + public static function set(EntityManager $entityManager, string $mode): void + { + self::$mode = $mode; + + $metadata = $entityManager->find(AppMetadata::class, 'mode'); + if($metadata){ + $metadata->setValue($mode); + } + else{ + $metadata = new AppMetadata('mode', $mode); + $entityManager->persist($metadata); + } + $entityManager->flush(); + + /*self::$data = [ + 'mode' => $mode, + 'since' => (new DateTimeImmutable())->format('c'), + 'by' => $by, + ];*/ + } +} \ No newline at end of file diff --git a/src/service/Captcha.php b/src/service/Captcha.php new file mode 100644 index 0000000..d57f912 --- /dev/null +++ b/src/service/Captcha.php @@ -0,0 +1,55 @@ + on pourrait appliquer le pattern "singleton" (justification = le captcha devient une sorte de ressource partagée) + +declare(strict_types=1); + +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..e59f728 --- /dev/null +++ b/src/service/Config.php @@ -0,0 +1,86 @@ +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..6f4e93d --- /dev/null +++ b/src/service/EmailService.php @@ -0,0 +1,102 @@ + 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; // une adresse bidon est donnée à setFrom() + $email_from_name = $form_data->getData()['email_from_name'] ?? Config::$email_from_name; // = site web + $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, Config::$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..4677bef --- /dev/null +++ b/src/service/FormValidation.php @@ -0,0 +1,215 @@ +data = $data; + $this->validation_strategy = $validation_strategy; + } + + public function validate(): bool + { + $this->errors = []; + + // pattern stratégie en une seule classe + switch($this->validation_strategy){ + // bloc formulaire de contact + case 'email_send': + $this->emailStrategy(); + break; + case 'email_params': // paramètrage en mode admin + $this->emailParamsStrategy(); + break; + + // formulaires pages spéciales + case 'create_user': + $this->createUserStrategy(); + break; + case 'connection': + $this->connectionStrategy(); + break; + case 'username_update': + $this->usernameUpdateStrategy(); + break; + case 'password_update': + $this->passwordUpdateStrategy(); + break; + + default: + http_response_code(500); // c'est un peu comme jeter une exception + echo json_encode(['success' => false, 'error' => 'server_error']); + die; + } + + $this->validated = true; + return empty($this->errors); + } + + public function getErrors(): array + { + return $this->errors; + } + + public function getField(string $field): string + { + return $this->validated ? $this->data[$field] : ''; + } + + // méthodes de validation + private function captchaValidate(bool $clean_session = true): void + { + $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){ + $error = 'error_non_valid_captcha'; + } + elseif($captcha_solution == 0){ // ne peut pas arriver, si? + $error = 'captcha_server_error'; + } + elseif($captcha_try !== $captcha_solution){ + $this->errors[] = 'bad_solution_captcha'; + } + } + + // 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 emailStrategy(): void + { + $this->captchaValidate(false); + + if(!isset($this->data['name']) || empty($this->data['name']) + || !isset($this->data['email']) || empty($this->data['email']) + || !isset($this->data['message']) || empty($this->data['message']) + || !isset($this->data['hidden']) || !empty($this->data['hidden'])){ + $this->errors[] = 'missing_fields'; + } + + elseif(!filter_var(trim($this->data['email']), FILTER_VALIDATE_EMAIL)){ + $this->errors[] = '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']); + } + private function emailParamsStrategy(): void + { + if(!isset($this->data['id'], $this->data['what_param'], $this->data['value'], $this->data['hidden']) + || !empty($this->data['hidden'])){ + $this->errors[] = 'missing_fields'; + } + + elseif($this->data['value'] !== ''){ + if(!in_array($this->data['what_param'], ['smtp_host', 'smtp_secure', 'smtp_username', 'smtp_password', 'email_dest'])){ + $this->errors[] = 'unknown_parameter'; + } + elseif($this->data['what_param'] === 'smtp_username' || $this->data['what_param'] === 'email_dest'){ + if(!filter_var($this->data['value'], FILTER_VALIDATE_EMAIL)){ + $this->errors[] = 'invalide_email_address'; + } + } + } + + // htmlspecialchars exécutés à l'affichage dans FormBuilder + } + private function createUserStrategy(): void + { + $this->captchaValidate(); + + // test mauvais paramètres + if(!isset($this->data['login']) || empty($this->data['login']) + || !isset($this->data['password']) || empty($this->data['password']) + || !isset($this->data['password_confirmation']) || empty($this->data['password_confirmation']) + || !isset($this->data['create_user_hidden']) || !empty($this->data['create_user_hidden'])) + { + $this->errors[] = 'bad_login_or_password'; + } + + if($this->data['password'] !== $this->data['password_confirmation']){ + $this->errors[] = 'different_passwords'; + } + + if($this->data['login'] !== self::removeSpacesTabsCRLF(htmlspecialchars($this->data['login'])) + || $this->data['password'] !== self::removeSpacesTabsCRLF(htmlspecialchars($this->data['password']))){ + $this->errors[] = 'forbidden_characters'; + } + } + private function connectionStrategy(): void + { + $this->captchaValidate(); + + if(!isset($this->data['login']) || empty($this->data['login']) + || !isset($this->data['password']) || empty($this->data['password']) + || !isset($this->data['connection_hidden']) || !empty($this->data['connection_hidden'])) + { + $this->errors[] = 'bad_login_or_password'; + } + } + private function usernameUpdateStrategy(): void + { + $this->captchaValidate(); + + if(!isset($this->data['login']) || empty($this->data['login']) + || !isset($this->data['password']) || empty($this->data['password']) + || !isset($this->data['new_login']) || empty($this->data['new_login']) + || !isset($this->data['modify_username_hidden']) || !empty($this->data['modify_username_hidden'])) + { + $this->errors[] = 'bad_login_or_password'; + } + + $new_login = self::removeSpacesTabsCRLF(htmlspecialchars($this->data['new_login'])); + if($new_login !== $this->data['new_login']){ + $this->errors[] = 'forbidden_characters'; + } + + if($this->data['login'] !== $_SESSION['user']['username']){ + $this->errors[] = 'bad_login_or_password'; + } + if($this->data['login'] === $new_login){ + $this->errors[] = 'same_username_as_before'; + } + } + private function passwordUpdateStrategy(): void + { + $this->captchaValidate(); + + if(!isset($this->data['login']) || empty($this->data['login']) + || !isset($this->data['password']) || empty($this->data['password']) + || !isset($this->data['new_password']) || empty($this->data['new_password']) + || !isset($this->data['modify_password_hidden']) || !empty($this->data['modify_password_hidden'])) + { + $this->errors[] = 'bad_login_or_password'; + } + + $new_password = self::removeSpacesTabsCRLF(htmlspecialchars($this->data['new_password'])); + if($new_password !== $this->data['new_password']){ + $this->errors[] = 'forbidden_characters'; + } + + if($this->data['login'] !== $_SESSION['user']['username']){ + $this->errors[] = 'bad_login_or_password'; + } + if($this->data['password'] === $new_password){ + $this->errors[] = 'same_password_as_before'; + } + } +} \ No newline at end of file diff --git a/src/service/Installation.php b/src/service/Installation.php new file mode 100644 index 0000000..5c2a901 --- /dev/null +++ b/src/service/Installation.php @@ -0,0 +1,209 @@ +l'extension " . $extension . ' 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; + } + 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 = 0700; + $droits_fichiers = 0600; + + if(!file_exists('user_data')){ + // créer le dossier user_data + mkdir('user_data/'); + chmod('user_data/', $droits_dossiers); + 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

+

Aide: "serveur web" se nomme "www-data" sur debian et ubuntu, il s\'appelera "http" sur d\'autres distributions.

'; + die; + } + + 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

+

Quand vous aurez terminé votre config.ini, donnez-lui par sécurité des droits 600.

'; + die; + } + else{ + // droits du config.ini + /*if(substr(sprintf('%o', fileperms('../config/config.ini')), -4) != 600){ + chmod('../config/config.ini', $droits_fichiers); + }*/ + + // tester les liens internes + // + + // le test de connexion à la BDD est dans le doctrine bootstrap + } + } + + + /* création d'un site minimal avec une page d'accueil à la toute 1ère visite du site, ne doit surtout pas être exécutée une seconde fois */ + + // protection 1 utilisé à chaque requête + static private function isFirstRun(EntityManager $entityManager): bool + { + $metadata = $entityManager->getRepository(AppMetadata::class)->find('installed'); + return !$metadata || $metadata->getValue() !== '1'; + } + + // protection 2, qui vérifie vraiment que les tables concernées sont vides + static private function areTablesEmpty(EntityManager $entityManager): bool + { + $empty = true; + $entities = ['Page', 'Node', 'NodeData']; + foreach($entities as $entity){ + $entity = 'App\Entity\\' . $entity; // nécéssaire quand on insère le nom avec une variable + + if($entityManager + ->createQuery("SELECT e FROM $entity e") + ->setMaxResults(1) + ->getOneOrNullResult()){ + $empty = false; + } + } + + // cas anormal détecté, on remet en place la clé "installed" + if(!$empty){ + self::preventReinstallation($entityManager); + } + + return $empty; + } + + // met en place la protection + static private function preventReinstallation(EntityManager $entityManager): void + { + $metadata = $entityManager->getRepository(AppMetadata::class)->find('installed'); + if($metadata){ + $metadata->setValue('1'); + } + else{ + $metadata = new AppMetadata('installed', '1'); + $entityManager->persist($metadata); + } + $entityManager->flush(); + } + + static public function fillStartingDatabase(EntityManager $entityManager): void + { + if(!Installation::isFirstRun($entityManager)){ + return; + } + + // la BDD n'est pas vierge, on ne touche à rien + if(!self::areTablesEmpty($entityManager)){ + return; + } + + /* -- table page -- */ + // paramètres: name_page, end_of_path, reachable, in_menu, hidden, position, parent + $accueil = new Page('Accueil', 'accueil', "Page d'accueil", true, true, false, 1, NULL); + $article = new Page('Article', 'article', "", true, false, false, NULL, NULL); + $connection = new Page('Connexion', 'connection', "Connexion", true, false, false, NULL, NULL); + $my_account = new Page('Mon compte', 'user_edit', "Mon compte", true, false, false, NULL, NULL); + $menu_paths = new Page("Menu et chemins", 'menu_paths', "Menu et chemins", true, false, false, NULL, NULL); + $menu_paths->addCSS('menu'); + $menu_paths->addJS('menu'); + $new_page = new Page('Nouvelle page', 'new_page', "Nouvelle page", true, false, false, NULL, NULL); + $new_page->addCSS('new_page'); + $new_page->addJS('new_page'); + $emails = new Page("Courriels", 'emails', "Consulter les courriels en base de données", true, false, false, NULL, NULL); + $emails->addCSS('show_emails'); + $emails->addJS('form'); + + /* -- table node -- */ + // paramètres: name_node, article_timestamp, attributes, position, parent, page, article + $head = new Node('head', 1, NULL, NULL, NULL); + $header = new Node('header', 2, NULL, NULL, NULL); + $nav = new Node('nav', 1, $header, NULL, NULL); + $main = new Node('main', 3, NULL, NULL, NULL); + $footer = new Node('footer', 4, NULL, NULL, NULL); + $breadcrumb = new Node('breadcrumb', 2, $header, NULL, NULL); + $login = new Node('login', 1, $main, $connection, NULL); + $user_edit = new Node('user_edit', 1, $main, $my_account, NULL); + $bloc_edit_menu = new Node('menu', 1, $main, $menu_paths, NULL); + $bloc_new_page = new Node('new_page', 1, $main, $new_page, NULL); + $bloc_emails = new Node('show_emails', 1, $main, $emails, NULL); + + /* -- table node_data -- */ + // paramètres: data, node, images + $head_data = new NodeData([], $head); + $header_data = new NodeData([], $header); + $footer_data = new NodeData([], $footer); + $emails_data = new NodeData([], $bloc_emails); + + /* -- table page -- */ + $entityManager->persist($accueil); + $entityManager->persist($article); + $entityManager->persist($connection); + $entityManager->persist($my_account); + $entityManager->persist($menu_paths); + $entityManager->persist($new_page); + $entityManager->persist($emails); + + /* -- table node -- */ + $entityManager->persist($head); + $entityManager->persist($header); + $entityManager->persist($nav); + $entityManager->persist($main); + $entityManager->persist($footer); + $entityManager->persist($breadcrumb); + $entityManager->persist($login); + $entityManager->persist($user_edit); + $entityManager->persist($bloc_edit_menu); + $entityManager->persist($bloc_new_page); + $entityManager->persist($bloc_emails); + + /* -- table node_data -- */ + $entityManager->persist($head_data); + $entityManager->persist($header_data); + $entityManager->persist($footer_data); + $entityManager->persist($emails_data); + + $entityManager->flush(); + + // empêcher la réutilisation de cette fonction + self::preventReinstallation($entityManager); + + // fin de l'installation + AppMode::set($entityManager, 'run'); + + // recharger la page? + //header('Location: ' . new URL); + } +} \ No newline at end of file diff --git a/src/service/Security.php b/src/service/Security.php new file mode 100644 index 0000000..356f4f4 --- /dev/null +++ b/src/service/Security.php @@ -0,0 +1,110 @@ +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..5bd2594 --- /dev/null +++ b/src/service/URL.php @@ -0,0 +1,88 @@ +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/router.php b/src/service/router.php new file mode 100644 index 0000000..fc6b028 --- /dev/null +++ b/src/service/router.php @@ -0,0 +1,384 @@ + 1er test, méthode http: GET, POST ou autre chose +=> 2ème test, type de contenu (méthode POST uniquement): +"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 +*/ + +declare(strict_types=1); + +if($request->getMethod() === 'GET'){ + // table "user" vide + if(!UserController::existUsers($entityManager)){ + require AbstractBuilder::VIEWS_PATH . 'user_create.php'; + die; + } + + // bouton déconnexion (méthode GET parce que l'utilisateur ne modifie plus de données à partir de là) + if($request->query->has('action') && $request->query->get('action') === 'deconnection'){ + UserController::disconnect($entityManager); + } + + // articles suivants + if($request->query->has('fetch') && $request->query->get('fetch') === 'next_articles'){ + ArticleController::fetch($entityManager, $request); + } + + // données du calendrier + // création du calendrier et changement de dates affichées (boutons flèches mais pas changement de vue) + if($request->query->has('action') && $request->query->get('action') === 'get_events' + && $request->query->has('start') && $request->query->has('end') && empty($request->getPayload()->all())) // getPayload ne récupère pas que des POST + { + CalendarController::getData($entityManager); + } + + // pages interdites + if(!IS_ADMIN && in_array(CURRENT_PAGE, ['menu_paths', 'new_page', 'user_edit', 'emails'])){ + header('Location: ' . new URL); + die; + } + + if(IS_ADMIN === true){ + // ... + } + + // construction d'une page + $response = (new ViewController)->buildView($entityManager, $request); // utilise Model + // parenthèses nécéssaires autour de l'instanciation pour PHP < 8.4 +} + + +elseif($request->getMethod() === 'POST'){ + /* -- contrôleurs appellables par tout le monde -- */ + + // table "user" vide + if(!UserController::existUsers($entityManager)){ + UserController::createAdminUser($entityManager); + } + + // requêtes JSON avec fetch() + if($_SERVER['CONTENT_TYPE'] === 'application/json') + { + $json = json_decode($request->getContent(), true); // = json_decode(file_get_contents('php://input'), true); + + if(isset($_GET['action'])) + { + // formulaire de contact + if($_GET['action'] === 'send_email'){ + ContactFormController::sendVisitorEmail($entityManager, $json); + } + } + } + + // envoi formulaire HTML + elseif($_SERVER['CONTENT_TYPE'] === 'application/x-www-form-urlencoded'){ + // tentative de connexion + if($request->query->has('action') && $request->query->get('action') === 'connection'){ + //$response = + UserController::connect($entityManager); + } + } + + + if(IS_ADMIN === true) + { + /* -- requêtes AJAX -- */ + + // requêtes JSON avec fetch() + if($_SERVER['CONTENT_TYPE'] === 'application/json') + { + $json = json_decode($request->getContent(), true); // = json_decode(file_get_contents('php://input'), true); + + if($request->query->has('action')) + { + /* -- manipulation des articles -- */ + if($_GET['action'] === 'editor_submit' && isset($json['id']) && isset($json['content'])){ + ArticleController::editorSubmit($entityManager, $json); + } + elseif($_GET['action'] === 'delete_article' && isset($json['id'])){ + $response = ArticleController::deleteArticle($entityManager, $json); // version AJAX + } + elseif($_GET['action'] === 'switch_positions' && isset($json['id1']) && isset($json['id2'])){ + ArticleController::switchPositions($entityManager, $json); + } + elseif($_GET['action'] === 'date_submit' && isset($json['id']) && isset($json['date'])){ + ArticleController::dateSubmit($entityManager, $json); + } + + /* -- bloc Formulaire -- */ + elseif($_GET['action'] === 'keep_emails'){ + ContactFormController::keepEmails($entityManager, $json); + } + elseif($_GET['action'] === 'set_retention_period'){ + ContactFormController::setEmailsRetentionPeriod($entityManager, $json); + } + elseif($_GET['action'] === 'set_email_param'){ + ContactFormController::setEmailParam($entityManager, $json); + } + elseif($_GET['action'] === 'test_email'){ + ContactFormController::sendTestEmail($entityManager, $json); + } + + /* -- page emails -- */ + elseif($_GET['action'] === 'delete_email'){ + ContactFormController::deleteEmail($entityManager, $json); + } + elseif($_GET['action'] === 'toggle_sensitive_email'){ + ContactFormController::toggleSensitiveEmail($entityManager, $json); + } + + /* -- upload d'image dans tinymce par copier-coller -- */ + // collage de HTML contenant une ou plusieurs balises + elseif($request->query->get('action') === 'upload_image_url'){ + ImageUploadController::uploadImageHtml(); + } + // collage d'une image (code base64 dans le presse-papier) non encapsulée dans du HTML + elseif($request->query->get('action') === 'upload_image_base64'){ + ImageUploadController::uploadImageBase64(); + } + + + /* -- requêtes spécifiques au calendrier -- */ + elseif($request->query->get('action') === 'new_event'){ + CalendarController::newEvent($json, $entityManager); + } + elseif($request->query->get('action') === 'update_event'){ + CalendarController::updateEvent($json, $entityManager); + } + elseif($request->query->get('action') === 'remove_event'){ + CalendarController::removeEvent($json, $entityManager); + } + else{ + echo json_encode(['success' => false]); + die; + } + } + + /* -- site entier (header, footer, favicon) -- */ + elseif($request->query->has('head_foot_text')){ + HeadFootController::setTextData($entityManager, $request->query->get('head_foot_text'), $json); + } + elseif($request->query->has('head_foot_social_check')){ + HeadFootController::displaySocialNetwork($entityManager, $request->query->get('head_foot_social_check'), $json); + } + + /* -- page Menu et chemins -- */ + elseif(isset($_GET['menu_edit'])) + { + // ne suit pas la règle, faire ça dans un contrôleur? + Model::$menu = new Menu($entityManager); // récupération des données + + // flèche gauche <=: position = position du parent + 1, parent = grand-parent, recalculer les positions + if($_GET['menu_edit'] === 'move_one_level_up' && isset($json['id'])){ + MenuAndPathsController::MoveOneLevelUp($entityManager, $json); + } + // flèche droite =>: position = nombre d'éléments de la fraterie + 1, l'élément précédent devient le parent + elseif($_GET['menu_edit'] === 'move_one_level_down' && isset($json['id'])){ + MenuAndPathsController::MoveOneLevelDown($entityManager, $json); + } + elseif($_GET['menu_edit'] === 'switch_positions' && isset($json['id1']) && isset($json['id2'])){ + MenuAndPathsController::switchPositions($entityManager, $json); + } + elseif($_GET['menu_edit'] === 'display_in_menu' && isset($json['id']) && isset($json['checked'])){ + MenuAndPathsController::displayInMenu($entityManager, $json); + } + elseif($_GET['menu_edit'] === 'url_edit' && isset($json['id']) && isset($json['field']) && isset($json['input_data'])){ + MenuAndPathsController::editUrl($entityManager, $json); + } + } + + /* -- mode Modification d'une page -- */ + // partie "page" + elseif(isset($_GET['page_edit'])) + { + // titre de la page + if($_GET['page_edit'] === 'page_title'){ + PageManagementController::setPageTitle($entityManager, $json); + } + // description dans les métadonnées + elseif($_GET['page_edit'] === 'page_description'){ + PageManagementController::setPageDescription($entityManager, $json); + } + } + + // partie "blocs" + elseif($request->query->has('bloc_edit')) + { + if($request->query->get('bloc_edit') === 'rename_page_bloc'){ + PageManagementController::renameBloc($entityManager, $json); + } + elseif($request->query->get('bloc_edit') === 'switch_blocs_positions'){ + PageManagementController::SwitchBlocsPositions($entityManager, $json); + } + elseif($request->query->get('bloc_edit') === 'change_articles_order'){ + PageManagementController::changeArticlesOrder($entityManager, $json); + } + elseif($request->query->get('bloc_edit') === 'change_presentation'){ + PageManagementController::changePresentation($entityManager, $json); + } + elseif($request->query->get('bloc_edit') === 'change_cols_min_width'){ + PageManagementController::changeColsMinWidth($entityManager, $json); + } + elseif($request->query->get('bloc_edit') === 'change_pagination_limit'){ + PageManagementController::changePaginationLimit($entityManager, $json); + } + } + } + + // upload avec FormData + elseif(strpos($_SERVER['CONTENT_TYPE'], 'multipart/form-data') !== false) + { + // dans tinymce avec le plugin (bouton "insérer une image" de l'éditeur ou glisser-déposer) + if($request->query->has('action') && $request->query->get('action') === 'upload_image_tinymce'){ + ImageUploadController::imageUploadTinyMce(); + } + // dans tinymce, des quatre méthodes: bouton "link", drag & drop, html, base64 + elseif($request->query->has('action') && $request->query->get('action') === 'upload_file_tinymce'){ + FileUploadController::fileUploadTinyMce(); + } + elseif($request->query->has('head_foot_image')){ + HeadFootController::uploadAsset($entityManager, $request->query->get('head_foot_image')); + } + } + + // requêtes XMLHttpRequest + elseif(isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest') + { + //echo "requête XMLHttpRequest reçue par le serveur"; + echo json_encode(['success' => false]); // noyer le poisson en laissant penser que le site gère les requêtes XHR + die; + } + + /* -- envoi formulaire HTML -- */ + elseif($_SERVER['CONTENT_TYPE'] === 'application/x-www-form-urlencoded') + { + if($request->query->has('action') && $request->query->get('action') === 'delete_article' && isset($_GET['id'])){ + $response = ArticleController::deleteArticle($entityManager, $_GET); // version formulaire + } + + /* -- nouvelle page -- */ + elseif(isset($_POST['page_name']) && $_POST['page_name'] !== null + && isset($_POST['page_name_path']) && $_POST['page_name_path'] !== null + && isset($_POST['page_location']) && $_POST['page_location'] !== null + && isset($_POST['page_description']) && $_POST['page_description'] !== null + && isset($_POST['new_page_hidden']) && $_POST['new_page_hidden'] === '') + { + PageManagementController::newPage($entityManager, $_POST); + } + + /* -- suppression d'une page -- */ + elseif(isset($_POST['page_id']) && $_POST['page_id'] !== null + && isset($_POST['submit_hidden']) && $_POST['submit_hidden'] === '') + { + PageManagementController::deletePage($entityManager); + } + + + /* -- mode Modification d'une page -- */ + + // modification du chemins en snake_case + elseif(isset($_POST['page_menu_path']) && $_POST['page_menu_path'] !== null + && isset($_POST['page_id']) && $_POST['page_id'] !== null + && isset($_POST['page_name_path_hidden']) && $_POST['page_name_path_hidden'] === '') + { + PageManagementController::updatePageMenuPath($entityManager); + } + // ajout d'un bloc dans une page + elseif(isset($_POST['bloc_title']) && $_POST['bloc_title'] !== null + && isset($_POST['bloc_select']) && $_POST['bloc_select'] !== null + && isset($_POST['bloc_title_hidden']) && $_POST['bloc_title_hidden'] === '') // contrôle anti-robot avec input hidden + { + PageManagementController::addBloc($entityManager); + } + // suppression d'un bloc de page + elseif(isset($_POST['delete_bloc_id']) && $_POST['delete_bloc_id'] !== null + && isset($_POST['delete_bloc_hidden']) && $_POST['delete_bloc_hidden'] === '') // contrôle anti-robot avec input hidden + { + PageManagementController::deleteBloc($entityManager); + } + + + /* -- page Menu et chemins -- */ + + // création d'une entrée de menu avec une URL + elseif(isset($_POST["label_input"]) && isset($_POST["url_input"]) && isset($_POST["location"])){ + MenuAndPathsController::newUrlMenuEntry($entityManager); + } + // suppression d'une entrée de menu avec une URL + elseif(isset($_POST['delete']) && isset($_POST['x']) && isset($_POST['y'])){ // 2 params x et y sont là parce qu'on a cliqué sur une image + MenuAndPathsController::deleteUrlMenuEntry($entityManager); + } + + + /* -- page Mon compte -- */ + elseif($request->query->has('action') && $request->query->get('action') === 'update_username') + { + UserController::updateUsername($entityManager); + } + elseif($request->query->has('action') && $request->query->get('action') === 'update_password') + { + UserController::updatePassword($entityManager); + } + + // redirection page d'accueil + else{ + header("Location: " . new URL(['error' => 'paramètres inconnus'])); + die; + } + } + // POST admin ne matchant pas + else{ + echo json_encode(['success' => false]); + die; + } + } + // POST non admin ne matchant pas + else{ + echo json_encode(['success' => false]); + die; + } +} + +// méthode inconnue +else{ + header("Location: " . new URL(['error' => 'tu fais quoi là mec?'])); + die; +} + + + +/* -- utilisation de la réponse -- */ +if(isset($response)){ + // cas gérés (d'autres sont à prévoir): mauvais id de la page article, accès page création d'article sans être admin + if($request->isMethod('GET') && $response->getStatusCode() == 302){ // 302 redirection temporaire + header('Location: ' . new URL(['page' => $_GET['from'] ?? ''])); + } + // redirection après traitement de formulaires HTTP + elseif($request->getMethod() === 'POST' && $_SERVER['CONTENT_TYPE'] === 'application/x-www-form-urlencoded'){ + $response_data = json_decode(($response)->getContent(), true); + $url = new URL(['page' => $_GET['from'] ?? '']); + $url->addParams(['success' => $response_data['success'], 'message' => $response_data['message']]); + header('Location: ' . $url); + } + // affichage d'une page OU requête AJAX + else{ + $response->send(); + } +} +// pas utilisation de RESPONSE (cas destiné à disparaître) +else{ + if($request->getMethod() === 'POST' && $_SERVER['CONTENT_TYPE'] === 'application/x-www-form-urlencoded'){ + header("Location: " . new URL(['error' => 'erreur côté serveur'])); + } + else{ + http_response_code(500); + echo "erreur côté serveur"; + } +} +//die; // inutile \ No newline at end of file diff --git a/src/service/session.php b/src/service/session.php new file mode 100644 index 0000000..57f2143 --- /dev/null +++ b/src/service/session.php @@ -0,0 +1,86 @@ + '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