Add email tracking, CSP reporting, security controllers and services

- EmailTracking entity + repository + pixel tracking controller
- CspReportController: filter noise, alert on real violations
- RedirectController: external redirect warning page
- UnsubscribeManager: HMAC-based unsubscribe with hashed storage
- MailerService: rewrite with S/MIME, tracking, unsubscribe headers
- ViteAssetExtension: add nonce CSP via Nelmio, isMobile
- composer: add stripe/stripe-php, mobiledetect
- Templates: add home/index, update base.html.twig with vite_asset
- Email template: rebrand to E-Ticket

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-18 21:35:36 +01:00
parent 46a84a9f9a
commit 8d8d70cab4
14 changed files with 525 additions and 17 deletions

View File

@@ -15,9 +15,11 @@
"league/flysystem-aws-s3-v3": "^3.32", "league/flysystem-aws-s3-v3": "^3.32",
"league/flysystem-bundle": "^3.6", "league/flysystem-bundle": "^3.6",
"liip/imagine-bundle": "^2.17", "liip/imagine-bundle": "^2.17",
"mobiledetect/mobiledetectlib": "*",
"nelmio/security-bundle": "^3.9", "nelmio/security-bundle": "^3.9",
"phpdocumentor/reflection-docblock": "^6.0", "phpdocumentor/reflection-docblock": "^6.0",
"phpstan/phpdoc-parser": "^2.3", "phpstan/phpdoc-parser": "^2.3",
"stripe/stripe-php": "*",
"symfony/asset": "8.0.*", "symfony/asset": "8.0.*",
"symfony/console": "8.0.*", "symfony/console": "8.0.*",
"symfony/doctrine-messenger": "8.0.*", "symfony/doctrine-messenger": "8.0.*",

