site: first-party, cookieless visit stats (self-hosted Umami at storebound.dev/s/, Do Not Track respected) with events for copy buttons, desktop downloads per OS, the teaser (play, 50 %, complete) and outbound docs / git / npm links; a privacy note in the footer; docs/site-analytics.md and tools/vds/ configs to rebuild the server side; scrub allows a page's public stats website id

This commit is contained in:
CRTkafa 2026-10-14 11:11:13 +08:00
parent 83c14d3a8e
commit 34c2fa22e5
7 changed files with 129 additions and 3 deletions

57
docs/site-analytics.md Normal file
View file

@ -0,0 +1,57 @@
# storebound.dev site analytics
The site counts visits with a self-hosted [Umami](https://umami.is) on the VDS that serves storebound.dev. Cookieless,
no personal data, Do Not Track respected, nothing sent to a third party. This page is the rebuild recipe (no secrets).
## What runs where
| Piece | Where |
|---|---|
| Umami 3.4.0 + Postgres 15.14 (Docker Compose, `restart: unless-stopped`) | `/opt/umami` on the VDS, `127.0.0.1:3920` only |
| Secrets (DB password, `APP_SECRET`, `TWO_FACTOR_ENCRYPTION_KEY`) | `/opt/umami/.env`, mode 600, generated on the server |
| Script and collect endpoint, first-party | `https://storebound.dev/s/p.js`, `https://storebound.dev/s/e` (`/etc/apache2/storebound-stats.conf`, from `tools/vds/apache-storebound-stats.conf`, included in the storebound.dev :443 vhost) |
| Dashboard | `https://stats.crt.fyi` (`tools/vds/apache-stats.crt.fyi.conf`; `*.crt.fyi` wildcard DNS through Cloudflare and the `crt.fyi` wildcard cert) |
| Admin login | the owner's Mac, `~/.storebound-keys/umami.json` (0600) |
URLs never contain "umami", "analytics", "track" or "collect" (`TRACKER_SCRIPT_NAME=p.js`, `COLLECT_API_ENDPOINT=/e`), so
common filter lists do not block them.
**Client IP.** Cloudflare sits in front. Apache's `mod_remoteip` takes `CF-Connecting-IP` only from Cloudflare's ranges
and passes the result as `X-Sb-Client`, which Umami reads (`CLIENT_IP_HEADER=x-sb-client`). A client cannot spoof it by
sending the header to the origin directly. The collect endpoint answers `Cache-Control: no-store`.
## Rebuild
1. `/opt/umami/docker-compose.yml`: Umami's upstream compose with the image pinned to `ghcr.io/umami-software/umami:3.4.0`,
port `127.0.0.1:3920:3000`, `env_file: .env`, and `TRACKER_SCRIPT_NAME: p.js`, `COLLECT_API_ENDPOINT: /e`,
`CLIENT_IP_HEADER: x-sb-client`, `DISABLE_TELEMETRY: "1"`, `DISABLE_UPDATES: "1"`; Postgres `postgres:15.14-alpine` with a
named volume, both `restart: unless-stopped`.
2. `.env` (umask 077): `POSTGRES_PASSWORD`, `DATABASE_URL=postgresql://umami:<pw>@db:5432/umami`, `APP_SECRET` and
`TWO_FACTOR_ENCRYPTION_KEY` from `openssl rand -hex`. `docker compose up -d`, wait for `/api/heartbeat`.
3. Log in as `admin` / `umami`, change the password at once (`POST /api/me/password`) to a generated one and store it in
`~/.storebound-keys/umami.json` on the Mac. Create the website `storebound.dev`; its id goes into the script tag.
4. Apache (back up every file first, `cp -a <file> /root/<name>.bak-<timestamp>`). The Cloudflare ranges live only on
the server, in `/etc/apache2/cloudflare-trusted-proxies.conf`; refresh them now and then:
```sh
ssh root@<server> 'for u in ips-v4 ips-v6; do curl -fs https://www.cloudflare.com/$u | sed "s/^/RemoteIPTrustedProxy /"; echo; done | grep . > /etc/apache2/cloudflare-trusted-proxies.conf'
scp tools/vds/apache-storebound-stats.conf root@<server>:/etc/apache2/storebound-stats.conf
scp tools/vds/apache-stats.crt.fyi.conf root@<server>:/etc/apache2/sites-available/stats.crt.fyi.conf
# in the storebound.dev :443 vhost, after the desktop include: Include /etc/apache2/storebound-stats.conf
ssh root@<server> 'a2ensite stats.crt.fyi && apache2ctl configtest && systemctl reload apache2'
```
5. Site: `site/index.html` loads `https://storebound.dev/s/p.js` with `defer`, `data-host-url="https://storebound.dev/s"`,
`data-domains="storebound.dev"` (localhost and previews send nothing) and `data-do-not-track="true"`.
## Events (site/main.js)
| Event | Data |
|---|---|
| `copy` | `what`: the copied command or the copied block's id (every Copy button) |
| `desktop-download` | `os` (macos, windows, linux), `file` |
| `teaser-play`, `teaser-50`, `teaser-complete` | once per page view |
| `outbound` | `kind` (docs, git, npm, other), `host`, `path` |
## Upgrade
Read Umami's release notes, change the pinned tag, `docker compose pull && docker compose up -d` in `/opt/umami`
(take `docker compose exec db pg_dump -U umami umami > /root/umami-<date>.sql` first).

View file

@ -9,6 +9,7 @@
<meta http-equiv="refresh" content="0; url=../#download"> <meta http-equiv="refresh" content="0; url=../#download">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<link rel="icon" href="../favicon.ico?v=2" sizes="16x16 32x32 48x48"> <link rel="icon" href="../favicon.ico?v=2" sizes="16x16 32x32 48x48">
<script defer src="https://storebound.dev/s/p.js" data-website-id="91ce17cb-8f26-451f-92be-752c805238ee" data-host-url="https://storebound.dev/s" data-domains="storebound.dev" data-do-not-track="true"></script>
<script>location.replace('../#download')</script> <script>location.replace('../#download')</script>
<style>body { font: 16px/1.6 ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 40px 16px; background: #f8fafc; color: #0f172a; } a { color: #4f46e5; } @media (prefers-color-scheme: dark) { body { background: #0b1120; color: #f1f5f9; } a { color: #818cf8; } }</style> <style>body { font: 16px/1.6 ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 40px 16px; background: #f8fafc; color: #0f172a; } a { color: #4f46e5; } @media (prefers-color-scheme: dark) { body { background: #0b1120; color: #f1f5f9; } a { color: #818cf8; } }</style>
</head> </head>

View file

@ -27,7 +27,8 @@
<meta name="twitter:image" content="https://storebound.dev/og.png?v=2"> <meta name="twitter:image" content="https://storebound.dev/og.png?v=2">
<link rel="stylesheet" href="style.css?v=20261013c"> <link rel="stylesheet" href="style.css?v=20261013c">
<script>document.documentElement.classList.add('js')</script> <script>document.documentElement.classList.add('js')</script>
<script src="main.js?v=20261013b" defer></script> <script defer src="https://storebound.dev/s/p.js" data-website-id="91ce17cb-8f26-451f-92be-752c805238ee" data-host-url="https://storebound.dev/s" data-domains="storebound.dev" data-do-not-track="true"></script>
<script src="main.js?v=20261014a" defer></script>
</head> </head>
<body> <body>
<a class="skip" href="#main">Skip to content</a> <a class="skip" href="#main">Skip to content</a>
@ -887,7 +888,7 @@ com.example.app.gems_100 create 174 regions, 2 listings, USD 0.99
<img class="mark" src="img/logo-mark.png?v=2" srcset="img/logo-mark.png?v=2 1x, img/logo-mark@2x.png?v=2 2x" width="28" height="28" alt="storebound logo"> <img class="mark" src="img/logo-mark.png?v=2" srcset="img/logo-mark.png?v=2 1x, img/logo-mark@2x.png?v=2 2x" width="28" height="28" alt="storebound logo">
<span>storebound</span> <span>storebound</span>
</a> </a>
<p class="fine">MIT · made by CRTkafa · not affiliated with Google or Apple.<br>App Store is a trademark of Apple Inc.; Google Play is a trademark of Google LLC.</p> <p class="fine">MIT · made by CRTkafa · not affiliated with Google or Apple.<br>App Store is a trademark of Apple Inc.; Google Play is a trademark of Google LLC.<br>Privacy: this site counts visits with self-hosted Umami. No cookies, no personal data, nothing shared with third parties, and Do Not Track is respected.</p>
</div> </div>
<nav aria-label="Footer"> <nav aria-label="Footer">
<a href="https://git.crt.fyi/CRT/storebound" target="_blank" rel="noopener">git repo<span class="ext" aria-hidden="true">↗</span><span class="sr-only"> (opens in a new tab)</span></a> <a href="https://git.crt.fyi/CRT/storebound" target="_blank" rel="noopener">git repo<span class="ext" aria-hidden="true">↗</span><span class="sr-only"> (opens in a new tab)</span></a>

View file

@ -1,5 +1,28 @@
// storebound.dev: copy buttons, tabs (AI tools, desktop screens), the teaser player, the demo (real CLI output), the // storebound.dev: copy buttons, tabs (AI tools, desktop screens), the teaser player, the demo (real CLI output), the
// How it works stepper and the desktop downloads (reads desktop/latest.json, same origin). No storage, no third parties. // How it works stepper and the desktop downloads (reads desktop/latest.json, same origin). No storage, no third parties.
// Stats: self-hosted Umami at /s/ (cookieless, Do Not Track respected); ev() is a no-op when the script is blocked or off.
(() => {
const ev = (name, data) => { try { window.umami?.track(name, data); } catch {} };
document.addEventListener('click', e => {
const a = e.target.closest?.('a[href]');
if (!a) return;
if (a.dataset.dl) { ev('desktop-download', { os: a.dataset.dl.split('-')[0], file: a.dataset.dl }); return; }
let u; try { u = new URL(a.href, location.href); } catch { return; }
if (u.host === location.host) return;
const docs = u.host === 'git.crt.fyi' && /\/(docs\/|README|CHANGELOG|SECURITY)/.test(u.pathname);
const kind = docs ? 'docs' : u.host === 'git.crt.fyi' ? 'git' : u.host.endsWith('npmjs.com') ? 'npm' : 'other';
ev('outbound', { kind, host: u.host, path: u.pathname.slice(0, 120) });
}, true);
const vid = document.querySelector('#teaser video');
if (vid) {
const seen = new Set();
const once = (k, d) => { if (!seen.has(k)) { seen.add(k); ev(k, d); } };
vid.addEventListener('play', () => once('teaser-play'));
vid.addEventListener('timeupdate', () => { if (vid.duration && vid.currentTime / vid.duration >= 0.5) once('teaser-50'); });
vid.addEventListener('ended', () => once('teaser-complete'));
}
window.__sbEv = ev;
})();
(() => { (() => {
const copyText = async text => { const copyText = async text => {
try { await navigator.clipboard.writeText(text); return true; } catch {} try { await navigator.clipboard.writeText(text); return true; } catch {}
@ -15,6 +38,7 @@
// Drop "# comment" tails so the copied commands run as is. // Drop "# comment" tails so the copied commands run as is.
const text = btn.dataset.copy ?? (target ? target.innerText.split('\n').map(l => l.replace(/\s+#.*$/, '')).join('\n').trim() : ''); const text = btn.dataset.copy ?? (target ? target.innerText.split('\n').map(l => l.replace(/\s+#.*$/, '')).join('\n').trim() : '');
const ok = await copyText(text); const ok = await copyText(text);
window.__sbEv?.('copy', { what: (btn.dataset.copy ?? btn.dataset.copyTarget ?? '').slice(0, 80) });
btn.textContent = ok ? 'Copied' : 'Press Ctrl+C'; btn.textContent = ok ? 'Copied' : 'Press Ctrl+C';
btn.classList.toggle('done', ok); btn.classList.toggle('done', ok);
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('done'); }, 1600); setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('done'); }, 1600);

View file

@ -20,7 +20,8 @@ export const RULES: Rule[] = [
// Google's public code signing team, which the desktop app checks Chrome for Testing against // Google's public code signing team, which the desktop app checks Chrome for Testing against
allow: (_m, line) => /"macTeamId":/.test(line) }, allow: (_m, line) => /"macTeamId":/.test(line) },
{ id: 'issuer-uuid', re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/g, { id: 'issuer-uuid', re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/g,
allow: m => /^0{8}-0{4}-0{4}-0{4}-0{12}$/.test(m) }, // a site stats script's website id is public by design (it is in every page view)
allow: (m, line) => /^0{8}-0{4}-0{4}-0{4}-0{12}$/.test(m) || line.includes(`data-website-id="${m}"`) },
{ id: 'ip-address', re: /(?<![\w.\-]|[A-Za-z]\/)(?:\d{1,3}\.){3}\d{1,3}(?![\w.\-])/g, // RFC 5737 documentation ranges (192.0.2.x, 198.51.100.x, 203.0.113.x) are fine in fixtures and docs { id: 'ip-address', re: /(?<![\w.\-]|[A-Za-z]\/)(?:\d{1,3}\.){3}\d{1,3}(?![\w.\-])/g, // RFC 5737 documentation ranges (192.0.2.x, 198.51.100.x, 203.0.113.x) are fine in fixtures and docs
allow: m => ['127.0.0.1', '0.0.0.0'].includes(m) || /^(192\.0\.2|198\.51\.100|203\.0\.113)\.\d{1,3}$/.test(m) }, allow: m => ['127.0.0.1', '0.0.0.0'].includes(m) || /^(192\.0\.2|198\.51\.100|203\.0\.113)\.\d{1,3}$/.test(m) },
{ id: 'private-name', re: new RegExp(['Yon', 'ga'].join(''), 'gi') }, { id: 'private-name', re: new RegExp(['Yon', 'ga'].join(''), 'gi') },

View file

@ -0,0 +1,26 @@
# stats.crt.fyi: the Umami dashboard (/opt/umami, 127.0.0.1:3920). Behind Cloudflare (*.crt.fyi wildcard DNS + cert).
<VirtualHost *:80>
ServerName stats.crt.fyi
Redirect permanent / https://stats.crt.fyi/
</VirtualHost>
<VirtualHost *:443>
ServerName stats.crt.fyi
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/crt.fyi/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/crt.fyi/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf
Header always set Strict-Transport-Security "max-age=63072000"
Header always set X-Robots-Tag "noindex, nofollow"
ProxyRequests Off
ProxyPreserveHost On
RemoteIPHeader CF-Connecting-IP
# RemoteIPTrustedProxy lines from https://www.cloudflare.com/ips-v4 and ips-v6 (docs/site-analytics.md)
Include /etc/apache2/cloudflare-trusted-proxies.conf
RequestHeader set X-Sb-Client "expr=%{REMOTE_ADDR}"
RequestHeader set X-Forwarded-Proto "https"
Header always set Cache-Control "no-store"
ProxyPass / http://127.0.0.1:3920/ retry=0 timeout=60
ProxyPassReverse / http://127.0.0.1:3920/
ErrorLog ${APACHE_LOG_DIR}/stats.crt.fyi-error.log
CustomLog ${APACHE_LOG_DIR}/stats.crt.fyi-access.log combined
</VirtualHost>

View file

@ -0,0 +1,16 @@
# storebound.dev first-party stats (Umami in /opt/umami on 127.0.0.1:3920), included in the :443 vhost.
# Neutral paths only: /s/p.js (script) and /s/e (collect). See docs/site-analytics.md.
# The real visitor IP: only Cloudflare may set CF-Connecting-IP; Umami reads it from X-Sb-Client.
RemoteIPHeader CF-Connecting-IP
# RemoteIPTrustedProxy lines from https://www.cloudflare.com/ips-v4 and ips-v6 (docs/site-analytics.md)
Include /etc/apache2/cloudflare-trusted-proxies.conf
ProxyRequests Off
<Location "/s/p.js">
ProxyPass http://127.0.0.1:3920/p.js retry=0 timeout=10
Header set Cache-Control "public, max-age=3600"
</Location>
<Location "/s/e">
ProxyPass http://127.0.0.1:3920/e retry=0 timeout=10
RequestHeader set X-Sb-Client "expr=%{REMOTE_ADDR}"
Header always set Cache-Control "no-store"
</Location>