diff --git a/assets/app.js b/assets/app.js index aa0920b..0c30be9 100644 --- a/assets/app.js +++ b/assets/app.js @@ -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) => { diff --git a/assets/modules/analytics.js b/assets/modules/analytics.js new file mode 100644 index 0000000..205b10c --- /dev/null +++ b/assets/modules/analytics.js @@ -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 }) +} diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index 2c88d66..c7bb5dd 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -23,3 +23,4 @@ framework: Symfony\Component\Notifier\Message\SmsMessage: async App\Message\MeilisearchMessage: async + App\Message\AnalyticsMessage: async diff --git a/config/packages/nelmio_security.yaml b/config/packages/nelmio_security.yaml index ffe04a1..04683e8 100644 --- a/config/packages/nelmio_security.yaml +++ b/config/packages/nelmio_security.yaml @@ -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 diff --git a/config/packages/prod/nelmio_security.yaml b/config/packages/prod/nelmio_security.yaml index 0f974cb..0be43a6 100644 --- a/config/packages/prod/nelmio_security.yaml +++ b/config/packages/prod/nelmio_security.yaml @@ -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 diff --git a/migrations/Version20260326105040.php b/migrations/Version20260326105040.php new file mode 100644 index 0000000..6cae544 --- /dev/null +++ b/migrations/Version20260326105040.php @@ -0,0 +1,44 @@ +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'); + } +} diff --git a/src/Controller/AnalyticsController.php b/src/Controller/AnalyticsController.php new file mode 100644 index 0000000..eaf3447 --- /dev/null +++ b/src/Controller/AnalyticsController.php @@ -0,0 +1,101 @@ +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; + } +} diff --git a/src/Entity/AnalyticsEvent.php b/src/Entity/AnalyticsEvent.php new file mode 100644 index 0000000..ed7aff1 --- /dev/null +++ b/src/Entity/AnalyticsEvent.php @@ -0,0 +1,110 @@ +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; + } +} diff --git a/src/Entity/AnalyticsUniqId.php b/src/Entity/AnalyticsUniqId.php new file mode 100644 index 0000000..1594453 --- /dev/null +++ b/src/Entity/AnalyticsUniqId.php @@ -0,0 +1,267 @@ + */ + #[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 + */ + 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; + } +} diff --git a/src/Message/AnalyticsMessage.php b/src/Message/AnalyticsMessage.php new file mode 100644 index 0000000..821f7d1 --- /dev/null +++ b/src/Message/AnalyticsMessage.php @@ -0,0 +1,16 @@ + $payload + */ + public function __construct( + public readonly string $uid, + public readonly string $action, + public readonly array $payload = [], + ) { + } +} diff --git a/src/MessageHandler/AnalyticsMessageHandler.php b/src/MessageHandler/AnalyticsMessageHandler.php new file mode 100644 index 0000000..29ae2db --- /dev/null +++ b/src/MessageHandler/AnalyticsMessageHandler.php @@ -0,0 +1,52 @@ +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(); + } + } +} diff --git a/src/Service/AnalyticsCryptoService.php b/src/Service/AnalyticsCryptoService.php new file mode 100644 index 0000000..1646093 --- /dev/null +++ b/src/Service/AnalyticsCryptoService.php @@ -0,0 +1,59 @@ +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); + } +} diff --git a/src/Twig/AnalyticsExtension.php b/src/Twig/AnalyticsExtension.php new file mode 100644 index 0000000..45f9317 --- /dev/null +++ b/src/Twig/AnalyticsExtension.php @@ -0,0 +1,22 @@ + $this->crypto->getKeyForJs(), + ]; + } +} diff --git a/templates/base.html.twig b/templates/base.html.twig index ed7265f..573d55a 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -84,7 +84,7 @@ {% endblock %} {% block head %}{% endblock %} - +