fix: corriger HMAC des logs + PDF style attestation + pagination glassmorphism + purge logs

src/Entity/AppLog.php:
- createdAt initialise avec date('Y-m-d H:i:s') au lieu de
  new DateTimeImmutable() pour tronquer les microsecondes
  (PostgreSQL arrondit les microsecondes differemment de PHP,
  ce qui causait des HMAC invalides a la relecture)
- generateHmac(): format Y-m-d\TH:i:s sans microsecondes

templates/admin/logs/pdf.html.twig (reecrit):
- Meme style que les attestations RGPD (templates/pdf/rgpd_*.html.twig):
  banniere gold avec logo, doc-type badge indigo, titre italic uppercase,
  info-grid avec cellules bordure indigo, tableaux data avec header dark,
  bloc HMAC avec encadre vert/rouge, footer SARL SITECONSEIL
- Logo passe au template via base64

src/Controller/Admin/LogsController.php:
- pdf(): injection de kernel.project_dir, chargement du logo en base64
  et passage au template

src/Command/PurgeEmailTrackingCommand.php:
- Ajout de la purge des AppLog de plus de 90 jours (meme seuil
  que EmailTracking), affiche le nombre de logs supprimes

templates/components/pagination/glass.html.twig (nouveau):
- Template de pagination KnpPaginator style glassmorphism:
  boutons glass avec hover, page active en glass-gold,
  fleches precedent/suivant

config/packages/knp_paginator.yaml (nouveau):
- Configuration KnpPaginator pour utiliser le template glass

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-02 23:15:00 +02:00
parent 9c1ea29505
commit d3e76f00de
6 changed files with 139 additions and 56 deletions

View File

@@ -0,0 +1,3 @@
knp_paginator:
template:
pagination: 'components/pagination/glass.html.twig'

View File

@@ -13,7 +13,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand( #[AsCommand(
name: 'app:email-tracking:purge', name: 'app:email-tracking:purge',
description: 'Supprime les enregistrements EmailTracking de plus de 90 jours', description: 'Supprime les EmailTracking et AppLog de plus de 90 jours',
)] )]
class PurgeEmailTrackingCommand extends Command class PurgeEmailTrackingCommand extends Command
{ {
@@ -45,7 +45,18 @@ class PurgeEmailTrackingCommand extends Command
$deleted = $qb->getQuery()->execute(); $deleted = $qb->getQuery()->execute();
$io->success("$deleted enregistrement(s) supprime(s)."); $io->text("EmailTracking : $deleted enregistrement(s) supprime(s).");
$deletedLogs = $this->em->createQueryBuilder()
->delete('App\Entity\AppLog', 'l')
->where('l.createdAt < :threshold')
->setParameter('threshold', $threshold)
->getQuery()
->execute();
$io->text("AppLog : $deletedLogs enregistrement(s) supprime(s).");
$io->success(($deleted + $deletedLogs).' enregistrement(s) supprime(s) au total.');
return Command::SUCCESS; return Command::SUCCESS;
} }

View File

@@ -6,6 +6,7 @@ use App\Entity\AppLog;
use App\Repository\AppLogRepository; use App\Repository\AppLogRepository;
use App\Service\AppLoggerService; use App\Service\AppLoggerService;
use Dompdf\Dompdf; use Dompdf\Dompdf;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Knp\Component\Pager\PaginatorInterface; use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -51,6 +52,7 @@ class LogsController extends AbstractController
AppLogRepository $repository, AppLogRepository $repository,
AppLoggerService $loggerService, AppLoggerService $loggerService,
Environment $twig, Environment $twig,
#[Autowire('%kernel.project_dir%')] string $projectDir,
): Response { ): Response {
$log = $repository->find($id); $log = $repository->find($id);
@@ -60,9 +62,13 @@ class LogsController extends AbstractController
$hmacValid = $loggerService->verifyLog($log); $hmacValid = $loggerService->verifyLog($log);
$logoPath = $projectDir.'/public/logo_facture.png';
$logo = file_exists($logoPath) ? 'data:image/png;base64,'.base64_encode((string) file_get_contents($logoPath)) : '';
$html = $twig->render('admin/logs/pdf.html.twig', [ $html = $twig->render('admin/logs/pdf.html.twig', [
'log' => $log, 'log' => $log,
'hmacValid' => $hmacValid, 'hmacValid' => $hmacValid,
'logo' => $logo,
]); ]);
$dompdf = new Dompdf(); $dompdf = new Dompdf();

View File

@@ -47,7 +47,7 @@ class AppLog
$this->action = $action; $this->action = $action;
$this->user = $user; $this->user = $user;
$this->ip = $ip; $this->ip = $ip;
$this->createdAt = new \DateTimeImmutable(); $this->createdAt = new \DateTimeImmutable(date('Y-m-d H:i:s'));
$this->hmac = $this->generateHmac($hmacSecret); $this->hmac = $this->generateHmac($hmacSecret);
} }
@@ -110,7 +110,7 @@ class AppLog
$this->action, $this->action,
$this->ip ?? '', $this->ip ?? '',
null !== $this->user ? (string) $this->user->getId() : '', null !== $this->user ? (string) $this->user->getId() : '',
$this->createdAt->format('Y-m-d\TH:i:s.u'), $this->createdAt->format('Y-m-d\TH:i:s'),
]); ]);
return hash_hmac('sha256', $payload, $secret); return hash_hmac('sha256', $payload, $secret);

