# 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, PM2 config, 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 . 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 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 PM2 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 pm2 start ecosystem.config.cjs ``` The production server runs on . PM2 starts the web app in cluster mode with two workers by default, waits for each worker to signal that it is ready, and then reloads workers one at a time during deploys. Override the worker count with `WEB_INSTANCES`. 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 before starting PM2 if needed: ```sh API_UPSTREAM=http://127.0.0.1:8080 pm2 start ecosystem.config.cjs ``` Set `PUBLIC_ORIGIN` to the public site origin in production, especially behind a reverse proxy: ```sh PUBLIC_ORIGIN=https://your-domain.example pm2 start ecosystem.config.cjs ``` 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. All PM2 web instances must share the same `SITE_SESSION_SECRET`. On startup, the web server preloads the critical public snapshots before signalling PM2 `ready`: 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: ```sh GITHUB_WEBHOOK_SECRET=your-secret pm2 start ecosystem.config.cjs ``` On PowerShell, set `$env:GITHUB_WEBHOOK_SECRET = "your-secret"` before starting PM2, or put the value in a `.env` file in the project root (recommended over inlining the secret in a shell command, which writes it to shell history). 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 pm2 reload tssbot-web --update-env pm2 reload tssbot-backend --update-env ``` Only processes listed in `PM2_RESTART_TARGETS` are reloaded. The default is `tssbot-web,tssbot-backend`, so unrelated PM2 processes are left alone. The web server handles `SIGINT` and `SIGTERM` by closing its listener and SQLite handles before exit, which lets PM2 finish reloads without dropping active requests. The webhook exits after 24 hours so PM2 restarts it cleanly. When webhook code changes are deployed, restart the webhook process once so PM2 loads the updated listener: ```sh pm2 reload tssbot-webhook --update-env ``` 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.