Add Google Analytics 4 with cookie consent integration
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m2s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m2s
- Add GA4 measurement ID to config (default: G-J0PCVT14NY) - Add /api/config endpoint to expose GA ID to frontend - Update cookie consent with Analytics category (opt-in) - Load GA4 only after user consents to analytics cookies - Update CSP to allow Google Analytics domains Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -65,11 +65,11 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
# Content Security Policy
|
# Content Security Policy
|
||||||
response.headers["Content-Security-Policy"] = (
|
response.headers["Content-Security-Policy"] = (
|
||||||
"default-src 'self'; "
|
"default-src 'self'; "
|
||||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com; "
|
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com https://www.googletagmanager.com; "
|
||||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net https://unpkg.com; "
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net https://unpkg.com; "
|
||||||
"font-src 'self' https://fonts.gstatic.com; "
|
"font-src 'self' https://fonts.gstatic.com; "
|
||||||
"img-src 'self' data: https://*.tile.openstreetmap.org https://unpkg.com; "
|
"img-src 'self' data: https://*.tile.openstreetmap.org https://unpkg.com https://www.google-analytics.com; "
|
||||||
"connect-src 'self' https://cdn.jsdelivr.net https://*.tile.openstreetmap.org https://unpkg.com; "
|
"connect-src 'self' https://cdn.jsdelivr.net https://*.tile.openstreetmap.org https://unpkg.com https://www.google-analytics.com https://analytics.google.com https://*.google-analytics.com; "
|
||||||
"frame-ancestors 'none'; "
|
"frame-ancestors 'none'; "
|
||||||
"base-uri 'self'; "
|
"base-uri 'self'; "
|
||||||
"form-action 'self';"
|
"form-action 'self';"
|
||||||
@@ -199,6 +199,14 @@ async def serve_rankings():
|
|||||||
return FileResponse(settings.frontend_dir / "index.html")
|
return FileResponse(settings.frontend_dir / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/config")
|
||||||
|
async def get_config():
|
||||||
|
"""Return public configuration for the frontend."""
|
||||||
|
return {
|
||||||
|
"ga_measurement_id": settings.ga_measurement_id
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/schools")
|
@app.get("/api/schools")
|
||||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||||
async def get_schools(
|
async def get_schools(
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ class Settings(BaseSettings):
|
|||||||
rate_limit_burst: int = 10 # Allow burst of requests
|
rate_limit_burst: int = 10 # Allow burst of requests
|
||||||
max_request_size: int = 1024 * 1024 # 1MB max request size
|
max_request_size: int = 1024 * 1024 # 1MB max request size
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
ga_measurement_id: Optional[str] = "G-J0PCVT14NY" # Google Analytics 4 Measurement ID
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
env_file_encoding = "utf-8"
|
env_file_encoding = "utf-8"
|
||||||
|
|||||||
@@ -354,6 +354,38 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
|
|
||||||
|
<!-- Google Analytics (loaded conditionally after consent) -->
|
||||||
|
<script>
|
||||||
|
var GA_MEASUREMENT_ID = null;
|
||||||
|
|
||||||
|
// Fetch GA ID from server config
|
||||||
|
fetch('/api/config')
|
||||||
|
.then(function(response) { return response.json(); })
|
||||||
|
.then(function(config) {
|
||||||
|
if (config.ga_measurement_id) {
|
||||||
|
GA_MEASUREMENT_ID = config.ga_measurement_id;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(err) { console.warn('Failed to load config:', err); });
|
||||||
|
|
||||||
|
function loadGoogleAnalytics() {
|
||||||
|
if (window.gaLoaded || !GA_MEASUREMENT_ID) return;
|
||||||
|
window.gaLoaded = true;
|
||||||
|
|
||||||
|
var script = document.createElement('script');
|
||||||
|
script.async = true;
|
||||||
|
script.src = 'https://www.googletagmanager.com/gtag/js?id=' + GA_MEASUREMENT_ID;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
window.gtag = gtag;
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', GA_MEASUREMENT_ID);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- Cookie Consent Banner -->
|
<!-- Cookie Consent Banner -->
|
||||||
<script src="https://cdn.jsdelivr.net/gh/silktide/consent-manager@main/silktide-consent-manager.js"></script>
|
<script src="https://cdn.jsdelivr.net/gh/silktide/consent-manager@main/silktide-consent-manager.js"></script>
|
||||||
<script>
|
<script>
|
||||||
@@ -365,14 +397,26 @@
|
|||||||
description: 'Essential cookies required for the website to function properly.',
|
description: 'Essential cookies required for the website to function properly.',
|
||||||
required: true,
|
required: true,
|
||||||
defaultValue: true
|
defaultValue: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'analytics',
|
||||||
|
label: 'Analytics',
|
||||||
|
description: 'Help us understand how visitors use our site so we can improve it.',
|
||||||
|
required: false,
|
||||||
|
defaultValue: false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
text: {
|
text: {
|
||||||
title: 'Cookie Preferences',
|
title: 'Cookie Preferences',
|
||||||
description: 'This website does not use tracking cookies. We only use essential cookies required for the site to function.',
|
description: 'We use cookies to improve your experience. Analytics cookies help us understand how you use the site.',
|
||||||
acceptAll: 'Accept',
|
acceptAll: 'Accept All',
|
||||||
rejectAll: 'Reject All',
|
rejectAll: 'Reject All',
|
||||||
save: 'Save Preferences'
|
save: 'Save Preferences'
|
||||||
|
},
|
||||||
|
onConsentChange: function(consent) {
|
||||||
|
if (consent.analytics) {
|
||||||
|
loadGoogleAnalytics();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user