aboutsummaryrefslogtreecommitdiff
path: root/src/Service
diff options
context:
space:
mode:
authorpolo <ordipolo@gmx.fr>2026-10-23 00:47:32 +0200
committerpolo <ordipolo@gmx.fr>2026-10-23 00:47:32 +0200
commitf27cca08ff786ceff1870147f6d6bad14d71117f (patch)
treed36597a72025313d6faf4ed228755fa267ebe2f6 /src/Service
parentc7309f900ef05bd5cb313110ec159b761af394ec (diff)
downloadcms-f27cca08ff786ceff1870147f6d6bad14d71117f.tar.gz
cms-f27cca08ff786ceff1870147f6d6bad14d71117f.tar.bz2
cms-f27cca08ff786ceff1870147f6d6bad14d71117f.zip
dossiers avec majuscule, namespace pour toutes les classes, autoload psr-4, UserDataService::createZip déplacé dans Backup
Diffstat (limited to 'src/Service')
-rw-r--r--src/Service/Backup.php272
-rw-r--r--src/Service/Captcha.php57
-rw-r--r--src/Service/Config.php90
-rw-r--r--src/Service/EmailService.php106
-rw-r--r--src/Service/FormValidation.php216
-rw-r--r--src/Service/Installation.php109
-rw-r--r--src/Service/Router.php393
-rw-r--r--src/Service/Security.php112
-rw-r--r--src/Service/URL.php90
-rw-r--r--src/Service/User.php25
-rw-r--r--src/Service/session.php94
11 files changed, 1564 insertions, 0 deletions
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 @@
1<?php
2// src/Service/Backup.php
3
4declare(strict_types=1);
5
6namespace App\Service;
7
8use Doctrine\ORM\EntityManager;
9use Symfony\Component\Process\Process; // protection injection dans le shell
10
11class Backup
12{
13 static public string $backup_dir = '../var/backups';
14 static private int $amount_to_keep = 20;
15
16 static public function mySQLdump(EntityManager $entityManager, string $type): string
17 {
18 $date = new \DateTime;
19 $file_path = self::$backup_dir . '/' . Config::$database . '_' . $date->format('Y-m-d') . '_' . $type . '.sql';
20
21 // les versions de mysql sont comme ci: 8.0.36
22 // celles de mariadb sont comme ça: 10.11.6-MariaDB
23 $version = $entityManager->getConnection()->fetchOne('SELECT VERSION()');
24 $engine = stripos($version, 'mariadb') !== false ? 'mariadb-dump' : 'mysqldump';
25
26 $tmp = tempnam('../var', 'tmp_db_codes_'); // crée un fichier avec un nom aléatoire et des droits 600 (concurrence)
27 // attention, si le dossier de destination n'est pas disponible, le fichier est placé avec les fichiers temporaires
28 file_put_contents($tmp,
29 "[client]\n
30 user=" . Config::$user . "\n
31 password=" . Config::$password . "\n
32 host=" . Config::$db_host . "\n");
33
34 $command = new Process([
35 $engine,
36 '--defaults-extra-file=' . $tmp, // pour ne pas enregistrer les codes dans l'historique de la console ou dans les processus de l'OS
37 '--single-transaction',
38 '--quick', // évite d'exploser la RAM si beaucoup de données
39 '--result-file=' . $file_path,
40 Config::$database
41 ]);
42
43 try{
44 // unlink et chmod permettent que le serveur et l'utilisateur appelant bin/mysqldump.php réussissent
45 if(file_exists($file_path)){
46 unlink($file_path);
47 }
48 $command->mustRun(); // comme run() mais lance une ProcessFailedException
49
50 //$file_path = self::gzipCompress($file_path); // '.gz' ajouté à la fin
51
52 chmod($file_path, 0666);
53 return $file_path;
54 }
55 finally{
56 // exécuté même quand situé après "return"
57 unlink($tmp);
58 self::cleanBackups();
59 }
60 }
61
62 // compression gzip (gros gain de place sur le serveur), nécessite l'extension zlib
63 static public function gzipCompress(string $file_path): string
64 {
65 try{
66 file_put_contents(
67 $file_path . '.gz',
68 gzencode(file_get_contents($file_path), 5), // plus rapide que 9 et taille identique d'après mes essais
69 );
70 unlink($file_path);
71 $file_path .= '.gz';
72 }
73 finally{
74 return $file_path;
75 }
76 }
77
78 static public function getBackupList(): array
79 {
80 $files = scandir(Backup::$backup_dir); // affiche un warning si échoue (à cacher en prod)
81 if(!$files){
82 throw new \RuntimeException("Le serveur a rencontré une erreur:<br>Accès aux backups impossible faute de permissions.");
83 }
84
85 $backup_array = [];
86 foreach($files as $file){
87 if($file[0] === '.'){
88 continue;
89 }
90 $backup_array[] = $file;
91 }
92 return $backup_array;
93 }
94
95 static public function cleanBackups(): void
96 {
97 $files = glob(self::$backup_dir . '/*.sql');
98 //usort($files, fn($a, $b) => filemtime($b) <=> filemtime($a)); // filemtime = date de dernière modification
99 arsort($files);
100
101 // tri par nom de BDD puis par date
102 $sorted_files = [];
103 $list_by_database = []; // pour le nettoyage 2
104 foreach($files as $file){
105 $exploded = explode('_', basename($file));
106 $sorted_files[$exploded[0]][$exploded[1]][] = $file;
107 $list_by_database[$exploded[0]][] = $file;
108 }
109
110 $date = new \DateTime;
111 foreach($sorted_files as $db_name => $from_one_database){
112 // on garde une "quantité à garder" par BDD
113 if(count($from_one_database) > self::$amount_to_keep){
114 // nettoyage 1
115 foreach($from_one_database as $date_key => $with_same_date){
116 // pas touche à aujourd'hui
117 if($date_key != $date->format('Y-m-d')){
118 self::cleanBackupsByPriority($with_same_date);
119 }
120 }
121 // nettoyage 2
122 $files_to_delete = array_slice($list_by_database[$db_name], self::$amount_to_keep);
123 foreach($files_to_delete as $file){
124 unlink($file);
125 }
126 }
127 }
128 }
129
130 // conserver un seul backup par jour choisi dans cet ordre de préférence: console => before-restore => download => auto
131 // cet ordre correspond à: volonté de l'utilisateur => état du jour avant changement => volonté de changement => automatique sans contrôle
132 static private function cleanBackupsByPriority(array $files): void
133 {
134 $priorities = [
135 'console' => 1,
136 'before-restore' => 2,
137 'uploaded' => 3,
138 'auto' => 4,
139 ];
140 $best_priority = PHP_INT_MAX;
141
142 // recherche du fichier à conserver
143 $to_keep = null;
144 foreach($files as $file){
145 foreach($priorities as $keyword => $priority){
146 if(str_contains(basename($file), $keyword) && $priority < $best_priority){
147 $best_priority = $priority;
148 $to_keep = $file;
149 break;
150 }
151 }
152 }
153 // suppression des autres
154 foreach($files as $file){
155 if($file !== $to_keep){
156 unlink($file);
157 }
158 }
159 }
160
161 static public function restoreDatabase(EntityManager $entityManager, string $file_name, array $exclusion_list = []): void
162 {
163 // création d'un backup de sécurité non écrasable
164 $date = new \DateTime;
165 if(!file_exists(self::$backup_dir . '/' . Config::$database . '_' . $date->format('Y-m-d') . '_before-restore.sql')){
166 Backup::mySQLdump($entityManager, 'before-restore');
167 }
168
169 $version = $entityManager->getConnection()->fetchOne('SELECT VERSION()');
170 $engine = stripos($version, 'mariadb') !== false ? 'mariadb' : 'mysql';
171
172 $tables = self::getDBListOfTables($entityManager, $exclusion_list); // choisir les tables à effacer et restaurer, exclure "user"
173
174 $tmp_db_codes = tempnam('../var', 'tmp_db_codes_'); // crée un fichier avec un nom aléatoire et des droits 600 (concurrence)
175 file_put_contents($tmp_db_codes,
176 "[client]\n
177 user=" . Config::$user . "\n
178 password=" . Config::$password . "\n
179 host=" . Config::$db_host . "\n");
180
181 //$file_name = self::gzipExtract($file_name); // '.gz' ajouté à la fin
182
183 $command = new Process([
184 $engine, // mariadb ou mysql
185 '--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
186 Config::$database
187 ]);
188 $command->setInput(file_get_contents(Backup::$backup_dir . '/' . $file_name)); // l'entrée <
189
190 // utiliser une liste de tables à exclure dynamique
191 try{
192 // tout effacer
193 $tables_with_backquotes = array_map(fn($t) => '`' . $t . '`', $tables);
194 if(!empty($tables_with_backquotes)){
195 $sql = "SET FOREIGN_KEY_CHECKS=0; DROP TABLE " . implode(', ', $tables_with_backquotes) . "; SET FOREIGN_KEY_CHECKS=1;";
196 $entityManager->getConnection()->executeStatement($sql);
197 }
198
199 // copie des tables à ne pas restaurer
200 foreach($exclusion_list as $excluded){
201 $entityManager->getConnection()->executeStatement('RENAME TABLE `' . TABLE_PREFIX . $excluded . '` TO `' . TABLE_PREFIX . $excluded . '_dont_touch`;');
202 }
203
204 // restaurer
205 $command->mustRun(); // comme run() mais lance une ProcessFailedException
206
207 // remettre table user comme avant
208 foreach($exclusion_list as $excluded){
209 $entityManager->getConnection()->executeStatement('DROP TABLE `' . TABLE_PREFIX . $excluded . '`;');
210 $entityManager->getConnection()->executeStatement('RENAME TABLE `' . TABLE_PREFIX . $excluded . '_dont_touch` TO `' . TABLE_PREFIX . $excluded . '`;');
211 }
212 }
213 finally{
214 // exécuté même quand situé après "return"
215 unlink($tmp_db_codes);
216 }
217 }
218
219 static private function getDBListOfTables(EntityManager $entityManager, array $exclusion_list = []): array
220 {
221 $tables = $entityManager->getConnection()->createSchemaManager()->listTableNames();
222 foreach($tables as $key => $elem){
223 foreach($exclusion_list as $excluded){
224 if(!is_string($excluded)){ // cas pas du tout censé arriver!
225 throw new \LogicException("Un nom de table doit etre une chaîne de caractères.");
226 }
227 if($elem === TABLE_PREFIX . $excluded){
228 unset($tables[$key]);
229 break; // petite optimisation
230 }
231 }
232 }
233 return $tables;
234 }
235
236 static public function createZip(string $zip_name, array $source_directories, array $pattern_to_target_in_user_data = []): string
237 {
238 $file_path = '../var/' . $zip_name;
239 $Zip = new \ZipArchive();
240
241 if($Zip->open($file_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== TRUE){ // ou ZipArchive::FL_OVERWRITE, à comparer à ZipArchive::OVERWRITE
242 throw new \RuntimeException("Création ou ouverture du fichier demandé impossible à cause d'une erreur de permissions sur le serveur.");
243 }
244
245 // recherche récursive dans les dossiers dans $source_directories, c'est comme le paramètre "-r" dans la console
246 $counter = 0;
247 foreach($source_directories as $path){
248 $directory = new \RecursiveDirectoryIterator($path);
249 $iterator = new \RecursiveIteratorIterator($directory);
250
251 foreach($iterator as $info){
252 if($info->getFilename() != "." && $info->getFilename() != ".." && $info->getFilename() != '.htaccess'){ // chemins inutiles . et .. et le .htaccess
253 $Zip->addGlob($info->getPathname(), 0, array(''));
254 $counter++;
255 }
256 }
257 }
258 // recherche à la racine avec des pattern de noms de fichiers (optionnel)
259 foreach($pattern_to_target_in_user_data as $one_pattern){
260 $Zip->addGlob($one_pattern, 0, array(''));
261 }
262
263 $Zip->close();
264 if($counter > 0){
265 //chmod($file_path, 0666);
266 return $zip_name;
267 }
268 else{
269 throw new \RuntimeException("Téléchargement des fichiers impossible, aucun fichier n'a été trouvé sur le serveur.");
270 }
271 }
272} \ 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 @@
1<?php
2// src/Service/Captcha.php
3//
4// la solution est stockée dans une unique variable $_SESSION['captcha']
5// => on pourrait appliquer le pattern "singleton" (justification = le captcha devient une sorte de ressource partagée)
6
7declare(strict_types=1);
8
9namespace App\Service;
10
11class Captcha
12{
13 private int $a;
14 private int $b;
15
16 public function __construct(){
17 $this->a = rand(2, 9);
18 $this->b = rand(2, 9);
19 }
20
21 public function getA(): string
22 {
23 return $this->toLettersFrench($this->a);
24 }
25 public function getB(): string
26 {
27 return $this->toLettersFrench($this->b);
28 }
29 public function getSolution(): int
30 {
31 return ($this->a * $this->b);
32 }
33
34 private function toLettersFrench(int $number): string
35 {
36 return match($number){
37 2 => 'deux',
38 3 => 'trois',
39 4 => 'quatre',
40 5 => 'cinq',
41 6 => 'six',
42 7 => 'sept',
43 8 => 'huit',
44 9 => 'neuf',
45 default => '', // erreur
46 };
47 }
48
49 // (à déplacer dans FormValidation?)
50 static public function controlInput(string $input = '0'): int
51 {
52 // un POST est une chaîne qu'on doit convertir en nombre dans deux conditions:
53 // test de format: $input est un nombre
54 // test d'intégrité: supprimer les décimales avec (int) ne change pas la valeur du nombre
55 return is_numeric($input) && $input == (int)$input ? (int)$input : 0;
56 }
57} \ 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 @@
1<?php
2// src/Service/Config.php
3
4declare(strict_types=1);
5
6namespace App\Service;
7
8class Config
9{
10 // BDD
11 static public string $db_host = 'localhost';
12 static public string $database = '';
13 static public string $db_driver = 'pdo_mysql';
14 static public string $user = '';
15 static public string $password = '';
16 static public string $table_prefix = '';
17
18 // classe URL
19 static public string $protocol = 'http';
20 static public string $index_path = '';
21 static public string $port = '80';
22
23 // envoi e-mails
24 static public string $smtp_host = '';
25 static public string $smtp_username = '';
26 static public string $smtp_password = '';
27 static public string $smtp_secure = ''; // tls (smarttls) ou ssl (smtps) ou plain_text/chaine vide
28
29 // destination e-mails
30 static public string $email_from = ''; // correspondant affiché dans la boite de réception
31 static public string $email_from_name = 'site web';
32 static public string $email_dest = ''; // destinataire affiché dans la boite de réception
33 static public string $email_dest_name = 'moi';
34
35 // copier dans ce tableau les variables contenant des chemins
36 static private array $path_vars = [];
37
38 static public function load(string $file_path): void
39 {
40 if(file_exists($file_path))
41 {
42 // ce serait bien de gérer aussi les fichiers corrompus?
43 $raw_data = parse_ini_file($file_path);
44 self::hydrate($raw_data);
45 }
46 else
47 {
48 echo "<p>Le fichier config/config.ini n'existe pas ou n'est pas lisible.</p>";
49 }
50 define('TABLE_PREFIX', self::$table_prefix);
51 }
52
53 // renseigner les variables internes de Config
54 static private function hydrate(array $raw_data): void
55 {
56 foreach($raw_data as $field => $value)
57 {
58 if($value != '') // valeur par défaut
59 {
60 if(isset(self::$$field)) // le champ existe dans Config
61 {
62 // problème du slash à la fin du nom d'un dossier
63 $value = self::slashAtEndOfPath($field, $value);
64 self::$$field = $value;
65 }
66 else
67 {
68 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";
69 }
70 }
71 /*else
72 {
73 echo "debug: le champ " . $field . " est vide, la valeur par défaut " . self::$$field . " sera utilisée.\n";
74 }*/
75 }
76 }
77
78
79 // pour que les chemins finissent toujours par un /
80 static private function slashAtEndOfPath(string $field, string $value): string
81 {
82 foreach(self::$path_vars as $item)
83 {
84 if($field === $item){
85 return !str_ends_with($value, '/') ? $value . '/' : $value;
86 }
87 }
88 return $value;
89 }
90}
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 @@
1<?php
2// src/Service/EmailService.php
3
4declare(strict_types=1);
5
6namespace App\Service;
7
8use PHPMailer\PHPMailer\PHPMailer;
9//use PHPMailer\PHPMailer\Exception;
10use Doctrine\ORM\EntityManager;
11use App\Entity\Email;
12use App\Entity\EmailForm;
13
14class EmailService
15{
16 const KEEP_EMAILS_DEFAULT = false;
17
18 static public function send(EntityManager $entityManager, EmailForm $form_data, bool $test_email, string $name = '', string $email = '', string $message = ''): bool
19 {
20 $mail = new PHPMailer(true); // true => exceptions
21 $mail->CharSet = 'UTF-8';
22
23 $smtp_host = $form_data->getData()['smtp_host'] ?? Config::$smtp_host;
24 $smtp_secure = $form_data->getData()['smtp_secure'] ?? Config::$smtp_secure;
25 $smtp_username = $form_data->getData()['smtp_username'] ?? Config::$smtp_username;
26 $smtp_password = $form_data->getData()['smtp_password'] ?? Config::$smtp_password;
27
28 $email_from = $form_data->getData()['email_from'] ?? Config::$email_from; // adresse de l'expéditeur affichée
29 $email_from = empty($email_from) ? $smtp_username : $email_from; // si vide
30 $email_from_name = $form_data->getData()['email_from_name'] ?? Config::$email_from_name; // = nom de l'expéditeur affiché
31 $email_dest = $form_data->getData()['email_dest'] ?? Config::$email_dest;
32 $email_dest_name = $form_data->getData()['email_dest_name'] ?? Config::$email_dest_name; // = destinataire formulaire
33
34 try{
35 // Paramètres du serveur
36 $mail->isSMTP();
37 $mail->Host = $smtp_host;
38 $mail->SMTPAuth = true;
39 $mail->Port = 25;
40
41 if($mail->SMTPAuth){
42 $mail->Username = $smtp_username; // e-mail
43 $mail->Password = $smtp_password;
44 $mail->SMTPSecure = $smtp_secure; // tls (starttls) ou ssl (smtps)
45 if($mail->SMTPSecure === 'tls'){
46 $mail->Port = 587;
47 }
48 elseif($mail->SMTPSecure === 'ssl'){
49 $mail->Port = 465;
50 }
51 }
52 //var_dump($mail->smtpConnect());die; // test de connexion
53
54 // Expéditeur et destinataire
55 // $email_from, $email_from_name et $email_dest_name sont modifiables uniquement dans le config.ini pour l'instant
56 $mail->setFrom(strtolower($email_from), $email_from_name);
57 $mail->addAddress(strtolower($email_dest), $email_dest_name);
58
59 // Contenu
60 $mail->isHTML(true);
61 if($test_email){
62 $mail->Subject = "TEST d'un envoi d'e-mail depuis le site web";
63 }
64 else{
65 $mail->Subject = 'Message envoyé par: ' . $name . ' (' . $email . ') depuis le site web';
66 }
67 $mail->Body = $message;
68 $mail->AltBody = $message;
69
70 $mail->send();
71
72 // copie en BDD
73 if(!$test_email && ($form_data->getData()['keep_emails'] ?? self::KEEP_EMAILS_DEFAULT)){
74 $db_email = new Email($name, $email, $email_dest, $message, $form_data);
75 $entityManager->persist($db_email);
76 self::updateLastContactDate($entityManager, $email);
77 $entityManager->flush();
78 }
79
80 return true;
81 }
82 catch(\Exception $e){
83 echo "Le message n'a pas pu être envoyé. Erreur : {$e} <br> {$mail->ErrorInfo}";
84 return false;
85 }
86 }
87
88 static public function updateLastContactDate(EntityManager $entityManager, string $sender): void
89 {
90 foreach($entityManager->getRepository('App\Entity\Email')->findAll() as $email){
91 $email->getSenderAddress() === $sender ? $email->updateLastContactDate() : null;
92 }
93 }
94
95 // peut être appelée par bin/clean_emails_cron.php
96 static public function cleanEmails(EntityManager $entityManager): void
97 {
98 $emails = $entityManager->getRepository('App\Entity\Email')->findAll();
99 foreach($emails as $email){
100 if($email->getDeletionDate() < new \DateTime()){
101 $entityManager->remove($email);
102 }
103 }
104 $entityManager->flush();
105 }
106} \ 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 @@
1<?php
2// src/Service/FormValidation.php
3//
4// pattern strategy simplifié, permet évolution future vers le vrai pattern
5
6declare(strict_types=1);
7
8namespace App\Service;
9
10// classe spéciale permettant de bénéficier du typage de classe au lieu de passer une chaîne à FormValidation
11enum ValidationStrategy {
12 // bloc formulaire de contact
13 case EmailSend;
14 case EmailParam;
15 // formulaires pages spéciales
16 case CreateUser;
17 case Connection;
18 case UsernameUpdate;
19 case PasswordUpdate;
20
21 public function method(): string
22 {
23 return match($this){
24 self::EmailSend => 'emailSendStrategy',
25 self::EmailParam => 'emailParamStrategy',
26 self::CreateUser => 'createUserStrategy',
27 self::Connection => 'connectionStrategy',
28 self::UsernameUpdate => 'usernameUpdateStrategy',
29 self::PasswordUpdate => 'passwordUpdateStrategy'
30 };
31 }
32}
33
34class FormValidation
35{
36 private array $data; // tableau associatif (probablement $_POST)
37 private \Closure $callStrategy;
38 private ?string $error;
39
40 public function __construct(array $data, ValidationStrategy $strategy){
41 $this->data = $data;
42 $method = $strategy->method();
43 $this->callStrategy = fn() => $this->$method();
44 }
45
46 public function validate(): bool
47 {
48 $this->error = ($this->callStrategy)(); // appel de la callback
49 return empty($this->errors);
50 }
51
52 public function getError(): string
53 {
54 return $this->error;
55 }
56
57 public function getField(string $field): string
58 {
59 return $this->data[$field] ?? '';
60 }
61
62 // méthodes de validation
63 private function captchaValidate(bool $clean_session = true): ?string
64 {
65 $captcha_solution = (isset($_SESSION['captcha']) && is_int($_SESSION['captcha'])) ? $_SESSION['captcha'] : 0;
66 $captcha_try = isset($this->data['captcha']) ? Captcha::controlInput($this->data['captcha']) : 0;
67 if($clean_session){
68 unset($_SESSION['captcha']);
69 }
70
71 if($captcha_try == 0){
72 return 'error_non_valid_captcha';
73 }
74 elseif($captcha_solution == 0){ // ne peut pas arriver, si?
75 return 'captcha_server_error';
76 }
77 elseif($captcha_try !== $captcha_solution){
78 return 'bad_solution_captcha';
79 }
80 return null;
81 }
82
83 // erreurs à la création des mots de passe
84 static private function removeSpacesTabsCRLF(string $chaine): string
85 {
86 $cibles = [' ', "\t", "\n", "\r"]; // doubles quotes !!
87 return(str_replace($cibles, '', $chaine));
88 }
89
90
91 // stratégies
92 private function emailSendStrategy(): ?string
93 {
94 if($error = $this->captchaValidate(false)){
95 return $error;
96 }
97
98 if(empty($this->data['name']) || empty($this->data['email']) || empty($this->data['message'])
99 || !isset($this->data['hidden']) || !empty($this->data['hidden'])){
100 return 'missing_fields';
101 }
102
103 elseif(!filter_var(trim($this->data['email']), FILTER_VALIDATE_EMAIL)){
104 return 'bad_email_address';
105 }
106
107 $this->data['name'] = htmlspecialchars(trim($this->data['name']));
108 $this->data['email'] = htmlspecialchars(trim($this->data['email']));
109 $this->data['message'] = htmlspecialchars($this->data['message']);
110
111 return null;
112 }
113 private function emailParamStrategy(): ?string
114 {
115 if(empty($this->data['id']) || empty($this->data['what_param']) || empty($this->data['value'])
116 || !isset($this->data['hidden']) || !empty($this->data['hidden'])){
117 return 'missing_fields';
118 }
119
120 if(!in_array($this->data['what_param'], ['smtp_host', 'smtp_secure', 'smtp_username', 'smtp_password', 'email_dest'])){
121 return 'unknown_parameter';
122 }
123 elseif($this->data['what_param'] === 'smtp_username' || $this->data['what_param'] === 'email_dest'){
124 if(!filter_var($this->data['value'], FILTER_VALIDATE_EMAIL)){
125 return 'invalide_email_address';
126 }
127 }
128 return null;
129 }
130 private function createUserStrategy(): ?string
131 {
132 if($error = $this->captchaValidate()){
133 return $error;
134 }
135
136 // test mauvais paramètres
137 if(empty($this->data['login']) || empty($this->data['password']) || empty($this->data['password_confirmation'])
138 || !isset($this->data['create_user_hidden']) || !empty($this->data['create_user_hidden']))
139 {
140 return 'bad_login_or_password';
141 }
142
143 if($this->data['password'] !== $this->data['password_confirmation']){
144 return 'different_passwords';
145 }
146
147 if($this->data['login'] !== self::removeSpacesTabsCRLF(htmlspecialchars($this->data['login']))
148 || $this->data['password'] !== self::removeSpacesTabsCRLF(htmlspecialchars($this->data['password']))){
149 return 'forbidden_characters';
150 }
151 return null;
152 }
153 private function connectionStrategy(): ?string
154 {
155 if($error = $this->captchaValidate()){
156 return $error;
157 }
158
159 if(empty($this->data['login']) || empty($this->data['password'])
160 || !isset($this->data['connection_hidden']) || !empty($this->data['connection_hidden']))
161 {
162 return 'bad_login_or_password';
163 }
164 return null;
165 }
166 private function usernameUpdateStrategy(): ?string
167 {
168 if($error = $this->captchaValidate()){
169 return $error;
170 }
171
172 if(empty($this->data['login']) || empty($this->data['password']) || empty($this->data['new_login'])
173 || !isset($this->data['modify_username_hidden']) || !empty($this->data['modify_username_hidden']))
174 {
175 return 'bad_login_or_password';
176 }
177
178 $new_login = self::removeSpacesTabsCRLF(htmlspecialchars($this->data['new_login']));
179 if($new_login !== $this->data['new_login']){
180 return 'forbidden_characters';
181 }
182
183 if($this->data['login'] !== $_SESSION['user']['username']){
184 return 'bad_login_or_password';
185 }
186 if($this->data['login'] === $new_login){
187 return 'same_username_as_before';
188 }
189 return null;
190 }
191 private function passwordUpdateStrategy(): ?string
192 {
193 if($error = $this->captchaValidate()){
194 return $error;
195 }
196
197 if(empty($this->data['login']) || empty($this->data['password']) || empty($this->data['new_password'])
198 || !isset($this->data['modify_password_hidden']) || !empty($this->data['modify_password_hidden']))
199 {
200 return 'bad_login_or_password';
201 }
202
203 $new_password = self::removeSpacesTabsCRLF(htmlspecialchars($this->data['new_password']));
204 if($new_password !== $this->data['new_password']){
205 return 'forbidden_characters';
206 }
207
208 if($this->data['login'] !== $_SESSION['user']['username']){
209 return 'bad_login_or_password';
210 }
211 if($this->data['password'] === $new_password){
212 return 'same_password_as_before';
213 }
214 return null;
215 }
216} \ 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 @@
1<?php
2// src/Service/Installation.php
3
4declare(strict_types=1);
5
6namespace App\Service;
7
8class Installation
9{
10 static public function phpDependancies(): void
11 {
12 $flag = false;
13 $extensions = ['pdo_mysql', 'mbstring', 'ctype', 'json', 'tokenizer', 'imagick', 'zip']; // les 5 premières sont pour doctrine
14 // ajouter plus tard zlib pour la compression des backups
15 foreach($extensions as $extension){
16 if(!extension_loaded($extension)){
17 echo("<p>L'extension <b>" . $extension . "</b> est manquante.</p>");
18 $flag = true;
19 }
20 }
21 if(!class_exists(\DOMDocument::class)){ // théoriquement plus fiable que extension_loaded()
22 echo("<p>L'extension <b>dom</b> est manquante.</p>");
23 $flag = true;
24 }
25
26 /*if(!extension_loaded('imagick') && !extension_loaded('gd')){
27 echo("<p>il manque une de ces extensions au choix pour le traitement des images: <b>imagick</b> (de préférence) ou <b>gd</b>.</p>");
28 $flag = true;
29 }*/
30 // si imagick n'est pas disponible, essayer gd (reste encore à coder)
31
32 if($flag){
33 echo '<p>Réalisez les actions nécéssaires sur le serveur ou contactez l\'administrateur du site.<br>
34 Quand le problème sera résolu, il vous suffira de <a href="#">recharger la page<a>.</p>';
35 die;
36 }
37 }
38
39 static public function checkFilesAndFoldersRights(): void
40 {
41 // -- droits des fichiers et dossiers --
42 $droits_dossiers = 0777;
43
44 $flag = false;
45 if(!file_exists('user_data')){
46 try{
47 mkdir('user_data/');
48 chmod('user_data/', $droits_dossiers);
49 }
50 catch(\Exception $e){
51 echo '<p style="color: red;">Le dossier public/user_data introuvable et le serveur n\'a pas la permission de le créer.<br>
52 Pour faire ça bien:<br>sudo -u "serveur web" mkdir /chemin/du/site/public/user_data</p>';
53 echo $e;
54 $flag = true;
55 }
56 }
57 if(!file_exists('../var')){
58 try{
59 mkdir('../var');
60 chmod('../var', $droits_dossiers);
61 }
62 catch(\Exception $e){
63 echo $e;
64 $flag = true;
65 }
66 }
67 if(!file_exists('../var/backups')){
68 try{
69 mkdir('../var/backups');
70 chmod('../var/backups', $droits_dossiers); // autoriser à la fois le serveur et les scripts dans bin/
71 }
72 catch(\Exception $e){
73 echo $e;
74 $flag = true;
75 }
76 }
77
78 // droits 600 pour celui-ci
79 if(!file_exists('../config/config.ini')){
80 // aide à la création du config.ini
81 echo '<p>Le fichier config/config.ini est introuvable.</p>';
82 echo '<p>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.<br>
83 Un modèle est disponible, il s\'agit du fichier config/config-template.ini</p>
84 <p>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.</p>';
85 $flag = true;
86 }
87 /*else{
88 // propriétaire du fichier
89 if(fileowner('../config/config.ini') != posix_geteuid()){
90 echo "<p>le fichier config/config.ini n'appartient pas au serveur.</p>";
91 }
92 else{
93 // droits du config.ini
94 if(substr(sprintf('%o', fileperms('../config/config.ini')), -4) != 600){
95 echo '<p>Attention, le </p>';
96 //chmod('../config/config.ini', $droits_fichiers);
97 }
98 }
99 }*/
100 if($flag){
101 die;
102 }
103
104 // tester les liens internes
105 //
106
107 // le test de connexion à la BDD est dans le doctrine bootstrap
108 }
109} \ 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 @@
1<?php
2// src/Service/Router.php
3//
4/* fonctionnement:
5=> 1er test, méthode http GET? POST?
6=> 2ème test, type de contenu:
7"application/x-www-form-urlencoded" = formulaire
8"application/json" = requête AJAX avec fetch()
9"multipart/form-data" = upload d'image par tinymce
10$_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest' requête AJAX xhs, non utilisée
11=> 3ème test, comme le 2ème test mais uniquement si IS_ADMIN est vrai */
12
13/* classes de réponses pour les contrôleurs
14Response($html, Response::HTTP_OK) page html
15JsonResponse(['success' => true, 'data' => $data]) ajax
16RedirectResponse('index.php?page=login') redirection
17BinaryFileResponse($filePath) téléchargement
18StreamedResponse(function () {echo "ligne 1\n";echo "ligne 2\n";}) gros fichier */
19
20// relire ça à l'occaz:
21// https://symfony.com/doc/current/introduction/from_flat_php_to_symfony.html
22
23declare(strict_types=1);
24
25namespace App\Service;
26
27use Symfony\Component\HttpFoundation\Request;
28use Doctrine\ORM\EntityManager;
29use Symfony\Component\HttpFoundation\JsonResponse;
30use Symfony\Component\HttpFoundation\RedirectResponse;
31use Symfony\Component\HttpFoundation\Response;
32use App\Service\User;
33use App\View\AbstractBuilder;
34use App\Model\Model;
35use App\Model\Menu;
36use App\Controller\UserController;
37use App\Controller\ArticleController;
38use App\Controller\CalendarController;
39use App\Controller\MaintenanceController;
40use App\Controller\ViewController;
41use App\Controller\ContactFormController;
42use App\Controller\ImageUploadController;
43use App\Controller\HeadFootController;
44use App\Controller\MenuAndPathsController;
45use App\Controller\PageManagementController;
46use App\Controller\FileUploadController;
47
48class Router{
49 private Request $request;
50 private EntityManager $entityManager;
51 private string $route = ''; // défaut page d'accueil
52
53 public function __construct(Request $request, EntityManager $entityManager){
54 $this->request = $request;
55 $this->entityManager = $entityManager;
56
57 if(!User::existUsers($entityManager)){ // table "user" vide
58 $this->route = 'no_user';
59 }
60 }
61
62 public function dispatch(): Response
63 {
64 if($this->request->getMethod() === 'GET'){
65 // table "user" vide
66 if($this->route === 'no_user'){
67 ob_start();
68 require AbstractBuilder::VIEWS_PATH . 'user_create.php';
69 return new Response(ob_get_clean());
70 }
71
72 // bouton déconnexion (méthode GET parce que l'utilisateur ne modifie plus de données à partir de là)
73 if($this->request->query->get('action') === 'deconnection'){
74 return UserController::disconnect(); // retourne un RedirectResponse
75 }
76
77 // articles suivants
78 if($this->request->query->get('fetch') === 'next_articles'){
79 return ArticleController::fetch($this->entityManager, $this->request); // retourne un JsonResponse
80 }
81
82 // données du calendrier
83 // création du calendrier ou changement de dates affichées (boutons flèches mais pas changement de vue)
84 if($this->request->query->get('action') === 'get_events'
85 && $this->request->query->has('start') && $this->request->query->has('end') && empty($this->request->getPayload()->all())){ // getPayload ne récupère pas que des POST
86 return CalendarController::getData($this->entityManager);
87 }
88
89 // pages interdites
90 if(!IS_ADMIN && in_array(CURRENT_PAGE, ['menu_paths', 'new_page', 'user_edit', 'emails', 'maintenance'])){
91 return new RedirectResponse((string)new URL);
92 }
93
94 if(IS_ADMIN){
95 if($this->request->query->get('action') === 'get_mysqldump'){
96 return MaintenanceController::getLastDump($this->entityManager);
97 }
98 if($this->request->query->get('action') === 'get_all_media'){
99 return MaintenanceController::getAllMedia();
100 }
101 }
102
103 // affichage d'une page
104 return ViewController::getWebPage($this->entityManager, $this->request);
105 }
106
107
108 elseif($this->request->getMethod() === 'POST'){
109 /* -- contrôleurs appelables par tout le monde -- */
110
111 // table "user" vide
112 if($this->route === 'no_user'){
113 return UserController::createAdminUser($this->entityManager);
114 }
115
116 // requête JSON avec fetch()
117 if($this->request->headers->get('Content-Type') === 'application/json')
118 {
119 $json = json_decode($this->request->getContent(), true); // = json_decode(file_get_contents('php://input'), true);
120
121 // formulaire de contact
122 if($this->request->query->get('action') === 'send_email'){
123 return ContactFormController::sendVisitorEmail($this->entityManager, $json);
124 }
125 /*else{
126 return new JsonResponse(['success' => false, 'error' => 'tu fais quoi là mec?'], JsonResponse::HTTP_BAD_REQUEST);
127 }*/
128 }
129
130 // envoi formulaire HTML
131 elseif($this->request->headers->get('Content-Type') === 'application/x-www-form-urlencoded'){
132 // tentative de connexion
133 if($this->request->query->get('action') === 'connection'){
134 return UserController::connect($this->entityManager);
135 }
136 /*else{
137 return new RedirectResponse((string)new URL(['error' => 'tu fais quoi là mec?']));
138 }*/
139 }
140
141 if(IS_ADMIN){
142 /* -- requêtes AJAX -- */
143
144 // requêtes JSON avec fetch()
145 if($this->request->headers->get('Content-Type') === 'application/json'){
146 $json = json_decode($this->request->getContent(), true); // = json_decode(file_get_contents('php://input'), true);
147
148 if($this->request->query->has('action')){
149 /* -- manipulation des articles -- */
150 if($this->request->query->get('action') === 'editor_submit' && isset($json['id']) && isset($json['content'])){
151 return ArticleController::editorSubmit($this->entityManager, $json);
152 }
153 elseif($this->request->query->get('action') === 'delete_article' && isset($json['id'])){
154 return ArticleController::deleteArticle($this->entityManager, $this->request); // version AJAX
155 }
156 elseif($this->request->query->get('action') === 'switch_positions' && isset($json['id1']) && isset($json['id2'])){
157 return ArticleController::switchPositions($this->entityManager, $json);
158 }
159 elseif($this->request->query->get('action') === 'date_submit' && isset($json['id']) && isset($json['date'])){
160 return ArticleController::dateSubmit($this->entityManager, $json);
161 }
162
163 switch($this->request->query->get('action')){
164 /* -- bloc Formulaire -- */
165 case 'keep_emails':
166 return ContactFormController::keepEmails($this->entityManager, $json);
167 case 'set_retention_period':
168 return ContactFormController::setEmailsRetentionPeriod($this->entityManager, $json);
169 case 'set_email_param':
170 return ContactFormController::setEmailParam($this->entityManager, $json);
171 case 'test_email':
172 return ContactFormController::sendTestEmail($this->entityManager, $json);
173
174 /* -- page emails -- */
175 case 'delete_email':
176 return ContactFormController::deleteEmail($this->entityManager, $json);
177 case 'toggle_sensitive_email':
178 return ContactFormController::toggleSensitiveEmail($this->entityManager, $json);
179
180 /* -- upload d'image dans tinymce par copier-coller -- */
181 // collage de HTML contenant une ou plusieurs balises <img>
182 case 'upload_image_url':
183 return ImageUploadController::uploadImageHtml();
184 // collage d'une image (code base64 dans le presse-papier) non encapsulée dans du HTML
185 case 'upload_image_base64':
186 return ImageUploadController::uploadImageBase64();
187
188 /* -- requêtes spécifiques au calendrier -- */
189 case 'new_event':
190 return CalendarController::newEvent($json, $this->entityManager);
191 case 'update_event':
192 return CalendarController::updateEvent($json, $this->entityManager);
193 case 'remove_event':
194 return CalendarController::removeEvent($json, $this->entityManager);
195
196 /* -- mode maintenance -- */
197 case 'get_logs':
198 return MaintenanceController::getLogs($this->entityManager);
199 case 'erase_logs':
200 return MaintenanceController::eraseLogs($this->entityManager);
201
202 default:
203 return new JsonResponse(['success' => false, 'error' => 'bad parameters'], JsonResponse::HTTP_BAD_REQUEST);
204 }
205 }
206
207 /* -- site entier (header, footer, favicon) -- */
208 elseif($this->request->query->has('head_foot_text')){
209 return HeadFootController::setTextData($this->entityManager, $this->request->query->get('head_foot_text'), $json);
210 }
211 elseif($this->request->query->has('head_foot_social_check')){
212 return HeadFootController::displaySocialNetwork($this->entityManager, $this->request->query->get('head_foot_social_check'), $json);
213 }
214
215 /* -- page Menu et chemins -- */
216 elseif($this->request->query->has('menu_edit')){
217 // ne suit pas la règle, faire ça dans un contrôleur?
218 Model::$menu = new Menu($this->entityManager); // récupération des données
219
220 // flèche gauche <=: position = position du parent + 1, parent = grand-parent, recalculer les positions
221 if($this->request->query->get('menu_edit') === 'move_one_level_up' && isset($json['id'])){
222 return MenuAndPathsController::MoveOneLevelUp($this->entityManager, $json);
223 }
224 // flèche droite =>: position (léments de a fraterie + 1, l'élément précédent devient le parent
225 elseif($this->request->query->get('menu_edit') === 'move_one_level_down' && isset($json['id'])){
226 return MenuAndPathsController::MoveOneLevelDown($this->entityManager, $json);
227 }
228 elseif($this->request->query->get('menu_edit') === 'switch_positions' && isset($json['id1']) && isset($json['id2'])){
229 return MenuAndPathsController::switchPositions($this->entityManager, $json);
230 }
231 elseif($this->request->query->get('menu_edit') === 'display_in_menu' && isset($json['id']) && isset($json['checked'])){
232 return MenuAndPathsController::displayInMenu($this->entityManager, $json);
233 }
234 elseif($this->request->query->get('menu_edit') === 'url_edit' && isset($json['id']) && isset($json['field']) && isset($json['input_data'])){
235 return MenuAndPathsController::editUrl($this->entityManager, $json);
236 }
237 else{
238 return new JsonResponse(['success' => false, 'error' => 'bad parameters'], JsonResponse::HTTP_BAD_REQUEST); // code 400
239 }
240 }
241
242 /* -- mode Modification d'une page -- */
243 // partie "page"
244 elseif($this->request->query->has('page_edit')){
245 switch($this->request->query->get('page_edit')){
246 case 'page_title':
247 return PageManagementController::setPageTitle($this->entityManager, $json);
248 case 'page_description':
249 return PageManagementController::setPageDescription($this->entityManager, $json);
250 default:
251 return new JsonResponse(['success' => false, 'error' => 'bad parameters'], JsonResponse::HTTP_BAD_REQUEST); // code 400
252 }
253 }
254
255 // partie "blocs"
256 elseif($this->request->query->has('bloc_edit')){
257 switch($this->request->query->get('bloc_edit')){
258 case 'rename_page_bloc':
259 return PageManagementController::renameBloc($this->entityManager, $json);
260 case 'switch_blocs_positions':
261 return PageManagementController::SwitchBlocsPositions($this->entityManager, $json, $this->request->query->get('page'));
262 case 'change_articles_order':
263 return PageManagementController::changeArticlesOrder($this->entityManager, $json);
264 case 'change_presentation':
265 return PageManagementController::changePresentation($this->entityManager, $json);
266 case 'change_cols_min_width':
267 return PageManagementController::changeColsMinWidth($this->entityManager, $json);
268 case 'change_pagination_limit':
269 return PageManagementController::changePaginationLimit($this->entityManager, $json);
270 default:
271 return new JsonResponse(['success' => false, 'error' => 'bad parameters'], JsonResponse::HTTP_BAD_REQUEST); // code 400
272 }
273 }
274
275 else{
276 return new JsonResponse(['success' => false, 'error' => 'bad parameters'], JsonResponse::HTTP_BAD_REQUEST); // code 400
277 }
278 }
279
280 /* -- upload avec FormData OU formulaire HTML avec fichier -- */
281 elseif(str_starts_with($this->request->headers->get('Content-Type'), 'multipart/form-data')){ // = $_SERVER['CONTENT_TYPE']
282 // dans tinymce avec le plugin (bouton "insérer une image" de l'éditeur ou glisser-déposer)
283 if($this->request->query->get('action') === 'upload_image_tinymce'){
284 return ImageUploadController::imageUploadTinyMce();
285 }
286 // dans tinymce, des quatre méthodes: bouton "link", drag & drop, html, base64
287 elseif($this->request->query->get('action') === 'upload_file_tinymce'){
288 return FileUploadController::fileUploadTinyMce();
289 }
290 elseif($this->request->query->has('head_foot_image')){
291 return HeadFootController::uploadAsset($this->entityManager, $this->request->query->get('head_foot_image'));
292 }
293
294 /* -- page Maintenance -- */
295 elseif($this->request->query->get('action') === 'restore_database' && $this->request->request->get('hidden') === ''
296 && $this->request->files->has('uploaded_sql')){
297 return MaintenanceController::downloadSQL($this->entityManager, $this->request);
298 }
299 else{
300 // choix ici entre répondre en JSON ou par une redirection, choix du JSON pour pouvoir passer un message
301 return new JsonResponse(['success' => false, 'error' => 'bad parameters'], JsonResponse::HTTP_BAD_REQUEST); // code 400
302 }
303 }
304
305 /* -- formulaire HTML sans fichier -- */
306 elseif($this->request->headers->get('Content-Type') === 'application/x-www-form-urlencoded'){
307 if($this->request->query->get('action') === 'delete_article' && $this->request->query->has('id')){
308 return ArticleController::deleteArticle($this->entityManager, $this->request); // version formulaire
309 }
310
311 /* -- nouvelle page -- */
312 elseif($this->request->request->get('page_name') !== null
313 && $this->request->request->get('page_name_path') !== null
314 && $this->request->request->get('page_location') !== null
315 && $this->request->request->get('page_description') !== null
316 && $this->request->request->get('new_page_hidden') === ''){
317 return PageManagementController::newPage($this->entityManager, $this->request->request);
318 }
319
320 /* -- suppression d'une page -- */
321 elseif($this->request->request->get('page_id') !== null
322 && $this->request->request->get('submit_hidden') === ''){
323 return PageManagementController::deletePage($this->entityManager, $this->request->request->get('page_id'));
324 }
325
326
327 /* -- mode Modification d'une page -- */
328 // modification du chemins en snake_case
329 elseif($this->request->request->get('page_menu_path') !== null
330 && $this->request->request->get('page_id') !== null
331 && $this->request->request->get('page_name_path_hidden') === ''){
332 return PageManagementController::updatePageMenuPath($this->entityManager, $this->request->request->get('page_menu_path'));
333 }
334 // ajout d'un bloc dans une page
335 elseif($this->request->request->get('bloc_title') !== null
336 && $this->request->request->get('bloc_select') !== null
337 && $this->request->request->get('bloc_title_hidden') === ''){ // contrôle anti-robot avec input hidden
338 return PageManagementController::addBloc($this->entityManager, $this->request);
339 }
340 // suppression d'un bloc de page
341 elseif($this->request->request->get('delete_bloc_id') !== null
342 && $this->request->request->get('delete_bloc_hidden') === ''){ // contrôle anti-robot avec input hidden
343 return PageManagementController::deleteBloc($this->entityManager, $this->request);
344 }
345
346
347 /* -- page Menu et chemins -- */
348 // création d'une entrée de menu avec une URL
349 elseif($this->request->request->has("label_input") && $this->request->request->has("url_input") && $this->request->request->has("location")){
350 return MenuAndPathsController::newUrlMenuEntry($this->entityManager);
351 }
352 // suppression d'une entrée de menu avec une URL
353 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
354 return MenuAndPathsController::deleteUrlMenuEntry($this->entityManager);
355 }
356
357
358 /* -- page Mon compte -- */
359 elseif($this->request->query->get('action') === 'update_username'){
360 return UserController::updateUsername($this->entityManager);
361 }
362 elseif($this->request->query->get('action') === 'update_password'){
363 return UserController::updatePassword($this->entityManager);
364 }
365
366 /* -- page Maintenance -- */
367 elseif($this->request->query->get('action') === 'restore_database' && $this->request->get('hidden') === '' && $this->request->request->has('selected_sql')){
368 return MaintenanceController::handleBackupSelection($this->entityManager, $this->request);
369 }
370
371 // redirection page d'accueil
372 return new RedirectResponse((string)new URL(['error' => 'paramètres inconnus']));
373 }
374
375 // requêtes XMLHttpRequest
376 elseif($this->request->isXmlHttpRequest()){
377 return new JsonResponse(['success' => false]); // noyer le poisson en laissant penser que le site gère les requêtes XHR
378 }
379
380 // POST admin ne matchant pas
381 return new Response('bad parameters', Response::HTTP_BAD_REQUEST); // code 400
382 }
383
384 // POST non-admin ne matchant pas
385 return new Response('bad parameters', Response::HTTP_BAD_REQUEST); // code 400
386 }
387
388 // méthode HTTP inconnue
389 else{
390 return new RedirectResponse((string)new URL(['error' => 'tu fais quoi là mec?']));
391 }
392 }
393} \ 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<?php
2// src/Service/Security.php
3//
4// htmlawed nettoie les entrées de l'utilisateur, en particulier le html de l'éditeur
5
6declare(strict_types=1);
7
8namespace App\Service;
9
10class Security
11{
12 private static $configHtmLawed = array(
13 'safe'=>1, // protection contre les élements et attributs dangereux
14
15 // liste blanche d'éléments HTML
16 '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',
17
18 // liste noire d'attributs HTML
19 'deny_attribute'=> 'id, class' // on garde 'style'
20 );
21 // faire qu'un certain élément puisse n'avoir que certains attributs, regarder la doc
22 private static $specHtmLawed = '';
23
24 // obtenir du HTML non dangereur sans appliquer htmlspecialchars
25 public static function secureHTML(string $chaine): string
26 {
27 return trim(htmLawed($chaine, self::$configHtmLawed, self::$specHtmLawed));
28 }
29
30 public static function secureFileName(string $chaine): string
31 {
32 // sécuriser un nom avec chemin avec basename?
33 //$chaine = basename($chaine);
34
35 /*
36 - caractères interdits sous windows / \ : * ? " < > |
37 - mac autorise les /
38 - mac interdit :
39 - linux autorise tout sauf les /
40 - imagemagick ne supporte pas les :
41
42 - 'espace' fonctionne
43 - / remplacé par firefox en :
44 - \ retire ce qui est devant le \
45 - * fonctionne
46 - ? permet le téléchargement mais pas l'affichage
47 - " ne fonctionne pas, remplacé par %22, filtrer %22
48 - < > fonctionnent
49 - | fonctionne
50 - = fonctionne, mais je filtre parce qu'on en trouve dans une URL
51 - ' ` fonctionnent
52 - % fonctionne
53 - (){}[] fonctionnent
54 - ^ fonctionne
55 - # ne fonctionne pas
56 - ~ fonctionne
57 - & fonctionne
58 - ^ pas encore testé
59 */
60
61 // => on remplace tout par des _
62 // filtrer / et \ semble inutile
63
64 /*$cibles = [' ', '/', '\\', ':', '*', '?', '<', '>', '|', '=', "'", '`', '"', '%22', '#'];
65 $chaine = str_replace($cibles, '_', $chaine); // nécéssite l'extension mbstring
66 $chaine = mb_strtolower($chaine);
67 return($chaine);*/
68
69 $chaine = preg_replace('/[^a-zA-Z0-9_-]/', '_', $chaine); // ne garder que les lettres, chiffres, tirets et underscores
70 $chaine = preg_replace('/_+/', '_', $chaine); // doublons d'underscores
71 return trim($chaine, '_');
72
73 // les problèmes avec \ persistent !!
74 // => javascript
75 // malheureusement document.getElementById('upload').files[0].name = chaine; ne marche pas! interdit!
76 // javascript ne doit pas pouvoir accéder au système de fichiers
77 // solutions:
78 // - au lieu de fournir une chaine (le chemin du fichier), donner un objet à files[0].name
79 // - créer une copie du fichier et l'envoyer à la place
80 // - envoyer le fichier en AJAX
81 // - envoyer le nom du fichier à part puis renommer en PHP
82 }
83}
84
85// lien sans http://
86function fixLinks($data)
87{
88 // 1/
89 // 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://
90 // tomber ainsi sur une page d'erreur est parfaitement déroutant
91
92 // regex pour détecter les balises <a> et ajouter http:// au début des liens si nécessaire
93 $pattern = '#(<a[^>]+href=")((?!https?://)[^>]+>)#';
94 //$data = preg_replace($pattern, '$1http://$2', $data);
95
96 // 2/
97 // cas où la regex fait mal son boulot:
98 // l'erreur 404 est gérée par le .htaccess
99 // et le visiteur est redirigé à la page "menu"
100 // (ça ne règle pas le problème mais c'est mieux)
101
102 // 3/
103 // quand l'éditeur est ouvert (avant de valider l'article),
104 // le lien qu'on vient de créer apparaît dans l'infobulle,
105 // cliquer dessus ouvre un onglet sur une erreur 404
106 // solution partielle avec le .htaccess
107 //
108 // solution? fermer ce nouvel onglet avec echo '<SCRIPT>javascript:window.close()</SCRIPT>';
109 // comment déclencher le JS? en faisant qu'une erreur 404 causée pour cette raison soit particulière?
110
111 return($data);
112}
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 @@
1<?php
2// src/Service/URL.php
3
4declare(strict_types=1);
5
6namespace App\Service;
7
8class URL implements \Stringable
9{
10 static private string $protocol = 'http://';
11 static private string $host = '';
12 static private string $port;
13 static private string $path = '/index.php';
14 private array $params;
15 private string $anchor = '';
16
17 public function __construct(array $gets = [], string $anchor = ''){
18 $this->params = $gets;
19 if($anchor != ''){
20 $this->setAnchor($anchor);
21 }
22 }
23
24 // setters statiques
25 static public function setProtocol(string $protocol = 'http'): void
26 {
27 self::$protocol = $protocol === 'https' ? 'https://' : 'http://';
28 }
29 static public function setPort(int|string $port = 80): void
30 {
31 if((int)$port === 443){
32 self::$protocol = 'https://';
33 self::$port = '';
34 }
35 elseif((int)$port === 80){
36 self::$protocol = 'http://';
37 self::$port = '';
38 }
39 else{
40 self::$port = ':' . (string)$port;
41 }
42 }
43 static public function setHost(string $host): void
44 {
45 self::$host = $host;
46 }
47 static public function setPath(string $path): void
48 {
49 self::$path = '/' . ltrim($path, '/');
50 }
51
52 //setters normaux
53 public function addParams(array $gets): void
54 {
55 // array_merge est préféré à l'opérateur d'union +, si une clé existe déjà la valeur est écrasée
56 $this->params = array_merge($this->params, $gets);
57 }
58 public function setAnchor(string $anchor = ''): void
59 {
60 if($anchor != ''){
61 $this->anchor = '#' . ltrim($anchor, '#');
62 }
63 else{
64 $this->anchor = '';
65 }
66 }
67
68 private function makeParams(): string
69 {
70 $output = '';
71 $first = true;
72
73 foreach($this->params as $key => $value) {
74 if($first){
75 $output .= '?';
76 $first = false;
77 }
78 else{
79 $output .= '&';
80 }
81 $output .= $key . '=' . $value;
82 }
83 return $output;
84 }
85
86 public function __toString(): string
87 {
88 return self::$protocol . self::$host . self::$port . self::$path . $this->makeParams() . $this->anchor;
89 }
90} \ 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 @@
1<?php
2// src/Service/User.php
3
4declare(strict_types=1);
5
6namespace App\Service;
7
8use Doctrine\ORM\EntityManager;
9
10class User{
11 static public function existUsers(EntityManager $entityManager): bool
12 {
13 if(!$entityManager // table vide
14 ->createQuery("SELECT u FROM App\Entity\User u")
15 ->setMaxResults(1)
16 ->getOneOrNullResult())
17 {
18 unset($_SESSION['user']);
19 return false;
20 }
21 else{
22 return true;
23 }
24 }
25} \ 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 @@
1<?php
2// src/Service/session.php
3
4// à voir si c'est mieux avec:
5//use Symfony\Component\HttpFoundation\Session\Session;
6
7// note: session_regenerate_id(true) se trouve dans UserController::connect
8
9declare(strict_types=1);
10
11use Doctrine\ORM\EntityManager;
12use App\Service\URL;
13use App\Controller\UserController;
14
15function startSession(EntityManager $entityManager): void
16{
17 ini_set('session.cookie_samesite', 'Strict');
18 ini_set('session.cookie_httponly', 'On');
19 ini_set('session.use_strict_mode', 'On');
20 ini_set('session.cookie_secure', 'On');
21 session_start();
22 validateSession($entityManager);
23}
24
25function validateSession(EntityManager $entityManager): void
26{
27 if(defined('IS_ADMIN')){
28 return;
29 }
30
31 $is_admin = false;
32
33 if(isset($_SESSION['user']['id'])){
34 $user = UserController::getUserById($_SESSION['user']['id'], $entityManager);
35
36 // visiteur normal
37 if(!$user){
38 session_unset();
39 session_destroy();
40 header('Location: ' . new URL(['message' => 'session_invalide']));
41 die;
42 }
43
44 // MAJ de la session avec CERTAINES données
45 $_SESSION['user']['username'] = $user->getLogin();
46 $_SESSION['user']['role'] = $user->getRole();
47
48 $is_admin = $user->getRole() === 'admin';
49 }
50
51 define('IS_ADMIN', $is_admin);
52
53 // si on a un jour besoin d'une variable globale au lieu d'une constante
54 //$GLOBALS['is_admin'] = $is_admin; // version modifiable 1
55 /*function isAdmin(): bool { // version modifiable 2
56 return $_SESSION['user']['role'] ?? null === 'admin';
57 }*/
58
59
60 // => système de cache à ajouter pour ne pas lire la BDD à chaque fois
61 //remplacer ce qui est en haut
62 /*$user = $_SESSION['user'] ?? null;
63 if (!$user) {
64 // visiteur
65 }
66 // Vérification périodique (ex: toutes les 5 minutes)
67 if (time() - $user['last_check'] > 300) {
68 $user = UserController::getUserById($user['id'], $entityManager);
69 if (!$user) {
70 session_destroy();
71 header('Location: /login.php');
72 exit;
73 }
74 // cache pour ne pas avoir à lire la BDD à chaque page
75 $_SESSION['user'] = [
76 'id' => $user['id'],
77 'role' => $user['role'],
78 'username' => $user['username'],
79 'last_check' => time()
80 ];
81 $user = $_SESSION['user'];
82 }
83 $is_admin = ($user['role'] === 'admin');*/
84
85
86 // améliorations possibles: ajouter expiration automatique + protection contre vol de session (IP / user-agent) sans casser ton app.
87}
88
89// nettoyage complet
90/*function cleanSession(){
91 unset($_SESSION['user']); // mémoire vive
92 session_destroy(); // fichier côté serveur
93 setcookie('PHPSESSID', '', time() - 86400, '/'); // cookie de session
94}*/ \ No newline at end of file