From 3b85ebbe124a05919e1cfd4578c21b09687d8801 Mon Sep 17 00:00:00 2001 From: "Sebastian BURGIN-FIX (ext)" Date: Sun, 25 Oct 2026 09:25:33 +0100 Subject: [PATCH] feat(analytics): load codebar analytics on the feedback page In production, when CODEBAR_ANALYTICS_URL is set, the signed feedback page loads the analytics.codebar.ch tracker with the page's nonce and crossorigin="anonymous" (no third-party cookie). The CSP gains connect-src for the analytics origin only then; everything else stays default-src 'none'. The tracker sends the page URL including the link signature, which analytics drops with the query string before storing. Co-Authored-By: Claude Opus 5.5 --- .env.example | 2 ++ .../Controllers/FeedbackPageController.php | 15 +++++++++++++-- config/default.php | 9 +++++++++ resources/views/feedback/show.blade.php | 3 +++ tests/Feature/FeedbackLinkTest.php | 19 +++++++++++++++++++ 5 files changed, 46 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 4692004..2c5baa8 100644 --- a/.env.example +++ b/.env.example @@ -47,3 +47,5 @@ OH_DEAR_HEALTH_CHECK_PATH=/oh-dear-health-check-results NIGHTWATCH_API_TOKEN= NIGHTWATCH_APPLICATION_ID= NIGHTWATCH_ENVIRONMENT_ID= + +CODEBAR_ANALYTICS_URL=https://analytics.codebar.ch diff --git a/app/Http/Controllers/FeedbackPageController.php b/app/Http/Controllers/FeedbackPageController.php index 97b2942..52cb28c 100644 --- a/app/Http/Controllers/FeedbackPageController.php +++ b/app/Http/Controllers/FeedbackPageController.php @@ -15,7 +15,7 @@ * Page of one feedback entry behind a signed link (feedback:link, share link): * screenshot with the sender's message, technical details in a second tab. * The only HTML the API serves: one nonce'd inline script (tabs, copy - * buttons), nothing loaded from other hosts. + * buttons) and, in production, the nonce'd codebar analytics tracker. */ final class FeedbackPageController { @@ -32,6 +32,7 @@ public function __invoke(Request $request, FeedbackEntry $entry): Response $environment = self::environment($entry); $console = self::rows($entry->console_log, ['type', 'message', 'source', 'line', 'column', 'stack']); $network = self::rows($entry->network_log, ['method', 'url', 'status', 'duration']); + $analyticsUrl = self::analyticsUrl(); $customData = $entry->custom_data === null || $entry->custom_data === [] ? null : (string) json_encode($entry->custom_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); return response()->view('feedback.show', [ @@ -51,14 +52,24 @@ public function __invoke(Request $request, FeedbackEntry $entry): Response 'statusUrl' => $request->boolean('manage') ? FeedbackLink::status($entry, $expires, is_string($by) ? $by : null) : null, 'expires' => $expires, 'nonce' => $nonce, + 'analyticsScript' => $analyticsUrl === null ? null : $analyticsUrl.'/js/script.js', ])->withHeaders([ - 'Content-Security-Policy' => "default-src 'none'; img-src 'self'; style-src 'nonce-{$nonce}'; script-src 'nonce-{$nonce}'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", + 'Content-Security-Policy' => "default-src 'none'; img-src 'self'; style-src 'nonce-{$nonce}'; script-src 'nonce-{$nonce}'; ".($analyticsUrl === null ? '' : "connect-src {$analyticsUrl}; ")."base-uri 'none'; form-action 'self'; frame-ancestors 'none'", 'Cache-Control' => 'private, no-store', 'Referrer-Policy' => 'no-referrer', 'X-Content-Type-Options' => 'nosniff', ]); } + /** Origin of codebar analytics when the tracker runs in this environment. */ + private static function analyticsUrl(): ?string + { + $url = config('default.codebar_analytics.url'); + $environments = (array) config('default.codebar_analytics.environments', []); + + return is_string($url) && $url !== '' && app()->environment($environments) ? rtrim($url, '/') : null; + } + /** @return array label => value, only what is known */ private static function environment(FeedbackEntry $entry): array { diff --git a/config/default.php b/config/default.php index 4e3e693..e52dc13 100644 --- a/config/default.php +++ b/config/default.php @@ -17,4 +17,13 @@ 'environment_id' => env('NIGHTWATCH_ENVIRONMENT_ID'), ], + /* + * codebar analytics (analytics.codebar.ch) on the feedback page, production only. + * The tracker sends the page URL; analytics drops the query string (the link signature). + */ + 'codebar_analytics' => [ + 'url' => env('CODEBAR_ANALYTICS_URL'), + 'environments' => ['production'], + ], + ]; diff --git a/resources/views/feedback/show.blade.php b/resources/views/feedback/show.blade.php index 9b82d36..5ac566d 100644 --- a/resources/views/feedback/show.blade.php +++ b/resources/views/feedback/show.blade.php @@ -385,5 +385,8 @@ }); })(); +@if ($analyticsScript) + +@endif diff --git a/tests/Feature/FeedbackLinkTest.php b/tests/Feature/FeedbackLinkTest.php index f313c5d..5ae986e 100644 --- a/tests/Feature/FeedbackLinkTest.php +++ b/tests/Feature/FeedbackLinkTest.php @@ -138,6 +138,25 @@ function entryWithShot(FeedbackProject $project, array $attributes = []): Feedba ->assertDontSee('Download'); }); +it('loads the nonced codebar analytics tracker in production only', function (): void { + config(['default.codebar_analytics.url' => 'https://analytics.codebar.ch/']); + $entry = entryWithShot($this->project); + $url = feedbackLink($entry->id)['url']; + + $local = test()->get($url, NAVIGATE)->assertOk(); + expect((string) $local->headers->get('Content-Security-Policy'))->not->toContain('connect-src') + ->and((string) $local->getContent())->not->toContain('analytics.codebar.ch'); + + app()['env'] = 'production'; + $response = test()->get($url, NAVIGATE)->assertOk(); + $csp = (string) $response->headers->get('Content-Security-Policy'); + preg_match("/'nonce-([^']+)'/", $csp, $nonce); + + expect($csp)->toContain('connect-src https://analytics.codebar.ch;')->toContain("default-src 'none'") + ->and((string) $response->getContent())->toContain('') + ->and(substr_count((string) $response->getContent(), 'toBe(2); +}); + it('escapes everything the browser sent', function (): void { $entry = FeedbackEntry::factory()->for($this->project, 'project')->create([ 'message' => '',