From ebe9745e7f81d6a11694775f88baa768b3b96560 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:55:57 +0100 Subject: [PATCH] feat: count visits without cookies Pages, referring domains and a few actions (searches that found nothing, downloads, missing addresses, "Did this page help?") are sent to GoatCounter. Nothing is stored on the device, and nothing is sent under Do Not Track or Global Privacy Control. wiki/privacy.md says what is sent. --- tests/test_site_nav.py | 2 +- tests/test_site_privacy.py | 79 +++++++++++++++ website/src/components/Footer.astro | 26 ++++- website/src/scripts/count.ts | 152 ++++++++++++++++++++++++++++ wiki/privacy.md | 50 +++++++++ 5 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 tests/test_site_privacy.py create mode 100644 website/src/scripts/count.ts create mode 100644 wiki/privacy.md 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