9.4 KiB
tssbot.web
Toothless' TSS Bot web stack.
The repo is split into:
frontend/- React + Vite + Tailwind v4 web shellbackend/- 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/teamsTSS team leaderboard/teams/:teamnamegenerated team profile with roster, summary, rating history, and battle results/battle-logsBattle Logs/viewerspublic consented viewer analytics dashboard
Local development
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:
VITE_API_TARGET=http://localhost:8080 npm run dev
Run the Rust backend separately:
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 PM2
On a fresh headless Ubuntu server, install the native build tools Rust crates need before the first backend build:
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"
npm install
npm run build
npm run build:backend
pm2 start ecosystem.config.cjs
The production server runs on http://localhost:3010. 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..100GET /api/tss/games/recent?limit=1..100GET /api/tss/games/:session_id?lang=...GET /api/tss/games/:session_id/logsGET /api/tss/teams/resolve?name=...GET /api/tss/teams/:teamGET /api/tss/teams/:team/historyGET /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:
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:
PUBLIC_ORIGIN=https://your-domain.example pm2 start ecosystem.config.cjs
Optional API protection tuning:
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:
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:
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.
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:
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:
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:
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:
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:
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.