View File

@@ -4,63 +4,103 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Log #{{ log.id }} - CRM SITECONSEIL</title> <title>Log #{{ log.id }} - CRM SITECONSEIL</title>
<style> <style>
body { font-family: Arial, Helvetica, sans-serif; font-size: 12px; color: #111827; margin: 40px; } @page { margin: 0; size: A4; }
h1 { font-size: 18px; margin-bottom: 4px; } body { font-family: Arial, Helvetica, sans-serif; font-size: 10px; color: #111827; margin: 0; padding: 0; }
.subtitle { font-size: 11px; color: #888; margin-bottom: 24px; } .banner { background: #fabf04; padding: 16px 32px; border-bottom: 1px solid #111827; }
table { width: 100%; border-collapse: collapse; margin-bottom: 20px; } .banner img { height: 36px; }
th { text-align: left; background-color: #111827; color: #fff; padding: 8px 12px; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; } .banner-title { font-size: 8px; font-weight: 700; text-transform: uppercase; letter-spacing: 2px; color: #111827; margin-top: 4px; opacity: 0.7; }
td { padding: 8px 12px; border-bottom: 1px solid #eee; font-size: 11px; } .container { padding: 24px 32px 16px; }
.label { color: #666; width: 150px; font-weight: bold; } .doc-type { display: inline-block; padding: 4px 12px; background: #4338ca; color: #fff; font-size: 8px; font-weight: 700; text-transform: uppercase; letter-spacing: 2px; margin-bottom: 8px; }
.value { color: #111; } h1 { font-size: 18px; font-weight: 700; text-transform: uppercase; letter-spacing: -0.5px; font-style: italic; margin: 0 0 4px 0; line-height: 1.1; }
.hmac-ok { color: #16a34a; font-weight: bold; } h2 { font-size: 11px; font-weight: 700; text-transform: uppercase; margin: 16px 0 4px; padding: 4px 10px; background: #fabf04; border: 1px solid #ddd; border-radius: 8px; display: inline-block; }
.hmac-ko { color: #dc2626; font-weight: bold; } .subtitle { font-size: 9px; color: #666; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px; }
.hmac-box { padding: 12px; border: 1px solid; margin-top: 20px; } .info-grid { display: table; width: 100%; margin-bottom: 12px; }
.hmac-box.ok { border-color: #16a34a; background-color: #f0fdf4; } .info-row { display: table-row; }
.hmac-box.ko { border-color: #dc2626; background-color: #fef2f2; } .info-grid .info-cell { display: table-cell; padding: 6px 10px; vertical-align: top; }
.footer { margin-top: 40px; font-size: 9px; color: #999; border-top: 1px solid #ddd; padding-top: 8px; } .info-label { font-size: 7px; font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px; color: #999; display: block; margin-bottom: 1px; }
.mono { font-family: monospace; font-size: 10px; word-break: break-all; } .info-value { font-size: 11px; font-weight: 700; color: #111827; }
.info-cell { border-left: 3px solid #4338ca; }
table.data { width: 100%; border-collapse: collapse; margin-top: 8px; font-size: 9px; border: 1px solid #ddd; border-radius: 8px; }
table.data th { background: #111827; color: #fff; padding: 4px 8px; text-align: left; text-transform: uppercase; font-size: 8px; font-weight: 700; letter-spacing: 0.5px; }
table.data td { padding: 5px 8px; border-bottom: 1px solid #e5e7eb; }
table.data tr:nth-child(even) td { background: #f9fafb; }
.hmac-box { margin: 16px 0; padding: 12px; border: 2px solid; border-radius: 8px; }
.hmac-box.ok { border-color: #16a34a; background: #f0fdf4; }
.hmac-box.ko { border-color: #dc2626; background: #fef2f2; }
.hmac-ok { color: #16a34a; font-weight: 700; font-size: 12px; }
.hmac-ko { color: #dc2626; font-weight: 700; font-size: 12px; }
.hmac { font-size: 7px; color: #aaa; word-break: break-all; margin: 8px 0; padding: 6px 8px; background: #f9fafb; border: 1px solid #e5e7eb; font-family: monospace; }
.contact-box { display: inline-block; padding: 6px 16px; background: #111827; color: #fff; font-weight: 700; text-transform: uppercase; font-size: 9px; letter-spacing: 1px; }
.mono { font-family: monospace; font-size: 9px; word-break: break-all; }
</style> </style>
</head> </head>
<body> <body>
<h1>Rapport de log #{{ log.id }}</h1> <div class="banner">
<p class="subtitle">CRM SITECONSEIL - Genere le {{ "now"|date("d/m/Y H:i:s") }}</p> {% if logo %}<img src="{{ logo }}" alt="CRM SITECONSEIL">{% endif %}
<div class="banner-title">SARL SITECONSEIL</div>
<table>
<tr><th colspan="2">Informations du log</th></tr>
<tr><td class="label">ID</td><td class="value">{{ log.id }}</td></tr>
<tr><td class="label">Date</td><td class="value">{{ log.createdAt|date('d/m/Y H:i:s') }}</td></tr>
<tr>
<td class="label">Utilisateur</td>
<td class="value">
{% if log.user %}
{{ log.user.fullName }} ({{ log.user.email }})
{% else %}
Non connecte
{% endif %}
</td>
</tr>
<tr><td class="label">Methode HTTP</td><td class="value">{{ log.method }}</td></tr>
<tr><td class="label">URL</td><td class="value mono">{{ log.url }}</td></tr>
<tr><td class="label">Route</td><td class="value mono">{{ log.route }}</td></tr>
<tr><td class="label">Action</td><td class="value">{{ log.action }}</td></tr>
<tr><td class="label">Adresse IP</td><td class="value mono">{{ log.ip }}</td></tr>
</table>
<div class="hmac-box {{ hmacValid ? 'ok' : 'ko' }}">
<p style="font-weight: bold; font-size: 13px; margin: 0 0 8px;">
{% if hmacValid %}
<span class="hmac-ok">&#10003; INTEGRITE VERIFIEE - DONNEES CONFORMES</span>
{% else %}
<span class="hmac-ko">&#10007; INTEGRITE COMPROMISE - DONNEES ALTEREES</span>
{% endif %}
</p>
<p style="font-size: 10px; color: #666; margin: 0 0 4px;">Signature HMAC SHA-256 :</p>
<p class="mono" style="margin: 0;">{{ log.hmac }}</p>
</div> </div>
<div class="container">
<span class="doc-type">Log d'activite</span>
<h1>Rapport de log #{{ log.id }}</h1>
<div class="subtitle">Trace d'activite &mdash; CRM SITECONSEIL</div>
<div class="footer"> <div class="info-grid">
<p>Document genere automatiquement par le CRM SITECONSEIL. Ce rapport atteste de l'integrite des donnees de log au moment de sa generation.</p> <div class="info-row">
<p>SARL SITECONSEIL - Siret : 418 664 058 - 27 rue Le Serurier, 02100 Saint-Quentin</p> <div class="info-cell" style="width: 25%;"><span class="info-label">ID</span><span class="info-value">#{{ log.id }}</span></div>
<div class="info-cell" style="width: 25%;"><span class="info-label">Date</span><span class="info-value">{{ log.createdAt|date('d/m/Y a H:i:s') }}</span></div>
<div class="info-cell" style="width: 25%;"><span class="info-label">Methode</span><span class="info-value">{{ log.method }}</span></div>
<div class="info-cell" style="width: 25%;"><span class="info-label">Adresse IP</span><span class="info-value">{{ log.ip ?? 'N/A' }}</span></div>
</div>
</div>
<h2>Utilisateur</h2>
<table class="data">
<thead><tr><th>Champ</th><th>Valeur</th></tr></thead>
<tbody>
{% if log.user %}
<tr><td>Nom complet</td><td><strong>{{ log.user.fullName }}</strong></td></tr>
<tr><td>Email</td><td class="mono">{{ log.user.email }}</td></tr>
<tr><td>ID utilisateur</td><td>{{ log.user.id }}</td></tr>
{% else %}
<tr><td>Utilisateur</td><td><em>Non connecte</em></td></tr>
{% endif %}
</tbody>
</table>
<h2>Requete</h2>
<table class="data">
<thead><tr><th>Champ</th><th>Valeur</th></tr></thead>
<tbody>
<tr><td>Methode HTTP</td><td><strong>{{ log.method }}</strong></td></tr>
<tr><td>URL</td><td class="mono">{{ log.url }}</td></tr>
<tr><td>Route</td><td class="mono">{{ log.route }}</td></tr>
<tr><td>Action</td><td><strong>{{ log.action }}</strong></td></tr>
<tr><td>Adresse IP</td><td class="mono">{{ log.ip ?? 'N/A' }}</td></tr>
<tr><td>Date et heure</td><td>{{ log.createdAt|date('d/m/Y H:i:s') }}</td></tr>
</tbody>
</table>
<h2>Integrite</h2>
<div class="hmac-box {{ hmacValid ? 'ok' : 'ko' }}">
{% if hmacValid %}
<span class="hmac-ok">&#10003; INTEGRITE VERIFIEE &mdash; DONNEES CONFORMES</span>
<p style="font-size: 9px; color: #666; margin: 4px 0 0;">Les donnees de ce log n'ont pas ete alterees depuis leur enregistrement.</p>
{% else %}
<span class="hmac-ko">&#10007; INTEGRITE COMPROMISE &mdash; DONNEES ALTEREES</span>
<p style="font-size: 9px; color: #991b1b; margin: 4px 0 0;">Attention : les donnees de ce log ont ete modifiees apres leur enregistrement initial.</p>
{% endif %}
</div>
<div class="hmac">HMAC-SHA256 : {{ log.hmac }}</div>
<div style="margin-top: 24px;">
<span class="contact-box">contact@siteconseil.fr</span>
</div>
<div style="margin-top: 16px; padding-top: 8px; border-top: 1px solid #ddd; font-size: 7px; color: #999; line-height: 1.6;">
SARL SITECONSEIL &mdash; Siret : 418 664 058 &mdash; TVA : FR05 418 664 058<br>
27 rue Le Serurier, 02100 Saint-Quentin, France &mdash; contact@siteconseil.fr<br>
<a href="https://www.siteconseil.fr" style="color: #999;">www.siteconseil.fr</a>
</div>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,23 @@
{% if pageCount > 1 %}
<nav class="flex items-center justify-center gap-1">
{% if previous is defined and previous %}
<a href="{{ path(route, query|merge({(pageParameterName): previous})) }}" class="glass px-3 py-2 text-xs font-bold text-gray-600 hover:bg-white/80 transition-all" rel="prev">&laquo;</a>
{% else %}
<span class="glass px-3 py-2 text-xs font-bold text-gray-300 cursor-default">&laquo;</span>
{% endif %}
{% for page in pagesInRange %}
{% if page != current %}
<a href="{{ path(route, query|merge({(pageParameterName): page})) }}" class="glass px-3 py-2 text-xs font-bold text-gray-600 hover:bg-white/80 transition-all">{{ page }}</a>
{% else %}
<span class="glass-gold px-3 py-2 text-xs font-bold text-gray-900">{{ page }}</span>
{% endif %}
{% endfor %}
{% if next is defined and next %}
<a href="{{ path(route, query|merge({(pageParameterName): next})) }}" class="glass px-3 py-2 text-xs font-bold text-gray-600 hover:bg-white/80 transition-all" rel="next">&raquo;</a>
{% else %}
<span class="glass px-3 py-2 text-xs font-bold text-gray-300 cursor-default">&raquo;</span>
{% endif %}
</nav>
{% endif %}