Files
ludikevent_crm/src/Command/BackupCommand.php
Serreau Jovann 492fd1b7e8 feat(Product): Ajoute la génération de slug pour les produits.
🐛 fix(ReserverController): Corrige la route de la sitemap.
♻️ refactor(SiteMapListener): Génère les URLs des produits dans la sitemap.
🔧 chore(ansible): Ajoute le dossier seo aux dossiers à sauvegarder.
2026-01-20 14:31:12 +01:00

176 lines
6.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Backup;
use App\Service\Mailer\Mailer;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\HttpKernel\KernelInterface;
use ZipArchive;
#[AsCommand(name: 'app:backup', description: 'Sauvegarde complète PostgreSQL + Images')]
class BackupCommand extends Command
{
public function __construct(
private readonly KernelInterface $kernel,
private readonly EntityManagerInterface $entityManager,
private readonly Mailer $mailer,
?string $name = null
) {
parent::__construct($name);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Démarrage de la sauvegarde PostgreSQL + Images');
$backupEntry = new Backup();
$this->entityManager->persist($backupEntry);
$this->entityManager->flush();
$projectDir = $this->kernel->getProjectDir();
$backupDir = $projectDir . '/sauvegarde';
$sqlDumpPath = $projectDir . '/var/temp_db.sql';
$imagesDir = $projectDir . '/public/images';
$pdfDir = $projectDir . '/public/pdf';
$seoDif = $projectDir . '/public/seo';
try {
if (!is_dir($backupDir)) {
mkdir($backupDir, 0777, true);
}
// 1. DUMP POSTGRESQL
$dbUrl = parse_url($_ENV['DATABASE_URL'] ?? '');
if (!$dbUrl) throw new \Exception("DATABASE_URL non configurée.");
$io->text('Extraction SQL via pg_dump...');
$command = sprintf(
'PGPASSWORD=%s pg_dump -h %s -p %s -U %s -F p %s > %s',
escapeshellarg($dbUrl['pass'] ?? ''),
escapeshellarg($dbUrl['host'] ?? '127.0.0.1'),
escapeshellarg((string)($dbUrl['port'] ?? 5432)),
escapeshellarg($dbUrl['user'] ?? ''),
escapeshellarg(ltrim($dbUrl['path'], '/')),
escapeshellarg($sqlDumpPath)
);
exec($command, $outputExec, $returnCode);
if ($returnCode !== 0) throw new \Exception("Échec pg_dump (Code: $returnCode)");
// 2. CRÉATION DU ZIP
$fileName = sprintf('db_backup_%s.zip', date('Y-m-d_H-i'));
$zipPath = $backupDir . '/' . $fileName;
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) {
// Ajout SQL
if (file_exists($sqlDumpPath)) {
$zip->addFile($sqlDumpPath, 'database.sql');
}
// Ajout Images Récursif
if (is_dir($imagesDir)) {
$io->text('Compression du dossier images...');
$this->addFolderToZip($imagesDir, $zip, 'images');
}
if (is_dir($pdfDir)) {
$io->text('Compression du dossier pdf...');
$this->addFolderToZip($pdfDir, $zip, 'pdf');
}
if (is_dir($seoDif)) {
$io->text('Compression du dossier seo...');
$this->addFolderToZip($seoDif, $zip, 'seo');
}
$zip->close();
} else {
throw new \Exception("Impossible d'initialiser le fichier ZIP.");
}
// 3. STATISTIQUES ET SUCCÈS
$fileSizeMb = round(filesize($zipPath) / (1024 * 1024), 2);
$backupEntry->setStatus('SUCCESS');
$backupEntry->setSizeMb($fileSizeMb);
$io->success("Sauvegarde réussie : $fileName ($fileSizeMb MB)");
} catch (\Exception $e) {
$backupEntry->setStatus('ERROR');
$backupEntry->setErrorMessage($e->getMessage());
$io->error("Échec : " . $e->getMessage());
}
$this->entityManager->flush();
// NOTIFICATION
$statusEmoji = ($backupEntry->getStatus() === 'SUCCESS') ? '✅' : '❌';
$this->mailer->send(
'notification@siteconseil.fr',
'Intranet Ludievent',
$statusEmoji . ' Sauvegarde - ' . date('d/m/Y'),
'mails/backup_notification.twig',
['backup' => $backupEntry]
);
if (file_exists($sqlDumpPath)) unlink($sqlDumpPath);
$this->cleanOldFiles($backupDir, $io);
return Command::SUCCESS;
}
/**
* Ajoute un dossier récursivement en corrigeant les séparateurs de dossiers
*/
private function addFolderToZip(string $folderPath, ZipArchive $zip, string $zipSubFolder): void
{
$files = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($folderPath, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($files as $file) {
// 1. On ignore les dossiers (déjà géré par LEAVES_ONLY mais par sécurité)
if ($file->isDir()) {
continue;
}
$filePath = $file->getRealPath();
$fileName = $file->getFilename();
// 2. EXCLUSION : On ignore les fichiers .gitignore
if ($fileName === '.gitignore') {
continue;
}
if ($filePath) {
// Calcul du chemin relatif interne (on force les '/' pour la compatibilité ZIP)
$relativeInZip = $zipSubFolder . '/' . str_replace('\\', '/', substr($filePath, strlen($folderPath) + 1));
$zip->addFile($filePath, $relativeInZip);
}
}
}
private function cleanOldFiles(string $backupDir, SymfonyStyle $io): void
{
$io->text('Nettoyage historique...');
$limitDate = new \DateTimeImmutable('-7 days');
$retentionTs = time() - (7 * 24 * 60 * 60);
foreach (glob($backupDir . '/*.zip') as $file) {
if (filemtime($file) <= $retentionTs) unlink($file);
}
$this->entityManager->createQuery(
'DELETE FROM App\Entity\Backup b WHERE b.createdAt <= :limitDate'
)->setParameter('limitDate', $limitDate)->execute();
}
}