diff --git a/go.mod b/go.mod index a6e1b95f..01c11204 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 - golang.org/x/crypto v0.55.0 + golang.org/x/crypto v0.56.0 golang.org/x/term v0.45.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 66a6ee20..82173aec 100644 --- a/go.sum +++ b/go.sum @@ -77,8 +77,8 @@ github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4= go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= diff --git a/site/src/components/Analytics.astro b/site/src/components/Analytics.astro index a6393151..9e40de78 100644 --- a/site/src/components/Analytics.astro +++ b/site/src/components/Analytics.astro @@ -2,7 +2,7 @@ // Google Analytics 4, on the terms this product argues for everywhere else. // // A documentation site for a tool whose pitch is "we do not silently claim -// anything" cannot ship tracking that behaves like everyone else's. Four rules +// anything" cannot ship tracking that behaves like everyone else's. Five rules // follow from that, and each is enforced here rather than left to a setting in // a console nobody in this repository can read: // @@ -10,21 +10,49 @@ // That is the state for `npm run dev`, for `just check`, for CI, and for // anyone who builds this site from a fork. Analytics belongs to whoever // runs onebox.run, not to the source. -// 2. Cookieless. `client_storage: "none"` keeps GA4 from writing `_ga`, which -// is what makes a consent banner necessary in the first place. Page views -// still count; returning readers are simply not re-identified. A banner -// asking permission to do something the site does not need to do would be -// the same failure this product refuses elsewhere. +// 2. Nothing reaches Google until measurement is allowed: no tag, no +// request, no identifier. One request is made before the answer is +// known, and it goes to this site's own /cdn-cgi/trace to find out +// whether the question has to be asked at all -- see below. // 3. No advertising signals. Google Signals and ad personalisation are off, // so the traffic here is not joined to an advertising profile. // 4. Do Not Track and Global Privacy Control are honoured before the script // loads, not after. A reader who has asked not to be measured generates no -// request to Google at all. +// request to Google at all, and is never asked the question either. +// 5. Withdrawal works. The control on /privacy revokes what acceptance set. // -// The query string is dropped from the recorded location. Documentation URLs -// carry nothing sensitive today, but this repository's whole position on -// secrets is that "nothing sensitive is in there today" is not a property you -// get to assume — verifyURL redacts queries for exactly this reason. +// Rule 2 replaces what this file used to do. It loaded gtag on every page and +// then held it in Consent Mode's denied state, which was described here as +// being cookieless — true, and the reason given was that a banner asking to do +// something the site does not need to do would be the failure this product +// refuses elsewhere. That argument was right about banners and wrong about what +// it bought. Denied mode still sent a hit for every page view; it simply sent +// one with no stable client id, so every view arrived as a new user in a new +// session. The site shipped Google's tag to every reader and got numbers in +// which users, sessions and returning readers were all noise. Refusing to ask +// is only principled when the answer is that nothing is collected, and +// something was. +// +// So the question is asked, but only of the readers who are owed it. The EEA, +// the UK and the Crown Dependencies require permission before an analytics +// identifier is stored; elsewhere measurement may run with a way to turn it +// off. Cloudflare already knows which applies and says so at /cdn-cgi/trace, so +// most readers are measured properly and never see a banner. A failed or +// unavailable lookup asks — the safe direction, and what a build outside +// Cloudflare gets. +// +// The query string is dropped from the recorded location, and the referrer is +// reduced to its origin. Documentation URLs carry nothing sensitive today, but +// this repository's whole position on secrets is that "nothing sensitive is in +// there today" is not a property you get to assume — verifyURL redacts queries +// for exactly this reason. +// +// The preference lives in localStorage rather than a cookie, so declining +// stores nothing that travels with a request, and every other open tab is told +// about the change and acts on it. A browser that refuses storage entirely +// keeps the answer in memory instead, where it governs the page it was given on +// and no other -- the direction that does not weaken the gate, but not one to +// describe as mirrored. // // The script body below is written as the element's plain text content, with no // `{...}` expression and no backticks around it. Astro treats script children as @@ -59,49 +87,278 @@ const enabled = Boolean(measurementId) && import.meta.env.PROD; enabled && ( ) diff --git a/site/src/components/AnalyticsConsent.astro b/site/src/components/AnalyticsConsent.astro new file mode 100644 index 00000000..d875da08 --- /dev/null +++ b/site/src/components/AnalyticsConsent.astro @@ -0,0 +1,149 @@ +--- +/* The consent prompt, for readers whose location requires one. + * + * Markup only. Analytics.astro decides whether this is ever shown and handles + * the clicks: this renders hidden and is revealed by that script alone, once it + * has either a stored preference or an answer from the location lookup. Putting + * the logic in two places is how the banner ends up shown to someone who + * already declined. + * + * It renders on every page rather than only where the site footer does, because + * a reader who arrives from a search result lands on a documentation page, and + * a consent prompt that only appears on the landing page asks the question of + * the wrong half of the audience. + * + * `hidden` in the source, not `display: none` in a stylesheet: the prompt is + * absent for the reader whose location does not require it and for the reader + * who has already answered, and neither should see it flash while a stylesheet + * loads. A build without a measurement id never renders it at all. + */ +const enabled = + Boolean(import.meta.env.PUBLIC_GA_MEASUREMENT_ID?.trim()) && import.meta.env.PROD; +--- + +{ + enabled && ( + + ) +} + + diff --git a/site/src/components/AnalyticsPreference.astro b/site/src/components/AnalyticsPreference.astro new file mode 100644 index 00000000..ea1b5e9a --- /dev/null +++ b/site/src/components/AnalyticsPreference.astro @@ -0,0 +1,170 @@ +--- +/* The standing analytics control, for the privacy page. + * + * The banner asks once. This is where the answer is changed afterwards, which + * is the half of consent that is easy to leave out: permission that cannot be + * withdrawn as readily as it was given was never permission. + * + * It states the current situation before offering the buttons, because "allowed + * or not" is the question a reader arrives with, and in the regions that do not + * require a prompt the honest answer is that measurement is already running. + * + * The status line is scripted rather than rendered, since the answer lives in + * the reader's browser and this site is static: every reader would otherwise be + * served the same cached sentence about a choice that is theirs alone. + */ +const enabled = + Boolean(import.meta.env.PUBLIC_GA_MEASUREMENT_ID?.trim()) && import.meta.env.PROD; +--- + +{ + enabled ? ( + <> +

