diff --git a/tests/test_site_nav.py b/tests/test_site_nav.py index fd66f61fd..f445b6765 100644 --- a/tests/test_site_nav.py +++ b/tests/test_site_nav.py @@ -427,7 +427,7 @@ START_HERE = [ "/retrobios/", "/retrobios/wiki/what-are-bios-files/", "/retrobios/which-pack/", "/retrobios/wiki/first-install/", "/retrobios/wiki/troubleshooting/", "/retrobios/wiki/faq/", "/retrobios/wiki/legal/", "/retrobios/wiki/glossary/", - "/retrobios/platforms/", + "/retrobios/wiki/privacy/", "/retrobios/platforms/", ] diff --git a/tests/test_site_privacy.py b/tests/test_site_privacy.py new file mode 100644 index 000000000..a478c836b --- /dev/null +++ b/tests/test_site_privacy.py @@ -0,0 +1,79 @@ +"""What the site counts stays what wiki/privacy.md says it counts. + +The counter is website/src/scripts/count.ts. These checks read its source: +the guarantees are in a few lines that a later edit could drop without any +page looking different. +""" +from __future__ import annotations + +import re +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +COUNTER = REPO_ROOT / "website" / "src" / "scripts" / "count.ts" +FOOTER = REPO_ROOT / "website" / "src" / "components" / "Footer.astro" +PRIVACY = REPO_ROOT / "wiki" / "privacy.md" + + +class TheCounterAsksNothingOfTheDevice(unittest.TestCase): + def setUp(self) -> None: + self.source = COUNTER.read_text(encoding="utf-8") + # Comments state the rules: only the code must keep them. + self.code = re.sub(r"^\s*//.*$", "", self.source, flags=re.M) + + def test_no_storage_on_the_device(self) -> None: + for api in ("document.cookie", "localStorage", "sessionStorage", "indexedDB"): + with self.subTest(api=api): + self.assertNotIn(api, self.code) + + def test_a_declined_browser_sends_nothing(self) -> None: + self.assertIn("doNotTrack === '1'", self.code) + self.assertIn("globalPrivacyControl === true", self.code) + self.assertRegex(self.code, r"const send = .*\n\tif \(!enabled\) return;") + + def test_only_the_published_site_counts(self) -> None: + self.assertIn("location.hostname === HOST", self.code) + + def test_no_query_anchor_or_device_detail_is_read(self) -> None: + for read in ("location.search", "location.hash", "location.href,", "screen.", "navigator.language"): + with self.subTest(read=read): + self.assertNotIn(read, self.code) + + def test_the_referrer_is_reduced_to_its_domain(self) -> None: + self.assertIn("from.origin", self.code) + self.assertNotIn("from.pathname", self.code) + + def test_a_search_with_personal_details_is_not_sent(self) -> None: + self.assertIn("!query.includes('@')", self.code) + self.assertIn("\\d{7,}", self.code) + self.assertIn("message.startsWith('No results for')", self.code) + + def test_one_endpoint(self) -> None: + self.assertEqual(set(re.findall(r"https://[\w.-]+", self.code)), + {"https://retrobios.goatcounter.com"}) + + +class ThePageSaysWhatIsCounted(unittest.TestCase): + def test_every_page_loads_the_counter_and_links_the_page(self) -> None: + footer = FOOTER.read_text(encoding="utf-8") + self.assertIn("import '../scripts/count.ts';", footer) + self.assertIn("wiki/privacy/", footer) + # Hidden until the script shows it: a declined browser is not asked. + self.assertRegex(footer, r"data-rb-helpful-box hidden") + + def test_the_page_names_each_thing_sent(self) -> None: + page = PRIVACY.read_text(encoding="utf-8") + for said in ("No cookies", "Do Not Track", "Global Privacy Control", + "copy button", "found nothing", "Did this page help?", + "The installer sends nothing back", "retrobios.goatcounter.com"): + with self.subTest(said=said): + self.assertIn(said, page) + + def test_the_installer_has_no_counter(self) -> None: + installer = (REPO_ROOT / "install.py").read_text(encoding="utf-8") + self.assertNotIn("goatcounter", installer.lower()) + + +if __name__ == "__main__": + unittest.main() diff --git a/website/src/components/Footer.astro b/website/src/components/Footer.astro index c70aae7ea..ba5c39970 100644 --- a/website/src/components/Footer.astro +++ b/website/src/components/Footer.astro @@ -1,7 +1,8 @@ --- // Starlight's footer (edit link, previous and next), who keeps the project // and where to report a problem, the release the packs come from, and the -// licence line. The release is read from release.json, written when the +// licence line, and "Did this page help?" (src/scripts/count.ts, shown +// only where the answer is counted). The release is read from release.json, written when the // packs were published: the site may be newer than the packs. None of it // is in the search index: "MIT for the tooling" was the excerpt of short // pages, and "bios", "release" and "problem" matched every page. @@ -24,9 +25,16 @@ const date = releaseDate(tag); + + diff --git a/website/src/scripts/count.ts b/website/src/scripts/count.ts new file mode 100644 index 000000000..a401fc8d6 --- /dev/null +++ b/website/src/scripts/count.ts @@ -0,0 +1,152 @@ +// What the site counts, as wiki/privacy.md states it. No cookie, no storage +// on the device, no identifier: one request per page or action to +// GoatCounter, which keeps totals. Nothing is sent when the browser asks +// not to be tracked (Do Not Track, Global Privacy Control), from a local +// build, or from a page not served at the site's address. +// +// Sent: the page path (never its query or #anchor), the domain of the site +// the reader came from, and the name of an action. An action is a click on +// a copy button, a pack or file download, a link that leaves the site, a +// site search that found nothing (the words, when they cannot hold a +// person's details), and the answer to "Did this page help?". + +const ENDPOINT = 'https://retrobios.goatcounter.com/count'; +const HOST = 'abdess.github.io'; +const BASE = import.meta.env.BASE_URL.replace(/\/?$/, '/'); + +const declined = (): boolean => { + const nav = navigator as Navigator & { globalPrivacyControl?: boolean }; + return nav.doNotTrack === '1' || nav.globalPrivacyControl === true; +}; +const enabled = location.hostname === HOST && !declined(); + +// The path under the site, "/emulators/mgba/": the base names every page. +const pagePath = (): string => { + const path = location.pathname; + return path.startsWith(BASE) ? `/${path.slice(BASE.length)}` : path; +}; + +const send = (params: Record): void => { + if (!enabled) return; + const url = `${ENDPOINT}?${new URLSearchParams({ ...params, rnd: Math.random().toString(36).slice(2) })}`; + if (!navigator.sendBeacon?.(url)) new Image().src = url; +}; + +// An action, filed under its name with the page it happened on as title. +export const countEvent = (name: string): void => { + send({ p: name.slice(0, 120), t: pagePath(), e: 'true' }); +}; + +// The site the reader came from, by domain alone: a path can carry a +// search or a private thread. +const referrer = (): string => { + try { + const from = new URL(document.referrer); + return from.hostname && from.hostname !== location.hostname ? from.origin : ''; + } catch { + return ''; + } +}; + +const countPage = (): void => { + const params: Record = { p: pagePath() }; + const from = referrer(); + if (from) params.r = from; + // GitHub Pages serves 404.html at the address asked for: the path says + // which link is broken, the title that it was one. + if (document.title.startsWith('Page not found')) params.t = '404'; + send(params); +}; + +// Clicks + +const slug = (text: string): string => text.trim().toLowerCase().replace(/[^a-z0-9.+-]+/g, '-').replace(/^-|-$/g, '').slice(0, 60); + +const linkAction = (link: HTMLAnchorElement): string | undefined => { + let url: URL; + try { + url = new URL(link.href, location.href); + } catch { + return undefined; + } + const release = /\/releases\/download\/[^/]+\/([^/?#]+)$/.exec(url.pathname); + if (url.hostname === 'github.com' && release) return `download/${decodeURIComponent(release[1])}`; + if (url.hostname === 'raw.githubusercontent.com') { + return `file/${decodeURIComponent(url.pathname.split('/').pop() ?? '')}`; + } + if (url.hostname === location.hostname) { + const get = new RegExp(`^${BASE}get/([^/]+)/`).exec(url.pathname); + return get ? `get/${get[1]}` : undefined; + } + return url.protocol.startsWith('http') ? `out/${url.hostname}` : undefined; +}; + +const onClick = (event: MouseEvent): void => { + const target = event.target instanceof Element ? event.target : null; + if (!target) return; + const copy = target.closest('.expressive-code .copy button, [data-copy]'); + if (copy) { + const frame = copy.closest('.frame'); + const title = frame?.querySelector('.title')?.textContent ?? ''; + countEvent(`copy/${slug(title) || (copy.matches('[data-copy]') ? 'picker' : 'code')}`); + return; + } + const help = target.closest('[data-rb-helpful]'); + if (help) { + countEvent(`helpful/${help.dataset.rbHelpful}`); + return; + } + const link = target.closest('a[href]'); + const action = link && linkAction(link); + if (action) countEvent(action); +}; + +// Search + +// A search that found nothing names a missing page. Its words are sent +// only when they cannot hold a person's details: short, no "@", no long +// run of digits. A hash is a file asked for, and is kept. +const isHash = (query: string): boolean => /^[0-9a-f]{8}$|^[0-9a-f]{32}$|^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(query); +const sendable = (query: string): boolean => + isHash(query) || (query.length >= 2 && query.length <= 40 && !query.includes('@') && !/\d{7,}/.test(query)); + +const watchSearch = (): void => { + const root = document.getElementById('starlight__search'); + if (!root) return; + const sent = new Set(); + let timer: number | undefined; + const settle = (): void => { + const input = root.querySelector('.pagefind-ui__search-input'); + const query = (input?.value ?? '').trim().toLowerCase().replace(/\s+/g, ' '); + // Pagefind's English message once a search has ended with nothing; + // while it runs the message reads "Searching for ...". + const message = root.querySelector('.pagefind-ui__message')?.textContent ?? ''; + if (!query || !message.startsWith('No results for') || sent.has(query) || !sendable(query)) return; + sent.add(query); + countEvent(`search-none/${query}`); + }; + new MutationObserver(() => { + window.clearTimeout(timer); + timer = window.setTimeout(settle, 1500); + }).observe(root, { childList: true, subtree: true, characterData: true }); +}; + +// "Did this page help?": the buttons answer once, then say thanks. +const wireHelpful = (): void => { + // Shown only where an answer is sent: a box that counts nothing would ask for nothing. + if (!enabled) return; + for (const box of document.querySelectorAll('[data-rb-helpful-box]')) { + box.hidden = false; + box.addEventListener('click', (event) => { + if (!(event.target instanceof Element) || !event.target.closest('[data-rb-helpful]')) return; + for (const button of box.querySelectorAll('button')) button.remove(); + const thanks = box.querySelector('[data-rb-helpful-thanks]'); + if (thanks) thanks.hidden = false; + }); + } +}; + +countPage(); +document.addEventListener('click', onClick, { capture: true }); +watchSearch(); +wireHelpful(); diff --git a/wiki/privacy.md b/wiki/privacy.md new file mode 100644 index 000000000..6f0854844 --- /dev/null +++ b/wiki/privacy.md @@ -0,0 +1,50 @@ +--- +level: start +order: 65 +terms: privacy, cookies, analytics, tracking +--- + +# What we count + +No cookies. No account. Nothing is stored on your device, and nothing +links a visit to a person. + +## On this site + +When a page opens, your browser sends one request to +[GoatCounter](https://www.goatcounter.com), an open-source counter. It +carries: + +- the page address, without the part after `?` or `#`; +- the site you came from, by its domain only (`google.com`, `reddit.com`). + +GoatCounter reads the browser name and the country from that request. It does +not keep your IP address. To tell a reload from a second visitor it keeps a +code made from the request for 8 hours, then drops it. + +These actions are counted the same way, with the page they happened on: + +- a click on a copy button, a pack download or a file link; +- a site search that found nothing, with its words, so a missing page shows up. + Words with an `@` or a long number are never sent; +- the answer to "Did this page help?". + +Not sent: your screen size, your language, a search that found results. + +The totals are kept two years and are public: +[retrobios.goatcounter.com](https://retrobios.goatcounter.com). + +If your browser sends Do Not Track or Global Privacy Control, nothing is sent, +and the "Did this page help?" question is not shown. A content blocker that +stops GoatCounter changes nothing on the site. + +## The installer + +The installer sends nothing back. It downloads files from GitHub, which sees +those requests as it sees any download. + +## Search engines + +Google and Bing tell us which searches showed the site and how often it was +clicked. They report totals, from their own records, never from your +browser.