257 lines
8.8 KiB
Markdown
257 lines
8.8 KiB
Markdown
# 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 <http://localhost:3001>.
|
|
|
|
Set `comingsoon=TRUE` in `.env` to serve a temporary coming soon page instead
|
|
of the app. Omit it, or set any other value, to serve the regular site.
|
|
|
|
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 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 <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..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=false
|
|
API_RATE_LIMIT_WINDOW_MS=60000
|
|
API_RATE_LIMIT_MAX=120
|
|
```
|
|
|
|
## 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.
|