From 9a520eebf2a5252814d1433a8b9b6b28bdaa622a Mon Sep 17 00:00:00 2001 From: jmeridth Date: Sat, 30 May 2026 00:56:51 -0500 Subject: [PATCH] feat: add GDPR consent banner gating GA4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Add a hand-rolled GDPR cookie/analytics consent banner that gates GA4 loading on explicit user action. New `_includes/consent_banner.html` ships the dialog markup (hidden by default via the `hidden` attribute). New `assets/js/consent.js` owns show/hide logic, keyboard accessibility, localStorage persistence, and dynamic injection of `gtag.js` only after the user clicks Accept. `assets/css/custom.css` gets the banner styling appended (themed via Bulma CSS variables so it adapts to dark mode automatically). `_includes/footer.html` is rewritten to emit `window.GA_MEASUREMENT_ID` via Liquid's `jsonify` filter, include the banner markup, load consent.js, and add a "Cookie preferences" footer link that re-opens the banner after a choice has been made. ## Why PR #81 added the GA4 measurement tag but loaded gtag.js unconditionally on every production page view — not GDPR-compliant for EU visitors. This commit gates the GA4 script injection on an explicit "Accept all" click; "Decline non-essential" records the choice and never loads any third-party tracking. Escape dismisses without recording, so the banner re-appears on the next visit (consent must be explicit, not implicit). The implementation is hand-rolled to match the codebase's existing posture (theme.js follows the same pattern); if more third-party scripts are added later, switching to a library with category-based consent (cookieconsent v3, Klaro) would scale better. ## Notes - The banner is non-modal (no `aria-modal`): page scrolling is not blocked. `role="dialog"` is declared, Escape closes, focus moves to the Decline button on show and restores on close. Focus trap is intentionally not implemented since the banner is non-modal. - GA4 ID is interpolated via `{{ site.google_analytics_id | jsonify }}` rather than bare string concat, so future `_config.yml` values containing quotes or `` cannot escape the JS string context. - A `window.ANALYTICS_DRY_RUN` flag is set in non-production builds. In that mode, consent.js logs `[consent] dry run: ...` to console instead of injecting gtag.js — local testing of the banner UI does not pollute production analytics. - A three-model security review (opus/sonnet/haiku) flagged three additional findings that were **considered and intentionally not addressed in this PR**: - **localStorage tampering**: any same-origin script can set `localStorage['analytics-consent'] = 'accepted'` and bypass the banner on the next load. Accepted risk — no first-party untrusted scripts run on the site, and HMAC-signing the consent value is overkill for a personal blog. - **Inline scripts block strict CSP**: `_includes/footer.html` and `_includes/head.html` both have inline ` @@ -21,13 +30,12 @@ -{% if jekyll.environment == "production" and site.google_analytics_id %} - - +{% if site.google_analytics_id %} + {% endif %} diff --git a/assets/css/custom.css b/assets/css/custom.css index 138da50..b50f469 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -268,3 +268,48 @@ p { [data-theme="dark"] .highlight pre { background-color: transparent; } + +/* + * GDPR cookie/analytics consent banner. Hidden by default via the [hidden] + * attribute; consent.js shows it when no choice is recorded and an + * analytics ID is configured. + */ +.consent-banner { + position: fixed; + bottom: 1rem; + left: 1rem; + right: 1rem; + max-width: 32rem; + margin: 0 auto; + padding: 1rem 1.25rem; + border-radius: 6px; + background: var(--bulma-scheme-main-bis); + color: var(--bulma-text); + border: 1px solid var(--bulma-border); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; + font-family: "Open Sans", sans-serif; + font-size: 0.875rem; +} + +.consent-banner-title { + font-weight: 700; + margin: 0 0 0.5rem 0; +} + +.consent-banner-text { + margin: 0 0 0.75rem 0; + line-height: 1.4; +} + +.consent-banner-buttons { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: flex-end; +} + +.consent-footer-link { + font-size: 0.75rem; + margin-top: 0.5rem !important; +} diff --git a/assets/js/consent.js b/assets/js/consent.js new file mode 100644 index 0000000..f30a186 --- /dev/null +++ b/assets/js/consent.js @@ -0,0 +1,114 @@ +--- +layout: null +--- +(function () { + var STORAGE_KEY = 'analytics-consent'; + var ACCEPTED = 'accepted'; + var DECLINED = 'declined'; + + function getStored() { + try { + return localStorage.getItem(STORAGE_KEY); + } catch (e) { + return null; + } + } + + function setStored(value) { + try { + localStorage.setItem(STORAGE_KEY, value); + } catch (e) {} + } + + function loadAnalytics() { + if (!window.GA_MEASUREMENT_ID) return; + if (document.getElementById('gtag-script')) return; + + if (window.ANALYTICS_DRY_RUN) { + console.log('[consent] dry run: would load GA4 with id ' + window.GA_MEASUREMENT_ID); + return; + } + + var script = document.createElement('script'); + script.async = true; + script.id = 'gtag-script'; + script.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(window.GA_MEASUREMENT_ID); + document.head.appendChild(script); + + window.dataLayer = window.dataLayer || []; + function gtag() { window.dataLayer.push(arguments); } + window.gtag = gtag; + gtag('js', new Date()); + gtag('config', window.GA_MEASUREMENT_ID); + } + + var previousFocus = null; + + function onKeydown(e) { + if (e.key === 'Escape' || e.keyCode === 27) { + e.preventDefault(); + hideBanner(); + } + } + + function showBanner() { + var banner = document.getElementById('consent-banner'); + if (!banner) return; + previousFocus = document.activeElement; + banner.hidden = false; + var declineBtn = banner.querySelector('.consent-decline'); + if (declineBtn && declineBtn.focus) declineBtn.focus(); + document.addEventListener('keydown', onKeydown); + } + + function hideBanner() { + var banner = document.getElementById('consent-banner'); + if (banner) banner.hidden = true; + document.removeEventListener('keydown', onKeydown); + if (previousFocus && previousFocus.focus) { + try { previousFocus.focus(); } catch (e) {} + } + previousFocus = null; + } + + function accept() { + setStored(ACCEPTED); + hideBanner(); + loadAnalytics(); + } + + function decline() { + setStored(DECLINED); + hideBanner(); + } + + function init() { + if (!window.GA_MEASUREMENT_ID) return; + + var current = getStored(); + if (current === ACCEPTED) { + loadAnalytics(); + } else if (current !== DECLINED) { + showBanner(); + } + + var acceptBtn = document.querySelector('#consent-banner .consent-accept'); + var declineBtn = document.querySelector('#consent-banner .consent-decline'); + if (acceptBtn) acceptBtn.addEventListener('click', accept); + if (declineBtn) declineBtn.addEventListener('click', decline); + + var prefLink = document.getElementById('cookie-preferences-link'); + if (prefLink) { + prefLink.addEventListener('click', function (e) { + e.preventDefault(); + showBanner(); + }); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})();