+ The site uses Google Analytics 4 to count visits and see which pages get read. It runs + only when it is allowed to, and it is configured to learn as little as will still + answer that question. +

+
+

+ Checking whether analytics are running in this browser. +

+
+ + +
+
+ + ) : ( +

+ This build of the site has no analytics configured, so nothing is measured, nothing is + stored, and there is nothing to turn off. The rest of this section describes what a build + that has them does. +

+ ) +} + + + + diff --git a/site/src/components/Footer.astro b/site/src/components/Footer.astro index bcf629d8..a3d41383 100644 --- a/site/src/components/Footer.astro +++ b/site/src/components/Footer.astro @@ -50,6 +50,7 @@ const isSplash = Astro.locals.starlightRoute.entry.data.template === "splash";
+ Privacy
) } @@ -75,4 +76,20 @@ const isSplash = Astro.locals.starlightRoute.entry.data.template === "splash"; margin-inline-start: auto; } + /* Outside the regions that require a consent prompt no banner is ever + shown, so the control on /privacy is the only way to turn analytics + off -- and SiteFooter, which carries the other link to it, renders on + splash pages alone. A reader arriving from a search result lands here + and would otherwise have no route to it. One text link, not the whole + band. */ + .meta a { + color: var(--sl-color-gray-3); + text-decoration: none; + } + + .meta a:hover { + color: var(--sl-color-white); + text-decoration: underline; + } + diff --git a/site/src/components/PageFrame.astro b/site/src/components/PageFrame.astro index 255b3110..86a2c566 100644 --- a/site/src/components/PageFrame.astro +++ b/site/src/components/PageFrame.astro @@ -24,6 +24,7 @@ */ import MobileMenuToggle from "@astrojs/starlight/components/MobileMenuToggle.astro"; import SiteFooter from "./SiteFooter.astro"; +import AnalyticsConsent from "./AnalyticsConsent.astro"; const { hasSidebar } = Astro.locals.starlightRoute; const isSplash = Astro.locals.starlightRoute.entry.data.template === "splash"; @@ -45,6 +46,7 @@ const isSplash = Astro.locals.starlightRoute.entry.data.template === "splash"; }
{isSplash && } +