# tssbot.web

Toothless' TSS Bot web stack.

The repo is split into:

- `frontend/` - React + Vite + Tailwind v4 web shell
- `backend/` - backend API service scaffold, ready for database-backed routes
- root process files - production frontend server, deploy webhook, systemd unit files, and shared repo scripts

Routes:

- `/` landing page
- `/teams` TSS team leaderboard
- `/teams/:teamname` generated team profile with roster, summary, rating history, and battle results
- `/battle-logs` Battle Logs
- `/viewers` public consented viewer analytics dashboard

## Local development

```sh
npm install
npm run dev
```

The frontend development server runs on <http://localhost:3001>.

By default, `/api/*` and `/health` requests are proxied to `http://localhost:6000`. Override
that with `VITE_API_TARGET`:

```sh
VITE_API_TARGET=http://localhost:8080 npm run dev
```

Run the Rust backend separately:

```sh
npm run dev:backend
```

The backend listens on <http://127.0.0.1:6000> by default and reads the SQLite
databases configured by `TSS_BATTLES_DB` and `TSS_TEAMS_DB`. Keep it bound to
`127.0.0.1` in production and let `tssbot-web` proxy public API requests.

## Production with systemd

On a fresh headless Ubuntu server, install the native build tools Rust crates
need before the first backend build:

```sh
sudo apt update
sudo apt install -y build-essential pkg-config curl
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
. "$HOME/.cargo/env"
```

```sh
npm install
npm run build
npm run build:backend
scripts/install-systemd-services.sh
```

`scripts/install-systemd-services.sh` symlinks the unit files under `systemd/`
into `~/.config/systemd/user/`, then runs `systemctl --user daemon-reload` and
`enable --now` for all three services. These are user-level (`systemctl --user`)
units running as the deploy user, not system-wide units — no root needed to
manage them day to day. Because user services normally stop when the user logs
out, enable lingering once so they keep running:

```sh
sudo loginctl enable-linger <deploy-user>
```

The production server runs on <http://localhost:3010>, `tssbot-backend` on
<http://127.0.0.1:6000> (see `BACKEND_PORT`), and the webhook listener on
`WEBHOOK_PORT`. Each runs as a single instance — a deploy restart is a plain
`systemctl --user restart`, so expect a brief (roughly 1-2 second) connection
drop while `tssbot-web` restarts, rather than PM2-style zero-downtime cluster
reloads.

Useful commands:

```sh
systemctl --user status tssbot-web tssbot-webhook tssbot-backend
journalctl --user -u tssbot-web -f
systemctl --user restart tssbot-web
```

The server serves `/health`
locally and only proxies the API routes used by the app:

- `GET /api/tss/leaderboard/teams?limit=1..100`
- `GET /api/tss/games/recent?limit=1..100`
- `GET /api/tss/games/:session_id?lang=...`
- `GET /api/tss/games/:session_id/logs`
- `GET /api/tss/teams/resolve?name=...`
- `GET /api/tss/teams/:team`
- `GET /api/tss/teams/:team/history`
- `GET /api/tss/teams/:team/games`

Vehicle icon PNGs are served statically at `/vehicle-icons` from `VEHICLE_ICONS_DIR`
(populated at deploy from `SHARED/ICONS/VEHICLES`).

The proxy blocks cross-origin/API-navigation requests, strips CORS headers from
the upstream response, rate limits callers, and caches successful GET responses.
Public TSS reads are written to a bounded JSON snapshot cache and served through
their normal `/api/tss/*` route. Fresh snapshots return without touching the
backend; stale snapshots are served immediately while the server refreshes them
in the background. Matching `/data/*` paths are also available for diagnostics or
static-first experiments, but the frontend uses `/api/tss/*` by default so the
site stays dynamic. All responses
ship `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`,
`Permissions-Policy`, `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`,
HSTS (over HTTPS), and HTML responses include a Content Security Policy that
allows only Cloudflare Turnstile and the CARTO basemap tiles.

Override the API target by setting it in `.env` and restarting:

```sh
echo 'API_UPSTREAM=http://127.0.0.1:8080' >> .env
systemctl --user restart tssbot-web
```

Set `PUBLIC_ORIGIN` to the public site origin in production, especially behind a
reverse proxy (same `.env` + restart pattern as above).

Optional API protection tuning:

```sh
API_CACHE_TTL_MS=15000
PUBLIC_DATA_CACHE_DIR=~/tsswebstorage/public-data
PUBLIC_DATA_CACHE_FRESH_MS=300000
PUBLIC_DATA_CACHE_STALE_MS=86400000
PUBLIC_DATA_PREWARM_INTERVAL_MS=300000
PUBLIC_DATA_COLD_TIMEOUT_MS=8000
VITE_STATIC_DATA=false
VITE_SITE_GATE=true
API_RATE_LIMIT_WINDOW_MS=60000
API_RATE_LIMIT_MAX=120
SITE_SESSION_SECRET=long-random-shared-secret
SITE_SESSION_TTL_SECONDS=43200
```

Successful Turnstile verification sets signed, HttpOnly Turnstile and site-session
cookies. `/api/*` and `/data/*` requests must present those cookies plus
same-origin browser request metadata, so the data is served to verified active
site sessions instead of as an open public API.

