Add first-party analytics tracker with encrypted transmissions
Core system: - AnalyticsUniqId entity (visitor identity with device/os/browser parsing) - AnalyticsEvent entity (page views linked to visitor) - POST /t endpoint with AES-256-GCM encrypted payloads - HMAC-SHA256 visitor hash for anti-tampering - Async processing via Messenger - JS module: auto page_view tracking, setAuth for logged users - Encryption key shared via data-k attribute on body - setAuth only triggers when cookie consent is accepted - Clean CSP: remove old tracker domains (Cloudflare, Umami) 100% first-party, no cookies, invisible to adblockers, RGPD-friendly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import { initCart } from "./modules/cart.js"
|
||||
import { initStripePayment } from "./modules/stripe-payment.js"
|
||||
import { initShare } from "./modules/share.js"
|
||||
import { initApiEnvSwitcher } from "./modules/api-env-switcher.js"
|
||||
import { initAnalytics } from "./modules/analytics.js"
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initMobileMenu()
|
||||
@@ -27,6 +28,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
initStripePayment()
|
||||
initShare()
|
||||
initApiEnvSwitcher()
|
||||
initAnalytics()
|
||||
|
||||
document.querySelectorAll('[data-confirm]').forEach(form => {
|
||||
form.addEventListener('submit', (e) => {
|
||||
|
||||
114
assets/modules/analytics.js
Normal file
114
assets/modules/analytics.js
Normal file
@@ -0,0 +1,114 @@
|
||||
const ENDPOINT = '/t'
|
||||
const SK_UID = '_u'
|
||||
const SK_HASH = '_h'
|
||||
|
||||
let encKey = null
|
||||
|
||||
async function importKey(b64) {
|
||||
const raw = Uint8Array.from(atob(b64), c => c.charCodeAt(0))
|
||||
return globalThis.crypto.subtle.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt'])
|
||||
}
|
||||
|
||||
async function encrypt(data) {
|
||||
if (!encKey) return null
|
||||
const json = new TextEncoder().encode(JSON.stringify(data))
|
||||
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
|
||||
const encrypted = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv, tagLength: 128 }, encKey, json)
|
||||
const buf = new Uint8Array(encrypted)
|
||||
const combined = new Uint8Array(12 + buf.length)
|
||||
combined.set(iv)
|
||||
combined.set(buf, 12)
|
||||
return btoa(String.fromCharCode(...combined))
|
||||
}
|
||||
|
||||
async function decrypt(b64) {
|
||||
if (!encKey) return null
|
||||
const raw = Uint8Array.from(atob(b64), c => c.charCodeAt(0))
|
||||
const iv = raw.slice(0, 12)
|
||||
const data = raw.slice(12)
|
||||
try {
|
||||
const decrypted = await globalThis.crypto.subtle.decrypt({ name: 'AES-GCM', iv, tagLength: 128 }, encKey, data)
|
||||
return JSON.parse(new TextDecoder().decode(decrypted))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function send(data, expectResponse = false) {
|
||||
const d = await encrypt(data)
|
||||
if (!d) return null
|
||||
try {
|
||||
if (!expectResponse && navigator.sendBeacon) {
|
||||
navigator.sendBeacon(ENDPOINT, JSON.stringify({ d }))
|
||||
return null
|
||||
}
|
||||
const res = await fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ d }),
|
||||
keepalive: true,
|
||||
})
|
||||
if (!res.ok || res.status === 204) return null
|
||||
const json = await res.json()
|
||||
return json.d ? await decrypt(json.d) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function getOrCreateVisitor() {
|
||||
let uid = sessionStorage.getItem(SK_UID)
|
||||
let hash = sessionStorage.getItem(SK_HASH)
|
||||
if (uid && hash) return { uid, hash }
|
||||
|
||||
const resp = await send({
|
||||
sw: screen.width,
|
||||
sh: screen.height,
|
||||
l: navigator.language || null,
|
||||
}, true)
|
||||
|
||||
if (!resp || !resp.uid || !resp.h) return null
|
||||
|
||||
sessionStorage.setItem(SK_UID, resp.uid)
|
||||
sessionStorage.setItem(SK_HASH, resp.h)
|
||||
return { uid: resp.uid, hash: resp.h }
|
||||
}
|
||||
|
||||
async function trackPageView(visitor) {
|
||||
await send({
|
||||
uid: visitor.uid,
|
||||
h: visitor.hash,
|
||||
u: location.pathname + location.search,
|
||||
t: document.title,
|
||||
r: document.referrer || null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function initAnalytics() {
|
||||
const keyB64 = document.body.dataset.k
|
||||
if (!keyB64 || document.body.dataset.env === 'dev') return
|
||||
|
||||
try {
|
||||
encKey = await importKey(keyB64)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const visitor = await getOrCreateVisitor()
|
||||
if (!visitor) return
|
||||
|
||||
await trackPageView(visitor)
|
||||
|
||||
const authUserId = document.body.dataset.uid
|
||||
if (authUserId) {
|
||||
await setAuth(parseInt(authUserId, 10))
|
||||
}
|
||||
}
|
||||
|
||||
export async function setAuth(userId) {
|
||||
const uid = sessionStorage.getItem(SK_UID)
|
||||
const hash = sessionStorage.getItem(SK_HASH)
|
||||
if (!uid || !hash || !encKey) return
|
||||
|
||||
await send({ uid, h: hash, setUser: userId })
|
||||
}
|
||||
@@ -23,3 +23,4 @@ framework:
|
||||
Symfony\Component\Notifier\Message\SmsMessage: async
|
||||
|
||||
App\Message\MeilisearchMessage: async
|
||||
App\Message\AnalyticsMessage: async
|
||||
|
||||
@@ -26,13 +26,9 @@ nelmio_security:
|
||||
- 'https://stripe.com'
|
||||
- 'https://*.stripe.com'
|
||||
- 'https://js.stripe.com'
|
||||
- 'https://cloudflare.com'
|
||||
- 'https://*.cloudflareinsights.com'
|
||||
- 'https://challenges.cloudflare.com'
|
||||
script-src:
|
||||
- 'self'
|
||||
- 'https://static.cloudflareinsights.com'
|
||||
- 'https://tools-security.esy-web.dev'
|
||||
- 'https://challenges.cloudflare.com'
|
||||
- 'https://cdn.jsdelivr.net'
|
||||
- 'https://js.stripe.com'
|
||||
@@ -54,9 +50,6 @@ nelmio_security:
|
||||
- 'blob:'
|
||||
connect-src:
|
||||
- 'self'
|
||||
- 'https://cloudflareinsights.com'
|
||||
- 'https://static.cloudflareinsights.com'
|
||||
- 'https://tools-security.esy-web.dev'
|
||||
- 'https://challenges.cloudflare.com'
|
||||
- 'https://nominatim.openstreetmap.org'
|
||||
- 'https://cdn.jsdelivr.net'
|
||||
@@ -88,8 +81,6 @@ nelmio_security:
|
||||
forward_as: redirUrl
|
||||
log: true
|
||||
allow_list:
|
||||
- cloudflareinsights.com
|
||||
- static.cloudflareinsights.com
|
||||
- stripe.com
|
||||
- connect.stripe.com
|
||||
- checkout.stripe.com
|
||||
|
||||
@@ -4,8 +4,6 @@ nelmio_security:
|
||||
script-src:
|
||||
- 'self'
|
||||
- 'nonce'
|
||||
- 'https://static.cloudflareinsights.com'
|
||||
- 'https://tools-security.esy-web.dev'
|
||||
|
||||
# Restreindre les soumissions de formulaires à notre domaine
|
||||
# et aux redirections OAuth des plateformes de partage social
|
||||
|
||||
44
migrations/Version20260326105040.php
Normal file
44
migrations/Version20260326105040.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260326105040 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE analytics_event (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, event_name VARCHAR(50) NOT NULL, url VARCHAR(2048) NOT NULL, title VARCHAR(255) DEFAULT NULL, referrer VARCHAR(2048) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, visitor_id INT NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE INDEX IDX_9CD0310A70BEE6D ON analytics_event (visitor_id)');
|
||||
$this->addSql('CREATE INDEX idx_ae_event ON analytics_event (event_name)');
|
||||
$this->addSql('CREATE INDEX idx_ae_created ON analytics_event (created_at)');
|
||||
$this->addSql('CREATE TABLE analytics_uniq_id (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, uid VARCHAR(36) NOT NULL, hash VARCHAR(64) NOT NULL, ip_hash VARCHAR(64) NOT NULL, user_agent VARCHAR(512) NOT NULL, screen_width INT DEFAULT NULL, screen_height INT DEFAULT NULL, language VARCHAR(10) DEFAULT NULL, device_type VARCHAR(10) NOT NULL, os VARCHAR(30) DEFAULT NULL, browser VARCHAR(30) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, user_id INT DEFAULT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_65C10CC1539B0606 ON analytics_uniq_id (uid)');
|
||||
$this->addSql('CREATE INDEX IDX_65C10CC1A76ED395 ON analytics_uniq_id (user_id)');
|
||||
$this->addSql('CREATE INDEX idx_analytics_ip ON analytics_uniq_id (ip_hash)');
|
||||
$this->addSql('CREATE INDEX idx_analytics_created ON analytics_uniq_id (created_at)');
|
||||
$this->addSql('ALTER TABLE analytics_event ADD CONSTRAINT FK_9CD0310A70BEE6D FOREIGN KEY (visitor_id) REFERENCES analytics_uniq_id (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE analytics_uniq_id ADD CONSTRAINT FK_65C10CC1A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE analytics_event DROP CONSTRAINT FK_9CD0310A70BEE6D');
|
||||
$this->addSql('ALTER TABLE analytics_uniq_id DROP CONSTRAINT FK_65C10CC1A76ED395');
|
||||
$this->addSql('DROP TABLE analytics_event');
|
||||
$this->addSql('DROP TABLE analytics_uniq_id');
|
||||
}
|
||||
}
|
||||
101
src/Controller/AnalyticsController.php
Normal file
101
src/Controller/AnalyticsController.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\AnalyticsUniqId;
|
||||
use App\Message\AnalyticsMessage;
|
||||
use App\Service\AnalyticsCryptoService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
class AnalyticsController extends AbstractController
|
||||
{
|
||||
#[Route('/t', name: 'app_analytics_track', methods: ['POST'])]
|
||||
public function track(
|
||||
Request $request,
|
||||
AnalyticsCryptoService $crypto,
|
||||
EntityManagerInterface $em,
|
||||
MessageBusInterface $bus,
|
||||
): Response {
|
||||
$body = $request->getContent();
|
||||
$envelope = json_decode($body, true);
|
||||
|
||||
if (!$envelope || !isset($envelope['d'])) {
|
||||
return new Response('', 400);
|
||||
}
|
||||
|
||||
$data = $crypto->decrypt($envelope['d']);
|
||||
if (null === $data) {
|
||||
return new Response('', 403);
|
||||
}
|
||||
|
||||
$uid = $data['uid'] ?? null;
|
||||
$hash = $data['h'] ?? null;
|
||||
|
||||
// No uid = create new visitor
|
||||
if (!$uid) {
|
||||
$visitor = $this->createVisitor($request, $data, $crypto, $em);
|
||||
|
||||
$responseData = $crypto->encrypt([
|
||||
'uid' => $visitor->getUid(),
|
||||
'h' => $visitor->getHash(),
|
||||
]);
|
||||
|
||||
return new JsonResponse(['d' => $responseData]);
|
||||
}
|
||||
|
||||
// Verify hash
|
||||
if (!$hash || !$crypto->verifyVisitorHash($uid, $hash)) {
|
||||
return new Response('', 403);
|
||||
}
|
||||
|
||||
// setUser
|
||||
if (isset($data['setUser'])) {
|
||||
$bus->dispatch(new AnalyticsMessage($uid, 'set_user', ['userId' => (int) $data['setUser']]));
|
||||
|
||||
return new Response('', 204);
|
||||
}
|
||||
|
||||
// page_view
|
||||
$bus->dispatch(new AnalyticsMessage($uid, 'page_view', [
|
||||
'url' => $data['u'] ?? '/',
|
||||
'title' => $data['t'] ?? null,
|
||||
'referrer' => $data['r'] ?? null,
|
||||
]));
|
||||
|
||||
return new Response('', 204);
|
||||
}
|
||||
|
||||
private function createVisitor(
|
||||
Request $request,
|
||||
array $data,
|
||||
AnalyticsCryptoService $crypto,
|
||||
EntityManagerInterface $em,
|
||||
): AnalyticsUniqId {
|
||||
$uid = Uuid::v4()->toRfc4122();
|
||||
$ua = $request->headers->get('User-Agent', '');
|
||||
|
||||
$visitor = new AnalyticsUniqId();
|
||||
$visitor->setUid($uid);
|
||||
$visitor->setHash($crypto->generateVisitorHash($uid));
|
||||
$visitor->setIpHash(hash('sha256', $request->getClientIp() ?? ''));
|
||||
$visitor->setUserAgent(substr($ua, 0, 512));
|
||||
$visitor->setScreenWidth(isset($data['sw']) ? (int) $data['sw'] : null);
|
||||
$visitor->setScreenHeight(isset($data['sh']) ? (int) $data['sh'] : null);
|
||||
$visitor->setLanguage(isset($data['l']) ? substr((string) $data['l'], 0, 10) : null);
|
||||
$visitor->setDeviceType(AnalyticsUniqId::parseDeviceType($ua));
|
||||
$visitor->setOs(AnalyticsUniqId::parseOs($ua));
|
||||
$visitor->setBrowser(AnalyticsUniqId::parseBrowser($ua));
|
||||
|
||||
$em->persist($visitor);
|
||||
$em->flush();
|
||||
|
||||
return $visitor;
|
||||
}
|
||||
}
|
||||
110
src/Entity/AnalyticsEvent.php
Normal file
110
src/Entity/AnalyticsEvent.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Index(columns: ['event_name'], name: 'idx_ae_event')]
|
||||
#[ORM\Index(columns: ['created_at'], name: 'idx_ae_created')]
|
||||
class AnalyticsEvent
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: AnalyticsUniqId::class, inversedBy: 'events')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private AnalyticsUniqId $visitor;
|
||||
|
||||
#[ORM\Column(length: 50)]
|
||||
private string $eventName = 'page_view';
|
||||
|
||||
#[ORM\Column(length: 2048)]
|
||||
private string $url;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $title = null;
|
||||
|
||||
#[ORM\Column(length: 2048, nullable: true)]
|
||||
private ?string $referrer = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getVisitor(): AnalyticsUniqId
|
||||
{
|
||||
return $this->visitor;
|
||||
}
|
||||
|
||||
public function setVisitor(AnalyticsUniqId $visitor): static
|
||||
{
|
||||
$this->visitor = $visitor;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEventName(): string
|
||||
{
|
||||
return $this->eventName;
|
||||
}
|
||||
|
||||
public function setEventName(string $eventName): static
|
||||
{
|
||||
$this->eventName = $eventName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUrl(): string
|
||||
{
|
||||
return $this->url;
|
||||
}
|
||||
|
||||
public function setUrl(string $url): static
|
||||
{
|
||||
$this->url = $url;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTitle(): ?string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function setTitle(?string $title): static
|
||||
{
|
||||
$this->title = $title;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReferrer(): ?string
|
||||
{
|
||||
return $this->referrer;
|
||||
}
|
||||
|
||||
public function setReferrer(?string $referrer): static
|
||||
{
|
||||
$this->referrer = $referrer;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
}
|
||||
267
src/Entity/AnalyticsUniqId.php
Normal file
267
src/Entity/AnalyticsUniqId.php
Normal file
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Index(columns: ['ip_hash'], name: 'idx_analytics_ip')]
|
||||
#[ORM\Index(columns: ['created_at'], name: 'idx_analytics_created')]
|
||||
class AnalyticsUniqId
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 36, unique: true)]
|
||||
private string $uid;
|
||||
|
||||
#[ORM\Column(length: 64)]
|
||||
private string $hash;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $user = null;
|
||||
|
||||
#[ORM\Column(length: 64)]
|
||||
private string $ipHash;
|
||||
|
||||
#[ORM\Column(length: 512)]
|
||||
private string $userAgent;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $screenWidth = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $screenHeight = null;
|
||||
|
||||
#[ORM\Column(length: 10, nullable: true)]
|
||||
private ?string $language = null;
|
||||
|
||||
#[ORM\Column(length: 10)]
|
||||
private string $deviceType = 'desktop';
|
||||
|
||||
#[ORM\Column(length: 30, nullable: true)]
|
||||
private ?string $os = null;
|
||||
|
||||
#[ORM\Column(length: 30, nullable: true)]
|
||||
private ?string $browser = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
/** @var Collection<int, AnalyticsEvent> */
|
||||
#[ORM\OneToMany(targetEntity: AnalyticsEvent::class, mappedBy: 'visitor', cascade: ['remove'])]
|
||||
private Collection $events;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
$this->events = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUid(): string
|
||||
{
|
||||
return $this->uid;
|
||||
}
|
||||
|
||||
public function setUid(string $uid): static
|
||||
{
|
||||
$this->uid = $uid;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getHash(): string
|
||||
{
|
||||
return $this->hash;
|
||||
}
|
||||
|
||||
public function setHash(string $hash): static
|
||||
{
|
||||
$this->hash = $hash;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUser(): ?User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function setUser(?User $user): static
|
||||
{
|
||||
$this->user = $user;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getIpHash(): string
|
||||
{
|
||||
return $this->ipHash;
|
||||
}
|
||||
|
||||
public function setIpHash(string $ipHash): static
|
||||
{
|
||||
$this->ipHash = $ipHash;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUserAgent(): string
|
||||
{
|
||||
return $this->userAgent;
|
||||
}
|
||||
|
||||
public function setUserAgent(string $userAgent): static
|
||||
{
|
||||
$this->userAgent = $userAgent;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getScreenWidth(): ?int
|
||||
{
|
||||
return $this->screenWidth;
|
||||
}
|
||||
|
||||
public function setScreenWidth(?int $screenWidth): static
|
||||
{
|
||||
$this->screenWidth = $screenWidth;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getScreenHeight(): ?int
|
||||
{
|
||||
return $this->screenHeight;
|
||||
}
|
||||
|
||||
public function setScreenHeight(?int $screenHeight): static
|
||||
{
|
||||
$this->screenHeight = $screenHeight;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLanguage(): ?string
|
||||
{
|
||||
return $this->language;
|
||||
}
|
||||
|
||||
public function setLanguage(?string $language): static
|
||||
{
|
||||
$this->language = $language;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeviceType(): string
|
||||
{
|
||||
return $this->deviceType;
|
||||
}
|
||||
|
||||
public function setDeviceType(string $deviceType): static
|
||||
{
|
||||
$this->deviceType = $deviceType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getOs(): ?string
|
||||
{
|
||||
return $this->os;
|
||||
}
|
||||
|
||||
public function setOs(?string $os): static
|
||||
{
|
||||
$this->os = $os;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBrowser(): ?string
|
||||
{
|
||||
return $this->browser;
|
||||
}
|
||||
|
||||
public function setBrowser(?string $browser): static
|
||||
{
|
||||
$this->browser = $browser;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, AnalyticsEvent>
|
||||
*/
|
||||
public function getEvents(): Collection
|
||||
{
|
||||
return $this->events;
|
||||
}
|
||||
|
||||
public static function parseDeviceType(string $ua): string
|
||||
{
|
||||
$ua = strtolower($ua);
|
||||
if (preg_match('/tablet|ipad|playbook|silk/i', $ua)) {
|
||||
return 'tablet';
|
||||
}
|
||||
if (preg_match('/mobile|iphone|ipod|android.*mobile|windows phone|blackberry/i', $ua)) {
|
||||
return 'mobile';
|
||||
}
|
||||
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
public static function parseOs(string $ua): ?string
|
||||
{
|
||||
$patterns = [
|
||||
'/windows nt/i' => 'Windows',
|
||||
'/macintosh|mac os x/i' => 'macOS',
|
||||
'/iphone|ipad|ipod/i' => 'iOS',
|
||||
'/android/i' => 'Android',
|
||||
'/linux/i' => 'Linux',
|
||||
'/cros/i' => 'ChromeOS',
|
||||
];
|
||||
|
||||
foreach ($patterns as $pattern => $name) {
|
||||
if (preg_match($pattern, $ua)) {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function parseBrowser(string $ua): ?string
|
||||
{
|
||||
$patterns = [
|
||||
'/edg(?:e|a)?\/[\d.]+/i' => 'Edge',
|
||||
'/opr\/[\d.]+|opera/i' => 'Opera',
|
||||
'/chrome\/[\d.]+(?!.*edg)/i' => 'Chrome',
|
||||
'/safari\/[\d.]+(?!.*chrome)/i' => 'Safari',
|
||||
'/firefox\/[\d.]+/i' => 'Firefox',
|
||||
];
|
||||
|
||||
foreach ($patterns as $pattern => $name) {
|
||||
if (preg_match($pattern, $ua)) {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
16
src/Message/AnalyticsMessage.php
Normal file
16
src/Message/AnalyticsMessage.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Message;
|
||||
|
||||
class AnalyticsMessage
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $uid,
|
||||
public readonly string $action,
|
||||
public readonly array $payload = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
52
src/MessageHandler/AnalyticsMessageHandler.php
Normal file
52
src/MessageHandler/AnalyticsMessageHandler.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\MessageHandler;
|
||||
|
||||
use App\Entity\AnalyticsEvent;
|
||||
use App\Entity\AnalyticsUniqId;
|
||||
use App\Entity\User;
|
||||
use App\Message\AnalyticsMessage;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler]
|
||||
class AnalyticsMessageHandler
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(AnalyticsMessage $message): void
|
||||
{
|
||||
$visitor = $this->em->getRepository(AnalyticsUniqId::class)->findOneBy(['uid' => $message->uid]);
|
||||
if (!$visitor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('set_user' === $message->action) {
|
||||
$userId = $message->payload['userId'] ?? null;
|
||||
if ($userId) {
|
||||
$user = $this->em->getRepository(User::class)->find($userId);
|
||||
if ($user) {
|
||||
$visitor->setUser($user);
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ('page_view' === $message->action) {
|
||||
$event = new AnalyticsEvent();
|
||||
$event->setVisitor($visitor);
|
||||
$event->setEventName('page_view');
|
||||
$event->setUrl($message->payload['url'] ?? '/');
|
||||
$event->setTitle($message->payload['title'] ?? null);
|
||||
$event->setReferrer($message->payload['referrer'] ?? null);
|
||||
|
||||
$this->em->persist($event);
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/Service/AnalyticsCryptoService.php
Normal file
59
src/Service/AnalyticsCryptoService.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
class AnalyticsCryptoService
|
||||
{
|
||||
private string $key;
|
||||
|
||||
public function __construct(
|
||||
#[Autowire('%kernel.secret%')] string $appSecret,
|
||||
) {
|
||||
$this->key = substr(hash('sha256', $appSecret.'_analytics', true), 0, 32);
|
||||
}
|
||||
|
||||
public function encrypt(array $data): string
|
||||
{
|
||||
$json = json_encode($data, \JSON_THROW_ON_ERROR);
|
||||
$iv = random_bytes(12);
|
||||
$encrypted = openssl_encrypt($json, 'aes-256-gcm', $this->key, \OPENSSL_RAW_DATA, $iv, $tag, '', 16);
|
||||
|
||||
return base64_encode($iv.$tag.$encrypted);
|
||||
}
|
||||
|
||||
public function decrypt(string $payload): ?array
|
||||
{
|
||||
$raw = base64_decode($payload, true);
|
||||
if (false === $raw || \strlen($raw) < 28) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$iv = substr($raw, 0, 12);
|
||||
$tag = substr($raw, 12, 16);
|
||||
$encrypted = substr($raw, 28);
|
||||
|
||||
$json = openssl_decrypt($encrypted, 'aes-256-gcm', $this->key, \OPENSSL_RAW_DATA, $iv, $tag);
|
||||
if (false === $json) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode($json, true);
|
||||
}
|
||||
|
||||
public function generateVisitorHash(string $uid): string
|
||||
{
|
||||
return hash_hmac('sha256', $uid, $this->key);
|
||||
}
|
||||
|
||||
public function verifyVisitorHash(string $uid, string $hash): bool
|
||||
{
|
||||
return hash_equals($this->generateVisitorHash($uid), $hash);
|
||||
}
|
||||
|
||||
public function getKeyForJs(): string
|
||||
{
|
||||
return base64_encode($this->key);
|
||||
}
|
||||
}
|
||||
22
src/Twig/AnalyticsExtension.php
Normal file
22
src/Twig/AnalyticsExtension.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig;
|
||||
|
||||
use App\Service\AnalyticsCryptoService;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\Extension\GlobalsInterface;
|
||||
|
||||
class AnalyticsExtension extends AbstractExtension implements GlobalsInterface
|
||||
{
|
||||
public function __construct(
|
||||
private AnalyticsCryptoService $crypto,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getGlobals(): array
|
||||
{
|
||||
return [
|
||||
'analytics_key' => $this->crypto->getKeyForJs(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,7 @@
|
||||
{% endblock %}
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col bg-[#fbfbfb] text-[#111827]" data-env="{{ app.environment }}">
|
||||
<body class="min-h-screen flex flex-col bg-[#fbfbfb] text-[#111827]" data-env="{{ app.environment }}" data-k="{{ analytics_key }}"{% if app.user and app.user.id is defined and app.request.cookies.get('e_ticket_consent') == 'accepted' %} data-uid="{{ app.user.id }}"{% endif %}>
|
||||
<header class="sticky top-0 z-50 bg-white border-b-4 border-gray-900">
|
||||
<nav class="mx-auto px-4 lg:px-8" role="navigation" aria-label="Navigation principale" itemscope itemtype="https://schema.org/SiteNavigationElement">
|
||||
<div class="flex justify-between items-center h-20">
|
||||
|
||||
44
tests/Entity/AnalyticsUniqIdTest.php
Normal file
44
tests/Entity/AnalyticsUniqIdTest.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Entity;
|
||||
|
||||
use App\Entity\AnalyticsUniqId;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AnalyticsUniqIdTest extends TestCase
|
||||
{
|
||||
public function testParseDeviceTypeMobile(): void
|
||||
{
|
||||
self::assertSame('mobile', AnalyticsUniqId::parseDeviceType('Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)'));
|
||||
self::assertSame('mobile', AnalyticsUniqId::parseDeviceType('Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0 Mobile Safari/537.36'));
|
||||
}
|
||||
|
||||
public function testParseDeviceTypeTablet(): void
|
||||
{
|
||||
self::assertSame('tablet', AnalyticsUniqId::parseDeviceType('Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X)'));
|
||||
}
|
||||
|
||||
public function testParseDeviceTypeDesktop(): void
|
||||
{
|
||||
self::assertSame('desktop', AnalyticsUniqId::parseDeviceType('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'));
|
||||
}
|
||||
|
||||
public function testParseOs(): void
|
||||
{
|
||||
self::assertSame('Windows', AnalyticsUniqId::parseOs('Mozilla/5.0 (Windows NT 10.0; Win64; x64)'));
|
||||
self::assertSame('macOS', AnalyticsUniqId::parseOs('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'));
|
||||
self::assertSame('iOS', AnalyticsUniqId::parseOs('Mozilla/5.0 (iPhone; CPU iPhone OS 16_0)'));
|
||||
self::assertSame('Android', AnalyticsUniqId::parseOs('Mozilla/5.0 (Linux; Android 13)'));
|
||||
self::assertSame('Linux', AnalyticsUniqId::parseOs('Mozilla/5.0 (X11; Linux x86_64)'));
|
||||
self::assertNull(AnalyticsUniqId::parseOs('UnknownBot/1.0'));
|
||||
}
|
||||
|
||||
public function testParseBrowser(): void
|
||||
{
|
||||
self::assertSame('Chrome', AnalyticsUniqId::parseBrowser('Mozilla/5.0 (Windows NT 10.0) Chrome/112.0.0.0 Safari/537.36'));
|
||||
self::assertSame('Firefox', AnalyticsUniqId::parseBrowser('Mozilla/5.0 (Windows NT 10.0) Gecko/20100101 Firefox/112.0'));
|
||||
self::assertSame('Safari', AnalyticsUniqId::parseBrowser('Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 Safari/605.1.15'));
|
||||
self::assertSame('Edge', AnalyticsUniqId::parseBrowser('Mozilla/5.0 (Windows NT 10.0) Chrome/112.0 Edg/112.0'));
|
||||
self::assertNull(AnalyticsUniqId::parseBrowser('UnknownBot/1.0'));
|
||||
}
|
||||
}
|
||||
89
tests/MessageHandler/AnalyticsMessageHandlerTest.php
Normal file
89
tests/MessageHandler/AnalyticsMessageHandlerTest.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\MessageHandler;
|
||||
|
||||
use App\Entity\AnalyticsEvent;
|
||||
use App\Entity\AnalyticsUniqId;
|
||||
use App\Entity\User;
|
||||
use App\Message\AnalyticsMessage;
|
||||
use App\MessageHandler\AnalyticsMessageHandler;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AnalyticsMessageHandlerTest extends TestCase
|
||||
{
|
||||
private function createVisitor(): AnalyticsUniqId
|
||||
{
|
||||
$visitor = new AnalyticsUniqId();
|
||||
$visitor->setUid('test-uid');
|
||||
$visitor->setHash('test-hash');
|
||||
$visitor->setIpHash('test-ip');
|
||||
$visitor->setUserAgent('test-ua');
|
||||
|
||||
return $visitor;
|
||||
}
|
||||
|
||||
public function testPageViewCreatesEvent(): void
|
||||
{
|
||||
$visitor = $this->createVisitor();
|
||||
|
||||
$visitorRepo = $this->createMock(EntityRepository::class);
|
||||
$visitorRepo->method('findOneBy')->with(['uid' => 'test-uid'])->willReturn($visitor);
|
||||
|
||||
$em = $this->createMock(EntityManagerInterface::class);
|
||||
$em->method('getRepository')->willReturn($visitorRepo);
|
||||
$em->expects(self::once())->method('persist')->with(self::callback(
|
||||
fn (AnalyticsEvent $e) => 'page_view' === $e->getEventName() && '/test' === $e->getUrl() && 'Test' === $e->getTitle()
|
||||
));
|
||||
$em->expects(self::once())->method('flush');
|
||||
|
||||
$handler = new AnalyticsMessageHandler($em);
|
||||
$handler(new AnalyticsMessage('test-uid', 'page_view', [
|
||||
'url' => '/test',
|
||||
'title' => 'Test',
|
||||
'referrer' => 'https://google.com',
|
||||
]));
|
||||
}
|
||||
|
||||
public function testSetUserLinksVisitorToUser(): void
|
||||
{
|
||||
$visitor = $this->createVisitor();
|
||||
$user = new User();
|
||||
$user->setEmail('test@test.fr');
|
||||
$user->setFirstName('Test');
|
||||
$user->setLastName('User');
|
||||
$user->setPassword('hashed');
|
||||
|
||||
$visitorRepo = $this->createMock(EntityRepository::class);
|
||||
$visitorRepo->method('findOneBy')->willReturn($visitor);
|
||||
|
||||
$userRepo = $this->createMock(EntityRepository::class);
|
||||
$userRepo->method('find')->with(42)->willReturn($user);
|
||||
|
||||
$em = $this->createMock(EntityManagerInterface::class);
|
||||
$em->method('getRepository')->willReturnCallback(function (string $class) use ($visitorRepo, $userRepo) {
|
||||
return AnalyticsUniqId::class === $class ? $visitorRepo : $userRepo;
|
||||
});
|
||||
$em->expects(self::once())->method('flush');
|
||||
|
||||
$handler = new AnalyticsMessageHandler($em);
|
||||
$handler(new AnalyticsMessage('test-uid', 'set_user', ['userId' => 42]));
|
||||
|
||||
self::assertSame($user, $visitor->getUser());
|
||||
}
|
||||
|
||||
public function testUnknownVisitorIsIgnored(): void
|
||||
{
|
||||
$repo = $this->createMock(EntityRepository::class);
|
||||
$repo->method('findOneBy')->willReturn(null);
|
||||
|
||||
$em = $this->createMock(EntityManagerInterface::class);
|
||||
$em->method('getRepository')->willReturn($repo);
|
||||
$em->expects(self::never())->method('persist');
|
||||
$em->expects(self::never())->method('flush');
|
||||
|
||||
$handler = new AnalyticsMessageHandler($em);
|
||||
$handler(new AnalyticsMessage('unknown', 'page_view', ['url' => '/']));
|
||||
}
|
||||
}
|
||||
85
tests/Service/AnalyticsCryptoServiceTest.php
Normal file
85
tests/Service/AnalyticsCryptoServiceTest.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\Service\AnalyticsCryptoService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AnalyticsCryptoServiceTest extends TestCase
|
||||
{
|
||||
private AnalyticsCryptoService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->service = new AnalyticsCryptoService('test_secret_key_for_analytics');
|
||||
}
|
||||
|
||||
public function testEncryptDecryptRoundTrip(): void
|
||||
{
|
||||
$data = ['uid' => 'abc-123', 'url' => '/test', 'title' => 'Test Page'];
|
||||
|
||||
$encrypted = $this->service->encrypt($data);
|
||||
self::assertNotEmpty($encrypted);
|
||||
|
||||
$decrypted = $this->service->decrypt($encrypted);
|
||||
self::assertSame($data, $decrypted);
|
||||
}
|
||||
|
||||
public function testDecryptInvalidPayloadReturnsNull(): void
|
||||
{
|
||||
self::assertNull($this->service->decrypt('invalid_base64'));
|
||||
self::assertNull($this->service->decrypt(base64_encode('short')));
|
||||
}
|
||||
|
||||
public function testDecryptTamperedPayloadReturnsNull(): void
|
||||
{
|
||||
$encrypted = $this->service->encrypt(['test' => true]);
|
||||
$tampered = substr($encrypted, 0, -2).'AA';
|
||||
|
||||
self::assertNull($this->service->decrypt($tampered));
|
||||
}
|
||||
|
||||
public function testGenerateAndVerifyVisitorHash(): void
|
||||
{
|
||||
$uid = 'test-uid-123';
|
||||
$hash = $this->service->generateVisitorHash($uid);
|
||||
|
||||
self::assertNotEmpty($hash);
|
||||
self::assertTrue($this->service->verifyVisitorHash($uid, $hash));
|
||||
}
|
||||
|
||||
public function testVerifyVisitorHashRejectsTampered(): void
|
||||
{
|
||||
$uid = 'test-uid-123';
|
||||
$hash = $this->service->generateVisitorHash($uid);
|
||||
|
||||
self::assertFalse($this->service->verifyVisitorHash('different-uid', $hash));
|
||||
self::assertFalse($this->service->verifyVisitorHash($uid, 'wrong_hash'));
|
||||
}
|
||||
|
||||
public function testGetKeyForJsReturnsBase64(): void
|
||||
{
|
||||
$key = $this->service->getKeyForJs();
|
||||
self::assertNotEmpty($key);
|
||||
self::assertNotFalse(base64_decode($key, true));
|
||||
}
|
||||
|
||||
public function testDifferentSecretsProduceDifferentKeys(): void
|
||||
{
|
||||
$service2 = new AnalyticsCryptoService('different_secret');
|
||||
|
||||
$uid = 'test-uid';
|
||||
$hash1 = $this->service->generateVisitorHash($uid);
|
||||
$hash2 = $service2->generateVisitorHash($uid);
|
||||
|
||||
self::assertNotSame($hash1, $hash2);
|
||||
}
|
||||
|
||||
public function testEncryptedDataCannotBeDecryptedByDifferentKey(): void
|
||||
{
|
||||
$service2 = new AnalyticsCryptoService('different_secret');
|
||||
|
||||
$encrypted = $this->service->encrypt(['test' => true]);
|
||||
self::assertNull($service2->decrypt($encrypted));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user