diff options
| author | polo <ordipolo@gmx.fr> | 2026-10-23 00:47:32 +0200 |
|---|---|---|
| committer | polo <ordipolo@gmx.fr> | 2026-10-23 00:47:32 +0200 |
| commit | f27cca08ff786ceff1870147f6d6bad14d71117f (patch) | |
| tree | d36597a72025313d6faf4ed228755fa267ebe2f6 /src/Service/Backup.php | |
| parent | c7309f900ef05bd5cb313110ec159b761af394ec (diff) | |
| download | cms-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.php | 272 |
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 | |||
| 4 | declare(strict_types=1); | ||
| 5 | |||
| 6 | namespace App\Service; | ||
| 7 | |||
| 8 | use Doctrine\ORM\EntityManager; | ||
| 9 | use Symfony\Component\Process\Process; // protection injection dans le shell | ||
| 10 | |||
| 11 | class 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 | ||
