aboutsummaryrefslogtreecommitdiff
path: root/src/Service/Backup.php
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/Backup.php
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/Backup.php')
-rw-r--r--src/Service/Backup.php272
1 files changed, 272 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