diff --git a/tests/test_site_privacy.py b/tests/test_site_privacy.py index 906d39ba7..c37038da0 100644 --- a/tests/test_site_privacy.py +++ b/tests/test_site_privacy.py @@ -49,6 +49,25 @@ class TheCounterAsksNothingOfTheDevice(unittest.TestCase): self.assertIn("countEvent(`search-none/${searchWords(query)}`)", self.code) self.assertIn("message.startsWith('No results for')", self.code) + def _shape(self, name: str): + """A search-shape predicate of count.ts, its regexes run by Python + (the counter's patterns use no syntax the two read differently).""" + body = re.search(rf"const {name} = \(query: string\): boolean =>(.*?);\n", self.code, re.S).group(1) + patterns = [re.compile(p) for p in re.findall(r"/(\^.*?\$|\[[^/]*?\]|\\d)/\.test\(query\)", body)] + digits = "(query.match(/\\d/g) ?? []).length <= 4" in body + return lambda q: all(p.search(q) for p in patterns) and ( + not digits or len(re.findall(r"\d", q)) <= 4) + + def test_a_number_alone_is_never_sent(self) -> None: + """An 8-digit phone number is valid hex and went out as a hash.""" + shapes = [self._shape(n) for n in ("isHash", "isFileName", "isFileStem")] + for query in ("55512345", "91234567", "0612345678", "+33612345678"): + with self.subTest(query=query): + self.assertFalse(any(shape(query) for shape in shapes)) + for query in ("b4430a3b", "scph5501", "scph5501.bin", "a" * 40): + with self.subTest(query=query): + self.assertTrue(any(shape(query) for shape in shapes)) + def test_one_endpoint(self) -> None: self.assertEqual(set(re.findall(r"https://[\w.-]+", self.code)), {"https://retrobios.goatcounter.com"}) @@ -70,6 +89,19 @@ class ThePageSaysWhatIsCounted(unittest.TestCase): with self.subTest(said=said): self.assertIn(said, page) + def test_the_page_names_what_the_counter_cannot_tell_apart(self) -> None: + """The page left out outbound clicks, which count.ts sends, said the + referrer went by domain when the origin goes, and promised a search + word goes out only as a file name while a nickname with digits does.""" + page = PRIVACY.read_text(encoding="utf-8") + code = COUNTER.read_text(encoding="utf-8") + self.assertIn("`out/${url.hostname}`", code) + self.assertIn("link to another site", page) + self.assertIn("from.origin", code) + self.assertIn("`https://www.google.com`", page) + self.assertIn("`dupont1985`", page) + self.assertIn("phone number, never is", 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()) diff --git a/website/src/scripts/count.ts b/website/src/scripts/count.ts index 31c5473e7..1a798738c 100644 --- a/website/src/scripts/count.ts +++ b/website/src/scripts/count.ts @@ -4,11 +4,12 @@ // 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 +// Sent: the page path (never its query or #anchor), the origin of the site +// the reader came from (scheme and host, no path), 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 (its words only when they are a file -// name or a hash), and the answer to "Did this page help?". +// site search that found nothing (its words only in the shapes below), +// and the answer to "Did this page help?". const ENDPOINT = 'https://retrobios.goatcounter.com/count'; const HOST = 'abdess.github.io'; @@ -37,7 +38,7 @@ 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 +// The site the reader came from, by its origin alone: a path can carry a // search or a private thread. const referrer = (): string => { try { @@ -104,11 +105,16 @@ const onClick = (event: MouseEvent): void => { // Search // A search that found nothing names a missing page. Its words are sent -// only in a shape no person's details take: a hash, a file name -// ("scph5501.bin"), or one word of letters and at most four digits -// ("scph5501", "ps3updat"). Any other search is counted with no words, -// as "other": a name, a phone number or an address is never sent. -const isHash = (query: string): boolean => /^(?:[0-9a-f]{8}|[0-9a-f]{32}|[0-9a-f]{40}|[0-9a-f]{64})$/.test(query); +// only in three shapes: a hash with at least one letter, a file name +// ("scph5501.bin"), or one word that starts with a letter and holds at most +// four digits ("scph5501", "ps3updat"). Any other search is counted with no +// words, as "other". A number alone, a phone number among them, is never +// sent. A nickname of the third shape ("dupont1985") is, as wiki/privacy.md +// says: nothing tells it from a file name. +// An 8-digit phone number is valid hex: a hash needs a letter, and a CRC32 +// written in digits only is counted as "other". +const isHash = (query: string): boolean => + /^(?:[0-9a-f]{8}|[0-9a-f]{32}|[0-9a-f]{40}|[0-9a-f]{64})$/.test(query) && /[a-f]/.test(query); // The extensions of the files the collection holds: "john.smith" is no file. const isFileName = (query: string): boolean => /^[\w.+-]{1,60}\.(?:bin|rom|zip|7z|pup|keys|img|dat|bios|ic\d+|u\d+|sys|nvm|mec|rom1|j64|pce|col|sms|gb|gbc|gba|nds|cfg|chd|iso|txt|fw|xml|yaml|pak|pk3|grp)$/.test(query); diff --git a/wiki/privacy.md b/wiki/privacy.md index 16ccd5c07..70f7972dc 100644 --- a/wiki/privacy.md +++ b/wiki/privacy.md @@ -16,7 +16,8 @@ When a page opens, your browser sends one request to carries: - the page address, without the part after `?` or `#`; -- the site you came from, by its domain only (`google.com`, `reddit.com`). +- the site you came from, by its address without a path + (`https://www.google.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 @@ -25,9 +26,13 @@ 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 click on a link to another site, by that site's domain (`reddit.com`); - a site search that found nothing, so a missing page shows up. Its words are sent only when they read as a file name (`scph5501.bin`, `scph5501`) or a - hash. Any other search is counted as "other", without its words; + hash with at least one letter. A word of letters with a few digits is sent + as typed, so a nickname written that way (`dupont1985`) would be too. A + number alone, such as a phone number, never is. Any other search is counted + as "other", without its words; - the answer to "Did this page help?". Not sent: your screen size, your language, a search that found results.