diff --git a/.gitignore b/.gitignore index 0ee7cd1..7edaa75 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ backup/*.sql /public/site.*.webmanifest /public/sw.js ###< spomky-labs/pwa-bundle ### +/sauvegarde/*.zip diff --git a/ansible/playbook.yml b/ansible/playbook.yml index 4fd3acc..eb11add 100644 --- a/ansible/playbook.yml +++ b/ansible/playbook.yml @@ -207,6 +207,18 @@ become: false args: chdir: "{{ path }}" + - name: "Cron Task Search" + ansible.builtin.cron: + name: "Intranet Ludikevent - Search" + minute: "*/5" + job: "php {{ path }}/bin/console app:search" + user: root + - name: "Cron Task Search" + ansible.builtin.cron: + name: "Intranet Ludikevent - Backup" + minute: "*/5" + job: "php {{ path }}/bin/console app:backup" + user: root - name: Set correct permissions for Symfony cache and logs directories ansible.builtin.file: path: "{{ item }}" diff --git a/migrations/Version20260116094522.php b/migrations/Version20260116094522.php new file mode 100644 index 0000000..d5e087e --- /dev/null +++ b/migrations/Version20260116094522.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE backup (id SERIAL NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, status VARCHAR(255) NOT NULL, error_message TEXT DEFAULT NULL, size_mb DOUBLE PRECISION NOT NULL, PRIMARY KEY(id))'); + $this->addSql('COMMENT ON COLUMN backup.created_at IS \'(DC2Type:datetime_immutable)\''); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('DROP TABLE backup'); + } +} diff --git a/migrations/Version20260116094541.php b/migrations/Version20260116094541.php new file mode 100644 index 0000000..aae23ba --- /dev/null +++ b/migrations/Version20260116094541.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE backup ALTER size_mb DROP NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE backup ALTER size_mb SET NOT NULL'); + } +} diff --git a/sauvegarde/.gitignore b/sauvegarde/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/src/Command/BackupCommand.php b/src/Command/BackupCommand.php new file mode 100644 index 0000000..839005b --- /dev/null +++ b/src/Command/BackupCommand.php @@ -0,0 +1,161 @@ +title('Démarrage de la sauvegarde PostgreSQL'); + + // 1. Initialisation du log en base de données + $backupEntry = new Backup(); + $this->entityManager->persist($backupEntry); + $this->entityManager->flush(); + + $projectDir = $this->kernel->getProjectDir(); + $backupDir = $projectDir . '/sauvegarde'; + $sqlDumpPath = $projectDir . '/var/temp_db.sql'; + + try { + if (!is_dir($backupDir)) { + mkdir($backupDir, 0777, true); + } + + // 2. Extraction des paramètres DATABASE_URL + $dbUrl = parse_url($_ENV['DATABASE_URL'] ?? ''); + if (!$dbUrl) throw new \Exception("DATABASE_URL non configurée dans le .env"); + + $dbName = ltrim($dbUrl['path'], '/'); + $dbUser = $dbUrl['user'] ?? ''; + $dbPass = $dbUrl['pass'] ?? ''; + $dbHost = $dbUrl['host'] ?? '127.0.0.1'; + $dbPort = $dbUrl['port'] ?? 5432; + + // 3. Exécution du dump PostgreSQL + $io->text('Extraction des données via pg_dump...'); + $command = sprintf( + 'PGPASSWORD=%s pg_dump -h %s -p %s -U %s -F p %s > %s', + escapeshellarg($dbPass), + escapeshellarg($dbHost), + escapeshellarg((string)$dbPort), + escapeshellarg($dbUser), + escapeshellarg($dbName), + escapeshellarg($sqlDumpPath) + ); + + exec($command, $outputExec, $returnCode); + + if ($returnCode !== 0) { + throw new \Exception("Échec de pg_dump (Code erreur: $returnCode)"); + } + + // 4. Création de l'archive 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) { + $zip->addFile($sqlDumpPath, 'database.sql'); + $zip->close(); + } else { + throw new \Exception("Impossible d'écrire le fichier ZIP dans $backupDir"); + } + + // 5. Enregistrement du 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) { + // 6. Gestion de l'échec + $backupEntry->setStatus('ERROR'); + $backupEntry->setErrorMessage($e->getMessage()); + $io->error("Échec : " . $e->getMessage()); + } + + // On enregistre les résultats finaux en BDD + $this->entityManager->flush(); + + // 7. Envoi de la notification Mail + $statusEmoji = ($backupEntry->getStatus() === 'SUCCESS') ? '✅' : '❌'; + $this->mailer->send( + 'notification@siteconseil.fr', + 'Intranet Ludievent', + $statusEmoji . ' Sauvegarde - ' . date('d/m/Y'), + 'mails/backup_notification.twig', + ['backup' => $backupEntry] + ); + + // 8. Nettoyage final + if (file_exists($sqlDumpPath)) unlink($sqlDumpPath); + $this->cleanOldFiles($backupDir, $io); + + return Command::SUCCESS; + } + + private function cleanOldFiles(string $backupDir, SymfonyStyle $io): void + { + $io->text('Nettoyage des anciennes données (Fichiers et BDD)...'); + + // 1. Définition de la date limite (7 jours en arrière) + $retentionDays = 7; + $limitDate = new \DateTimeImmutable('-' . $retentionDays . ' days'); + $retentionTimestamp = time() - ($retentionDays * 24 * 60 * 60); + + // 2. Suppression des fichiers ZIP physiques + $files = glob($backupDir . '/*.zip'); + $deletedFiles = 0; + + foreach ($files as $file) { + if (filemtime($file) <= $retentionTimestamp) { + if (unlink($file)) { + $deletedFiles++; + } + } + } + + // 3. Suppression des logs en base de données via une requête DQL + // Cela évite de charger des milliers d'objets en mémoire + $query = $this->entityManager->createQuery( + 'DELETE FROM App\Entity\Backup b WHERE b.createdAt <= :limitDate' + )->setParameter('limitDate', $limitDate); + + $deletedRows = $query->execute(); + + // 4. Rapport dans la console + if ($deletedFiles > 0 || $deletedRows > 0) { + $io->note(sprintf( + "Nettoyage effectué :\n- Fichiers ZIP supprimés : %d\n- Historique BDD supprimé : %d entrées", + $deletedFiles, + $deletedRows + )); + } + } +} diff --git a/src/Controller/Dashboard/BackupController.php b/src/Controller/Dashboard/BackupController.php new file mode 100644 index 0000000..d8e3e57 --- /dev/null +++ b/src/Controller/Dashboard/BackupController.php @@ -0,0 +1,102 @@ +render('dashboard/backup.twig', [ + 'backups' => $backupRepository->findBy([], ['createdAt' => 'DESC']), + ]); + } + + #[Route(path: '/crm/sauvegarde/download/{id}', name: 'app_crm_backup_download', methods: ['GET'])] + public function download(Backup $backup): Response + { + $projectDir = $this->getParameter('kernel.project_dir'); + $fileName = sprintf('db_backup_%s.zip', $backup->getCreatedAt()->format('Y-m-d_H-i')); + $filePath = $projectDir . '/sauvegarde/' . $fileName; + + if (!file_exists($filePath)) { + // Utilisation de record(string, string) + $this->appLogger->record( + 'SECURITY_ALERT', + "Fichier de sauvegarde introuvable : $fileName (ID: {$backup->getId()})" + ); + + $this->addFlash('error', "Le fichier physique est introuvable sur le serveur."); + return $this->redirectToRoute('app_crm_backup'); + } + + $this->appLogger->record( + 'INFO', + "Téléchargement de la sauvegarde : $fileName" + ); + + $response = new BinaryFileResponse($filePath); + $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $fileName); + + return $response; + } + + #[Route(path: '/crm/sauvegarde/delete/{id}', name: 'app_crm_backup_delete', methods: ['GET'])] + public function delete(Backup $backup): Response + { + if (!$this->isGranted('ROLE_ROOT')) { + $this->appLogger->record( + 'SECURITY_ALERT', + "Tentative de suppression de sauvegarde sans droits ROOT (ID Backup: {$backup->getId()})" + ); + + $this->addFlash('error', "Accès refusé. Rôle ROOT requis."); + return $this->redirectToRoute('app_crm_backup'); + } + + $projectDir = $this->getParameter('kernel.project_dir'); + $fileName = sprintf('db_backup_%s.zip', $backup->getCreatedAt()->format('Y-m-d_H-i')); + $filePath = $projectDir . '/sauvegarde/' . $fileName; + + try { + if (file_exists($filePath)) { + unlink($filePath); + } + + $backupId = $backup->getId(); + $this->entityManager->remove($backup); + $this->entityManager->flush(); + + $this->appLogger->record( + 'SECURITY_ALERT', + "Suppression définitive de la sauvegarde ID: $backupId et du fichier $fileName" + ); + + $this->addFlash('success', "La sauvegarde a été supprimée."); + + } catch (\Exception $e) { + $this->appLogger->record( + 'ERROR', + "Erreur lors de la suppression de la sauvegarde : " . $e->getMessage() + ); + $this->addFlash('error', "Erreur lors de la suppression."); + } + + return $this->redirectToRoute('app_crm_backup'); + } +} diff --git a/src/Entity/Backup.php b/src/Entity/Backup.php new file mode 100644 index 0000000..46c6fe5 --- /dev/null +++ b/src/Entity/Backup.php @@ -0,0 +1,87 @@ +createdAt = new \DateTimeImmutable(); + $this->status = 'PENDING'; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getStatus(): ?string + { + return $this->status; + } + + public function setStatus(string $status): static + { + $this->status = $status; + + return $this; + } + + public function getErrorMessage(): ?string + { + return $this->errorMessage; + } + + public function setErrorMessage(?string $errorMessage): static + { + $this->errorMessage = $errorMessage; + + return $this; + } + + public function getSizeMb(): ?float + { + return $this->sizeMb; + } + + public function setSizeMb(float $sizeMb): static + { + $this->sizeMb = $sizeMb; + + return $this; + } +} diff --git a/src/Repository/BackupRepository.php b/src/Repository/BackupRepository.php new file mode 100644 index 0000000..1ab38b2 --- /dev/null +++ b/src/Repository/BackupRepository.php @@ -0,0 +1,43 @@ + + */ +class BackupRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Backup::class); + } + + // /** + // * @return Backup[] Returns an array of Backup objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('b') + // ->andWhere('b.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('b.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?Backup + // { + // return $this->createQueryBuilder('b') + // ->andWhere('b.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/templates/dashboard/backup.twig b/templates/dashboard/backup.twig new file mode 100644 index 0000000..4b4c532 --- /dev/null +++ b/templates/dashboard/backup.twig @@ -0,0 +1,121 @@ +{% extends 'dashboard/base.twig' %} + +{% block title %}Sauvegardes{% endblock %} + +{% block title_header %}Gestion des Sauvegardes{% endblock %} + +{% block actions %} +
+ Rétention : 7 Jours +
+{% endblock %} + +{% block body %} +
+ {# Orbes de lumière en arrière-plan pour l'effet de profondeur #} +
+
+ +
+

+ Historique des Sauvegardes +

+ + {# Conteneur principal Glassmorphism Sombre #} +
+ + + + + + + + + + + {% for backup in backups %} + + {# Identité #} + + + {# Statut en Français #} + + + {# Taille #} + + + {# Actions #} + + + {% else %} + + + + {% endfor %} + +
Identité de la SauvegardeÉtatTailleActions
+
+
+ + + +
+
+

Base de données SQL

+

Réalisé le {{ backup.createdAt|date('d/m/Y') }} à {{ backup.createdAt|date('H:i') }}

+
+
+
+
+ {% if backup.status == 'SUCCESS' %} +
+ + Saine +
+ {% else %} +
+ + Échec +
+ {% endif %} +
+
+ + {{ backup.sizeMb|default('0.00') }} MO + + +
+ {% if backup.status == 'SUCCESS' %} + + + + + Télécharger + + {% endif %} + + {% if is_granted('ROLE_ROOT') %} + + + + + + {% endif %} +
+
+
+ + + +

Historique vide

+
+
+
+
+
+{% endblock %} diff --git a/templates/dashboard/base.twig b/templates/dashboard/base.twig index 8012c87..52873a1 100644 --- a/templates/dashboard/base.twig +++ b/templates/dashboard/base.twig @@ -58,6 +58,7 @@
Gestion Admins Audit Logs + Sauvegarde
diff --git a/templates/dashboard/search.twig b/templates/dashboard/search.twig index 3d6da29..59174e4 100644 --- a/templates/dashboard/search.twig +++ b/templates/dashboard/search.twig @@ -2,63 +2,98 @@ {% block title %}Recherche : {{ query }}{% endblock %} -{% block body %} -
-
-
-

Recherche Multicritères

-

- Résultats pour "{{ query }}" -

-
-
- - {{ results|length }} correspondance(s) - -
-
+{% block title_header %}Moteur de Recherche{% endblock %} - {# Formulaire de mise à jour #} -
-
- - -
-
- - {% if results is not empty %} -
- {% for item in results %} -
-
-
- {{ item.initials }} -
-
- - {{ item.type }} - -

{{ item.title }}

-

{{ item.subtitle }}

-
-
- -
-
#{{ item.id }}
- - Accéder - -
-
- {% endfor %} -
- {% else %} -
-

Aucune donnée trouvée pour cette recherche.

-
- {% endif %} +{% block actions %} +
+ + {{ results|length }} Résultat(s) +
{% endblock %} + +{% block body %} +
+ {# Orbes décoratifs pour l'effet Glass #} +
+
+ +
+ + {# Barre de recherche flottante #} +
+
+
+ + + +
+ + +
+
+ + {% if results is not empty %} +
+ {% for item in results %} +
+ + {# Effet de lueur au survol #} +
+ +
+
+ {{ item.initials }} +
+ + {{ item.type }} + +
+ +
+

+ {{ item.title }} +

+

+ {{ item.subtitle }} +

+
+ +
+
+ Identifiant + #{{ item.id }} +
+ + Voir Fiche + + +
+
+ {% endfor %} +
+ {% else %} +
+
+ + + +
+

Aucun résultat trouvé

+

Désolé, nous n'avons trouvé aucune correspondance pour "{{ query }}".

+
+ {% endif %} +
+
+ + +{% endblock %} diff --git a/templates/mails/backup_notification.twig b/templates/mails/backup_notification.twig new file mode 100644 index 0000000..171cbf5 --- /dev/null +++ b/templates/mails/backup_notification.twig @@ -0,0 +1,39 @@ +{% extends 'mails/base.twig' %} + +{% block content %} + + + + Rapport de Sauvegarde + + + + Statut : {{ datas.backup.status == 'SUCCESS' ? 'OPÉRATION RÉUSSIE ✅' : 'ÉCHEC DÉTECTÉ ❌' }} + + + + + Date + {{ datas.backup.createdAt|date('d/m/Y H:i') }} + + {% if datas.backup.status == 'SUCCESS' %} + + Taille de l'archive + {{ datas.backup.sizeMb }} MB + + {% endif %} + + ID Log + #{{ datas.backup.id }} + + + + {% if datas.backup.status == 'ERROR' %} + + Message d'erreur :
+ {{ datas.backup.errorMessage }} +
+ {% endif %} +
+
+{% endblock %}