mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-06 10:03:49 -06:00
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.
This commit is contained in:
1 parent
79a21ba1f1
commit
ebe9745e7f
5 files changed
+307
-2
No files matched your search
@@ -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/",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
<div class="rb-footer-root" data-pagefind-ignore>
|
||||
<Default />
|
||||
<div class="rb-footer">
|
||||
<p class="rb-helpful" data-rb-helpful-box hidden>
|
||||
Did this page help?
|
||||
<button type="button" data-rb-helpful="yes">Yes</button>
|
||||
<button type="button" data-rb-helpful="no">No</button>
|
||||
<span data-rb-helpful-thanks hidden>Thanks, counted.</span>
|
||||
</p>
|
||||
<p>
|
||||
Maintained by <a href="https://github.com/Abdess">Abdess on GitHub</a>.{' '}
|
||||
<a href={`${REPO}/issues/new`}>Report a problem</a>.
|
||||
<a href={`${import.meta.env.BASE_URL.replace(/\/?$/, '/')}wiki/privacy/`}>Privacy</a>.
|
||||
</p>
|
||||
<p>
|
||||
Packs from release <a href={`${REPO}/releases/tag/${tag}`}>{tag}</a>{date && <>, <time datetime={tag.slice(1).replaceAll('.', '-')}>{date}</time></>}.
|
||||
@@ -38,6 +46,10 @@ const date = releaseDate(tag);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import '../scripts/count.ts';
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* The wrapper only keeps the footer out of the search index: it adds no box. */
|
||||
.rb-footer-root { display: contents; }
|
||||
@@ -52,4 +64,16 @@ const date = releaseDate(tag);
|
||||
.rb-footer a { color: inherit; }
|
||||
.rb-footer a:hover { color: var(--sl-color-white); }
|
||||
.rb-footer .rb-copyright { margin-top: 0.75rem; }
|
||||
.rb-helpful button {
|
||||
margin-left: 0.4rem;
|
||||
padding: 0.1rem 0.6rem;
|
||||
font: inherit;
|
||||
color: var(--sl-color-white);
|
||||
background: transparent;
|
||||
border: 1px solid var(--sl-color-gray-4);
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rb-helpful button:hover { border-color: var(--sl-color-accent); }
|
||||
.rb-helpful span { margin-left: 0.4rem; }
|
||||
</style>
|
||||
@@ -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<string, string>): 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<string, string> = { 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<HTMLButtonElement>('[data-rb-helpful]');
|
||||
if (help) {
|
||||
countEvent(`helpful/${help.dataset.rbHelpful}`);
|
||||
return;
|
||||
}
|
||||
const link = target.closest<HTMLAnchorElement>('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<string>();
|
||||
let timer: number | undefined;
|
||||
const settle = (): void => {
|
||||
const input = root.querySelector<HTMLInputElement>('.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<HTMLElement>('[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<HTMLElement>('[data-rb-helpful-thanks]');
|
||||
if (thanks) thanks.hidden = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
countPage();
|
||||
document.addEventListener('click', onClick, { capture: true });
|
||||
watchSearch();
|
||||
wireHelpful();
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user