176
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "57b0fe3e59760d7d8cb08112bed744a4", "content-hash": "06d85967d9503db645c96c90e761fa2a",
"packages": [ "packages": [
{ {
"name": "aws/aws-crt-php", "name": "aws/aws-crt-php",
@@ -2865,6 +2865,70 @@
}, },
"time": "2025-07-25T09:04:22+00:00" "time": "2025-07-25T09:04:22+00:00"
}, },
{
"name": "mobiledetect/mobiledetectlib",
"version": "4.8.10",
"source": {
"type": "git",
"url": "https://github.com/serbanghita/Mobile-Detect.git",
"reference": "96b1e1fa9a968de7660a031106ab529f659d0192"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/96b1e1fa9a968de7660a031106ab529f659d0192",
"reference": "96b1e1fa9a968de7660a031106ab529f659d0192",
"shasum": ""
},
"require": {
"php": ">=8.0",
"psr/simple-cache": "^3"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^v3.75.0",
"phpbench/phpbench": "^1.2",
"phpstan/phpstan": "^2.1.11",
"phpunit/phpunit": "^9.6.22",
"squizlabs/php_codesniffer": "^3.12.1"
},
"type": "library",
"autoload": {
"psr-4": {
"Detection\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Serban Ghita",
"email": "serbanghita@gmail.com",
"homepage": "http://mobiledetect.net",
"role": "Developer"
}
],
"description": "Mobile_Detect is a lightweight PHP class for detecting mobile devices. It uses the User-Agent string combined with specific HTTP headers to detect the mobile environment.",
"homepage": "https://github.com/serbanghita/Mobile-Detect",
"keywords": [
"detect mobile devices",
"mobile",
"mobile detect",
"mobile detector",
"php mobile detect"
],
"support": {
"issues": "https://github.com/serbanghita/Mobile-Detect/issues",
"source": "https://github.com/serbanghita/Mobile-Detect/tree/4.8.10"
},
"funding": [
{
"url": "https://github.com/serbanghita",
"type": "github"
}
],
"time": "2026-01-09T16:21:59+00:00"
},
{ {
"name": "monolog/monolog", "name": "monolog/monolog",
"version": "3.10.0", "version": "3.10.0",
@@ -3797,6 +3861,57 @@
}, },
"time": "2024-09-11T13:17:53+00:00" "time": "2024-09-11T13:17:53+00:00"
}, },
{
"name": "psr/simple-cache",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/simple-cache.git",
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\SimpleCache\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interfaces for simple caching",
"keywords": [
"cache",
"caching",
"psr",
"psr-16",
"simple-cache"
],
"support": {
"source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
},
"time": "2021-10-29T13:26:27+00:00"
},
{ {
"name": "ralouphie/getallheaders", "name": "ralouphie/getallheaders",
"version": "3.0.3", "version": "3.0.3",
@@ -3921,6 +4036,65 @@
}, },
"time": "2026-03-03T17:31:43+00:00" "time": "2026-03-03T17:31:43+00:00"
}, },
{
"name": "stripe/stripe-php",
"version": "v19.4.1",
"source": {
"type": "git",
"url": "https://github.com/stripe/stripe-php.git",
"reference": "095384404587d07de2ad1154c389c4051c5ed92f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/stripe/stripe-php/zipball/095384404587d07de2ad1154c389c4051c5ed92f",
"reference": "095384404587d07de2ad1154c389c4051c5ed92f",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"php": ">=5.6.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.94.0",
"phpstan/phpstan": "^1.2",
"phpunit/phpunit": "^5.7 || ^9.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0-dev"
}
},
"autoload": {
"psr-4": {
"Stripe\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Stripe and contributors",
"homepage": "https://github.com/stripe/stripe-php/contributors"
}
],
"description": "Stripe PHP Library",
"homepage": "https://stripe.com/",
"keywords": [
"api",
"payment processing",
"stripe"
],
"support": {
"issues": "https://github.com/stripe/stripe-php/issues",
"source": "https://github.com/stripe/stripe-php/tree/v19.4.1"
},
"time": "2026-03-06T22:53:13+00:00"
},
{ {
"name": "symfony/asset", "name": "symfony/asset",
"version": "v8.0.6", "version": "v8.0.6",

View File

@@ -38,10 +38,3 @@ services:
arguments: arguments:
$manifest: '%kernel.project_dir%/public/build/.vite/manifest.json' $manifest: '%kernel.project_dir%/public/build/.vite/manifest.json'
App\Service\MailerService:
arguments:
$smimeCertificate: '%kernel.project_dir%/cert/certificate.pem'
$smimePrivateKey: '%kernel.project_dir%/cert/private-key.pem'
$smimePassphrase: '%env(SMIME_PASSPHRASE)%'
$fromEmail: 'contact@e-cosplay.fr'
$realMail: '%env(bool:REAL_MAIL)%'

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Psr\Log\LoggerInterface;
use Symfony\Component\Routing\Attribute\Route;
class CspReportController extends AbstractController
{
#[Route('/my-csp-report', name: 'app_csp_report', methods: ['POST'])]
public function report(Request $request, MailerInterface $mailer, LoggerInterface $logger): Response
{
$data = $request->getContent();
if (empty($data)) {
return new Response('No data provided', 400);
}
$report = json_decode($data, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$logger->error('CSP Report JSON decode error: ' . json_last_error_msg());
return new Response('Invalid JSON', 400);
}
$details = $report['csp-report'] ?? $report;
$sourceFile = $details['source-file'] ?? '';
$blockedUri = $details['blocked-uri'] ?? '';
$documentUri = $details['document-uri'] ?? '';
$violatedDirective = $details['violated-directive'] ?? '';
$shouldIgnore = false;
if (
str_contains($sourceFile, 'extension://') ||
str_contains($sourceFile, 'chrome-extension') ||
str_contains($sourceFile, 'moz-extension') ||
str_contains($documentUri, '.local') ||
str_contains($sourceFile, 'localhost') ||
$blockedUri === 'wasm-eval' ||
$blockedUri === 'inline' && str_contains($sourceFile, 'node_modules') ||
$blockedUri === 'about:blank'
) {
$shouldIgnore = true;
}
if ($shouldIgnore) {
$logger->info('CSP Violation ignored (Extension or Local Dev): ' . $sourceFile);
return new Response('Report ignored', 204);
}
$logger->warning('REAL CSP VIOLATION: ' . $data);
$email = (new Email())
->from('security-notify@e-cosplay.fr')
->to('contact@e-cosplay.fr')
->subject('Alerte Securite : Violation CSP detectee')
->priority(Email::PRIORITY_HIGH)
->text(
"Un rapport de violation CSP potentiellement critique a ete intercepte.\n\n" .
"Document: " . $documentUri . "\n" .
"Directive violee: " . $violatedDirective . "\n" .
"Element bloque: " . $blockedUri . "\n" .
"Fichier source: " . $sourceFile . "\n\n" .
"Details complets :\n" .
json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
);
try {
$mailer->send($email);
} catch (\Exception $e) {
$logger->error('Failed to send CSP report email: ' . $e->getMessage());
}
return new Response('Report processed', 204);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Controller;
use App\Repository\EmailTrackingRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class EmailTrackingController extends AbstractController
{
#[Route('/track/{messageId}/logo.jpg', name: 'app_email_track', methods: ['GET'])]
public function track(
string $messageId,
EmailTrackingRepository $repository,
EntityManagerInterface $em,
#[Autowire('%kernel.project_dir%')] string $projectDir,
): Response {
$tracking = $repository->findOneBy(['messageId' => $messageId]);
if ($tracking !== null) {
$tracking->markAsOpened();
$em->flush();
}
$response = new BinaryFileResponse($projectDir . '/public/logo.jpg');
$response->headers->set('Content-Type', 'image/jpeg');
$response->headers->set('Cache-Control', 'no-store');
return $response;
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class RedirectController extends AbstractController
{
#[Route('/external-redirect', name: 'app_external_redirect', methods: ['GET'])]
public function warning(Request $request): Response
{
$url = $request->query->get('redirUrl');
if (!$url) {
return $this->redirectToRoute('app_home');
}
return $this->render('pages/external_redirect.twig');
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Entity;
use App\Repository\EmailTrackingRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: EmailTrackingRepository::class)]
class EmailTracking
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 64, unique: true)]
private ?string $messageId = null;
#[ORM\Column(length: 255)]
private ?string $recipient = null;
#[ORM\Column(length: 255)]
private ?string $subject = null;
#[ORM\Column(length: 10)]
private ?string $state = null;
#[ORM\Column]
private ?\DateTimeImmutable $sentAt = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $openedAt = null;
public function __construct(string $messageId, string $recipient, string $subject)
{
$this->messageId = $messageId;
$this->recipient = $recipient;
$this->subject = $subject;
$this->state = 'sent';
$this->sentAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getMessageId(): ?string
{
return $this->messageId;
}
public function getRecipient(): ?string
{
return $this->recipient;
}
public function getSubject(): ?string
{
return $this->subject;
}
public function getState(): ?string
{
return $this->state;
}
public function getSentAt(): ?\DateTimeImmutable
{
return $this->sentAt;
}
public function getOpenedAt(): ?\DateTimeImmutable
{
return $this->openedAt;
}
public function markAsOpened(): void
{
if ($this->state === 'sent') {
$this->state = 'opened';
$this->openedAt = new \DateTimeImmutable();
}
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\EmailTracking;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EmailTracking>
*/
class EmailTrackingRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EmailTracking::class);
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Service;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class UnsubscribeManager
{
private string $storagePath;
private string $secret;
public function __construct(
#[Autowire('%kernel.project_dir%')] string $projectDir,
#[Autowire('%kernel.secret%')] string $appSecret,
) {
$this->storagePath = $projectDir . '/var/unsubscribed.json';
$this->secret = $appSecret;
}
public function generateToken(string $email): string
{
return hash_hmac('sha256', strtolower(trim($email)), $this->secret);
}
public function isValidToken(string $email, string $token): bool
{
return hash_equals($this->generateToken($email), $token);
}
public function isUnsubscribed(string $email): bool
{
$hash = $this->generateToken($email);
return \in_array($hash, $this->loadHashes(), true);
}
public function unsubscribe(string $email): void
{
$hash = $this->generateToken($email);
$hashes = $this->loadHashes();
if (!\in_array($hash, $hashes, true)) {
$hashes[] = $hash;
$this->saveHashes($hashes);
}
}
/**
* @return list<string>
*/
private function loadHashes(): array
{
if (!file_exists($this->storagePath)) {
return [];
}
$data = json_decode(file_get_contents($this->storagePath), true);
return \is_array($data) ? $data : [];
}
/**
* @param list<string> $hashes
*/
private function saveHashes(array $hashes): void
{
$dir = \dirname($this->storagePath);
if (!is_dir($dir)) {
mkdir($dir, 0o755, true);
}
file_put_contents($this->storagePath, json_encode(array_values($hashes)), \LOCK_EX);
}
}

View File

@@ -6,7 +6,7 @@ use Detection\MobileDetect;
use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\CacheItemPoolInterface;
use Twig\Extension\AbstractExtension; use Twig\Extension\AbstractExtension;
use Twig\TwigFunction; use Twig\TwigFunction;
use Nelmio\SecurityBundle\EventListener\ContentSecurityPolicyListener;
class ViteAssetExtension extends AbstractExtension class ViteAssetExtension extends AbstractExtension
{ {
@@ -17,7 +17,7 @@ class ViteAssetExtension extends AbstractExtension
public function __construct( public function __construct(
private readonly string $manifest, private readonly string $manifest,
private readonly CacheItemPoolInterface $cache, private readonly CacheItemPoolInterface $cache,
private readonly ?ContentSecurityPolicyListener $cspListener = null,
) { ) {
$this->isDev = $_ENV['VITE_LOAD'] === "0"; $this->isDev = $_ENV['VITE_LOAD'] === "0";
} }
@@ -26,18 +26,21 @@ class ViteAssetExtension extends AbstractExtension
{ {
return [ return [
new TwigFunction('vite_asset', $this->asset(...), ['is_safe' => ['html']]), new TwigFunction('vite_asset', $this->asset(...), ['is_safe' => ['html']]),
new TwigFunction('isMobile', $this->isMobile(...), ['is_safe' => ['html']]),
new TwigFunction('vite_favicons', $this->favicons(...), ['is_safe' => ['html']]) new TwigFunction('vite_favicons', $this->favicons(...), ['is_safe' => ['html']])
]; ];
} }
/**
* Récupère le nonce pour les scripts via le Listener de Nelmio
*/
protected function getNonce(): string protected function getNonce(): string
{ {
return ''; return $this->cspListener?->getNonce('script') ?? '';
} }
public function isMobile(): bool
{
$detect = new MobileDetect();
return $detect->isMobile() || $detect->isTablet();
}
private function loadManifest(): void private function loadManifest(): void
{ {

View File

@@ -87,6 +87,15 @@
"bin/phpunit" "bin/phpunit"
] ]
}, },
"stripe/stripe-php": {
"version": "19.4",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "19.0",
"ref": "d6829c693e3927a8972c7671d74a1a5c505712b0"
}
},
"symfony/console": { "symfony/console": {
"version": "8.0", "version": "8.0",
"recipe": { "recipe": {

View File

@@ -5,9 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{% endblock %}</title> <title>{% block title %}{% endblock %}</title>
{% block stylesheets %}{% endblock %} {% block stylesheets %}{% endblock %}
{% block javascripts %}
{{ vite_asset('app.js') }}
{% endblock %}
</head> </head>
<body> <body>
{% block body %}{% endblock %} {% block body %}{% endblock %}
{% block javascripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -85,14 +85,14 @@
<div class="wrapper"> <div class="wrapper">
<div class="container"> <div class="container">
<div class="header"> <div class="header">
<h1>🎫 E-Cosplay Ticket</h1> <h1>🎫 E-Ticket</h1>
</div> </div>
<div class="content"> <div class="content">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
<div class="footer"> <div class="footer">
{% block footer %} {% block footer %}
<p>&copy; {{ "now"|date("Y") }} E-Cosplay — <a href="https://e-cosplay.fr">e-cosplay.fr</a></p> <p>&copy; {{ "now"|date("Y") }} E-Ticket — <a href="https://ticket.e-cosplay.fr">e-cosplay.fr</a></p>
<p>Cet email a été envoyé depuis contact@e-cosplay.fr</p> <p>Cet email a été envoyé depuis contact@e-cosplay.fr</p>
{% endblock %} {% endblock %}
</div> </div>

View File

@@ -0,0 +1,7 @@
{% extends 'base.html.twig' %}
{% block title %}Accueil - E-Ticket{% endblock %}
{% block body %}
{% endblock %}