On startup, the web server preloads the critical public snapshots: team
leaderboard, player leaderboard, home teams, and recent games. `/health`
includes a `public_data` block with the latest preload status. A same-origin
`POST /api/cache/prewarm` refreshes those snapshots on demand.

## Reverse proxy / Cloudflare

The server only honours `CF-Connecting-IP`, `X-Forwarded-For`, `X-Forwarded-Proto`,
and the Cloudflare geolocation headers when the immediate TCP peer is listed in
`TRUSTED_UPSTREAM_IPS`. Bind the server to `127.0.0.1` (or leave it on `0.0.0.0`
behind a firewall) and front it with nginx/Cloudflare for the configuration to
take effect:

```sh
TRUST_PROXY=cloudflare
TRUSTED_UPSTREAM_IPS=127.0.0.1,::1,::ffff:127.0.0.1
```

Set `TRUST_PROXY=none` if the server is exposed directly. Without it, an attacker
that reaches the app port can spoof client-IP headers to bypass rate limiting.

## Uptime snapshots

The production server samples uptime every 30 minutes and exposes the history at
`/api/uptime`. Snapshots are stored in SQLite under `~/tsswebstorage` by
default. Set `UPTIME_STORAGE_DIR` to choose a different folder:

```sh
UPTIME_STORAGE_DIR=~/tsswebstorage
UPTIME_DATABASE_FILE=uptime.sqlite
UPTIME_SAMPLE_INTERVAL_MS=1800000
UPTIME_HISTORY_LIMIT=336
```

The server creates the storage folder, SQLite database, and `uptime_snapshots`
table automatically.

## Viewer analytics

The site shows a centered cookie notice before analytics start. The first screen
offers `Allow all` or `Configure`; detailed settings only appear after
`Configure`. A necessary cookie remembers the visitor's choice. If a visitor
allows analytics, the browser sends page-view and heartbeat events to
`POST /api/viewers/event`. Visitors can choose whether to include browser/device,
screen, language/timezone, referrer, and technical diagnostics. Technical
diagnostics can include HTTP version, protocol headers, content negotiation
headers, browser privacy signals, network quality, touch support, CPU/memory
hints, and similar debugging fields. The public `/viewers` page reads `GET
/api/viewers` and shows active pages, 24-hour page totals, top pages, and any
consented client details.

Viewer analytics are stored in SQLite under the same `UPTIME_STORAGE_DIR` by
default. Raw IP addresses and IP hashes are not stored in viewer analytics.
Withdrawing consent removes the local visitor ID and calls
`POST /api/viewers/delete` to delete matching visitor/session analytics records.
The `/privacy` page lists the controller, contact route, purposes, retention,
rights, and complaint routes.

```sh
ANALYTICS_DATABASE_FILE=viewers.sqlite
ANALYTICS_RETENTION_DAYS=30
ANALYTICS_ACTIVE_WINDOW_SECONDS=75
```

This is an implementation aid, not legal advice. For production GDPR compliance,
keep the `/privacy` page aligned with the configured retention period, hosting
setup, and actual data fields.

## GitHub webhook

The webhook process listens on port `3011` at `/github`. Configure GitHub to send
push events there.

A webhook secret is required — without `GITHUB_WEBHOOK_SECRET`, the webhook
rejects every request. Put it in `.env` in the project root (recommended over
inlining the secret in a shell command, which writes it to shell history), then
restart:

```sh
echo 'GITHUB_WEBHOOK_SECRET=your-secret' >> .env
systemctl --user restart tssbot-webhook
```

The webhook only deploys pushes whose `ref` is in `GITHUB_WEBHOOK_REFS`
(default `refs/heads/main`). Optionally pin the repository:

```sh
GITHUB_WEBHOOK_REFS=refs/heads/main
GITHUB_WEBHOOK_REPOSITORY=owner/repo
```

Deploys run `npm ci` (not `npm install`) so an attacker who compromises a
dependency cannot quietly add new packages — the lockfile is the source of
truth.

Set `DISCORD_INCLUDE_PATCH=true` only if the Discord channel is private; by
default the patch preview is omitted from Discord notifications to avoid
leaking diff contents.

The default deploy flow is:

```sh
git pull --ff-only
npm ci --include=dev --include=optional
npm run build -- --outDir ../dist-next
cargo build --manifest-path backend/Cargo.toml --release
# the webhook promotes dist-next to dist after carrying over old hashed assets
systemctl --user restart tssbot-web tssbot-backend
```

Only services listed in `RESTART_TARGETS` are restarted. The default is
`tssbot-web,tssbot-backend`, so unrelated systemd units are left alone. The web
server handles `SIGINT` and `SIGTERM` by closing its listener and SQLite
handles before exit, giving systemd a clean shutdown within `TimeoutStopSec`
instead of a hard kill. The webhook exits cleanly every 24 hours; its unit uses
`Restart=always` so systemd relaunches it right away.

When webhook code changes are deployed, the webhook restarts itself once
(delayed so its own deploy response/notifications land first) so it loads the
updated listener:

```sh
systemctl --user restart tssbot-webhook
```

The webhook listener reads `.env` on startup. To send Discord notifications for
listener restarts and GitHub push deploy start/success/failure events, set:

```sh
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
```

Deploy completion and failure notifications include a changed-file summary and a
truncated patch preview for the pushed diff.
