TRXTD/server/server.mjs
Tronax 1a9ac5bf45
feat: account system, OIDC, meta-progression research tree, and automated tests
Account & Persistence Layer (server/db.mjs)
- SQLite via node:sqlite DatabaseSync with WAL mode, foreign keys, and
  synchronous=NORMAL for microsecond response times
- Users table: UUID primary key, unique lowercase username, scrypt-hashed
  password, optional OIDC sub/issuer, crystal balance, timestamps
- Sessions table: 64-byte random hex token, FK to users, configurable TTL
  with automatic expiry cleanup every 10 minutes
- User upgrades table: composite PK (user_id, upgrade_id), level tracking,
  ON CONFLICT DO UPDATE for idempotent merges
- User stats table: games played/won, total kills/score, highest wave
- Password hashing: crypto.scrypt with 16-byte salt and 64-byte derived
  key, constant-time comparison via crypto.timingSafeEqual
- Guest merge: caps crystals at 100,000, caps levels to defined maxLevels,
  uses MAX(level, new) to preserve higher account levels, ignores unknown
  upgrade IDs, transactional with BEGIN IMMEDIATE/COMMIT/ROLLBACK
- Atomic crystal purchasing: subtracts cost only if balance sufficient,
  upgrades level within transaction, returns full user object on success

Meta-Progression Definitions (shared/meta-upgrades.mjs)
- Single source of truth shared between server (authoritative validation)
  and client (talent tree UI)
- 3 branches: Economy (start_gold, wave_bonus, obstacle_discount),
  Defense (bonus_lives, shockwave, fortress_shield),
  Towers (tower_range, tower_speed, dot_potency)
- 9 upgrades with 1–5 levels each, progressive cost curves
- calcCrystalsEarned(wave, score, win): base 2.5 per wave, +40 for victory,
  +floor(score/250), minimum 1 crystal per game

REST API (server/server.mjs)
- /api/auth/register: username 3–16 chars a-z0-9_-; password min 8 chars;
  case-insensitive uniqueness; auto-creates user_stats row; returns
  session cookie (HttpOnly, SameSite=Lax, Secure when HTTPS)
- /api/auth/login: constant-time username lookup via scrypt verify;
  rate-limited 20 auth attempts/IP/minute
- /api/auth/logout: deletes session server-side, clears cookie
- /api/auth/me: returns publicUser (id, username, crystals, upgrades,
  stats, oidc flag) or null
- /api/auth/oidc/login: PKCE Authorization Code flow with SHA-256 S256
  challenge/verifier; discovers .well-known/openid-configuration;
  verifies RS256 id_token signature via JWKS public key; validates
  issuer, audience, and expiry; finds or creates user by OIDC sub/issuer
- /api/auth/oidc/callback: exchanges code for tokens, verifies id_token,
  issues session cookie, redirects to /?auth=ok or /?auth=error
- /api/upgrades/buy: validates upgrade ID against META_UPGRADES,
  checks current level < maxLevel, deducts cost from crystals
- /api/game/finish: server-authoritative crystal calculation;
  bounds-checks inputs (wave ≤ 9999, score ≤ 10M, kills ≤ 1M);
  updates user_stats (games_played, games_won, total_kills, total_score,
  highest_wave via MAX)
- /api/auth/merge-guest: one-time guest-to-account crystal and upgrade
  migration with level caps
- /api/config: public endpoint exposing OIDC enabled state and button label
- Security: CSP header on all responses, X-Content-Type-Options: nosniff,
  X-Frame-Options: DENY, Referrer-Policy: no-referrer, cache-control
  no-store on API responses, path-traversal protection on static serving

Multiplayer Fairness
- Meta-upgrades (start_gold, bonus_lives, tower_range, tower_speed,
  dot_potency, wave_bonus, obstacle_discount, shockwave, fortress_shield)
  applied only in solo campaign mode
- Co-op and Duel multiplayer sessions reset all meta buffs to zero,
  preserving lockstep determinism and competitive balance
- Multiplayer results set crystalsEarned: 0 to prevent duplicate rewards

Frontend (Vue 3 + TypeScript)
- AuthModal.vue: username/password login and registration form with
  validation, OIDC single sign-on button (shown when configured),
  guest-to-account upgrade on first login
- UserProfileBar.vue: top-bar indicator showing crystal count (💎),
  user display name, research and login/logout buttons, reactive
  auth state via auth controller
- ResearchTree.vue: interactive talent tree modal with 3 branches,
  per-upgrade cost/level display, purchase confirmation, disabled
  state for unaffordable/maxed upgrades, branch icons and descriptions
- auth.ts: reactive controller managing login, registration, OIDC
  redirect detection (?auth=ok/?auth=error), guest profile migration
  on first login, upgrade purchasing, and game result reporting
- meta.ts: frontend helpers for branch definitions, upgrade costs,
  and guest profile persistence in localStorage
- engine.ts: solo meta bonus application (start_gold, bonus_lives,
  tower_range, tower_speed, dot_potency, wave_bonus, obstacle_discount,
  shockwave, fortress_shield); crystal rewards in finish() path
- mpgame.ts: meta buff reset in multiplayer sessions; crystalsEarned: 0
- sound.ts: shield sound synthesis for fortress_shield absorption
- store.ts: auth state, research tree toggle, upgrade snapshot
- types.ts: SfxName extended with "shield" sound

Docker Configuration
- Multi-stage build: node:22-alpine build → node:22-alpine runtime
  (production deps only: ws)
- VOLUME /app/data for persistent SQLite database
- HEALTHCHECK on /health endpoint
- docker-compose.yml: port 3001, persistent ./data volume,
  commented OIDC environment variables (OIDC_ENABLED, OIDC_ISSUER,
  OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_BUTTON_LABEL)
- .gitignore: data/, *.db, *.db-journal, *.db-wal, *.db-shm

Automated Tests (npm test)
- scripts/test-db.mjs (17 unit tests): isolated SQLite persistence –
  scrypt hash/verify roundtrip, timing-safe constant-time comparison,
  user creation with lowercase enforcement, UNIQUE constraint, session
  create/get/delete lifecycle, expired session invalidation, crystal
  addition, 5-level upgrade cost progression with max-level guard,
  guest merge (crystal cap 100k, level cap, MAX() semantics, unknown
  upgrade rejection), game result stats accumulation, calcCrystalsEarned
  formula verification
- scripts/test-auth.mjs (31 integration tests): spawns real server with
  isolated DATA_DIR, exercises full REST flow – register validation
  (username too short, password too short, duplicate, case-insensitive),
  login (wrong password, correct), session cookie attributes (HttpOnly,
  SameSite=Lax, Path=/), /me endpoint, upgrade purchase (insufficient
  crystals, unknown ID, successful purchase), game finish rewards
  (victory, defeat, negative values clamped to 1 crystal minimum),
  guest merge (crystals, level caps, unknown upgrades), logout,
  OIDC-disabled endpoints (400), unauthenticated guards (401), 404
  routing, CSP header on static files, rate limiting (429 after 20+
  auth attempts per minute), and persistence across server restart
  (kill + respawn with same DATA_DIR preserves all state)
2026-08-16 16:22:38 +02:00

829 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* TRXTD game server: static frontend (dist/) + multiplayer WebSocket relay.
* Single port for everything ideal for Docker deployments.
*
* Dev: npm run server (ws on :3001 next to vite on :5173)
* Prod: serves dist/ + ws on PORT (default 3001)
*
* Security hardening:
* - WebSocket maxPayload limit (prevents DoS)
* - Heartbeat (ping/pong) to clean dead sockets
* - Rate-limiting per connection (action flood protection)
* - Strict message schema validation
* - Static file serving with path-traversal protection
* - Inactive room cleanup timeout
*/
import http from 'node:http'
import { promises as fs } from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { fileURLToPath } from 'node:url'
import { WebSocketServer } from 'ws'
import {
buyUpgrade,
cleanExpiredSessions,
createSession,
createUserLocal,
deleteSession,
findOrCreateUserOidc,
getSession,
getUserById,
getUserByUsername,
hashPassword,
mergeGuest,
recordGameResult,
verifyPassword,
} from './db.mjs'
import { META_UPGRADES, calcCrystalsEarned } from '../shared/meta-upgrades.mjs'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DIST_DIR = process.env.DIST_DIR || path.join(__dirname, '..', 'dist')
const PORT = Number(process.env.PORT || 3001)
const MAX_PAYLOAD_BYTES = 4096
const MAX_ACTIONS_PER_SEC = 30
const ROOM_TIMEOUT_MS = 30 * 60 * 1000
// public hosting hardening
const MAX_WS_TOTAL = 500 // total concurrent websocket connections
const MAX_WS_PER_IP = 20 // concurrent websocket connections per client IP
const MAX_ROOMS = 300 // concurrent rooms
// ------------------------------------------------------------------ accounts & OIDC config
const SESSION_COOKIE = 'trxtd_session'
const OIDC = {
enabled: String(process.env.OIDC_ENABLED || 'false').toLowerCase() === 'true',
issuer: String(process.env.OIDC_ISSUER || '').replace(/\/$/, ''),
clientId: String(process.env.OIDC_CLIENT_ID || ''),
clientSecret: String(process.env.OIDC_CLIENT_SECRET || ''),
redirectUri: String(process.env.OIDC_REDIRECT_URI || ''),
buttonLabel: String(process.env.OIDC_BUTTON_LABEL || 'Mit Single Sign-On anmelden'),
}
/** pending OIDC authorization attempts (state -> pkce verifier), short-lived */
const oidcPending = new Map()
let oidcDiscovery = null
let oidcDiscoveryAt = 0
// periodic session & oidc-state cleanup
setInterval(() => {
cleanExpiredSessions()
const now = Date.now()
for (const [state, entry] of oidcPending.entries()) {
if (now - entry.createdAt > 10 * 60 * 1000) oidcPending.delete(state)
}
}, 10 * 60 * 1000).unref()
const CSP =
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; " +
"base-uri 'self'; frame-ancestors 'none'"
// ------------------------------------------------------------------ client ip (proxy aware)
function isPrivateIp(ip) {
if (!ip) return false
if (ip === '::1' || ip.startsWith('fc') || ip.startsWith('fd') || ip.startsWith('fe80')) return true
const m = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
if (!m) return false
const a = Number(m[1])
const b = Number(m[2])
return a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254)
}
/**
* Real client IP. X-Forwarded-For is only trusted when the direct peer is a
* private address (reverse proxy / docker network), so external clients cannot
* spoof it. Do not expose the port directly to the internet.
*/
function clientIp(req) {
const socketIp = String(req.socket?.remoteAddress || 'unknown').replace(/^::ffff:/, '')
const xff = req.headers['x-forwarded-for']
if (xff && isPrivateIp(socketIp)) {
const first = String(xff).split(',')[0].trim().replace(/^::ffff:/, '')
if (first) return first
}
return socketIp
}
/**
* WebSocket origin check: blocks cross-site websocket hijacking (other
* websites opening connections to this server from a victim's browser).
* Allowed: same origin, local development, entries in ALLOWED_ORIGINS.
*/
function originAllowed(req) {
const origin = req.headers.origin
if (!origin) return true // non-browser clients (curl, node, bots without origin)
const host = String(req.headers.host || '').toLowerCase()
let originHost = ''
try {
originHost = new URL(origin).host.toLowerCase()
} catch {
return false
}
if (host && originHost === host) return true
if (/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(originHost)) return true
const extra = String(process.env.ALLOWED_ORIGINS || '')
.toLowerCase()
.split(',')
.map((s) => s.trim())
.filter(Boolean)
if (extra.some((o) => o === originHost || o === origin.toLowerCase())) return true
return false
}
// ------------------------------------------------------------------ auth helpers
function parseCookies(req) {
const out = {}
const header = req.headers.cookie
if (!header) return out
for (const part of header.split(';')) {
const idx = part.indexOf('=')
if (idx === -1) continue
out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim())
}
return out
}
function sessionTokenFromReq(req) {
return parseCookies(req)[SESSION_COOKIE] || null
}
function setSessionCookie(res, token, expiresAt, req) {
const proto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim()
const secure = proto === 'https' || (req.socket?.encrypted ?? false)
const sameSite = 'Lax'
res.setHeader(
'Set-Cookie',
`${SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=${sameSite}${secure ? '; Secure' : ''}; Expires=${new Date(expiresAt).toUTCString()}`,
)
}
function clearSessionCookie(res) {
res.setHeader('Set-Cookie', `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`)
}
function json(res, status, obj) {
const body = JSON.stringify(obj)
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
})
res.end(body)
}
function readJsonBody(req, maxBytes = 8192) {
return new Promise((resolve, reject) => {
let size = 0
const chunks = []
req.on('data', (c) => {
size += c.length
if (size > maxBytes) {
reject(new Error('body too large'))
req.destroy()
return
}
chunks.push(c)
})
req.on('end', () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'))
} catch {
reject(new Error('invalid json'))
}
})
req.on('error', reject)
})
}
function publicUser(user) {
if (!user) return null
return {
id: user.id,
username: user.username,
displayName: user.display_name,
crystals: user.crystals,
upgrades: user.upgrades,
stats: user.stats,
oidc: Boolean(user.oidc_sub),
}
}
// simple per-IP rate limit for auth endpoints
const authBuckets = new Map()
function authRateLimited(ip) {
const now = Date.now()
const b = authBuckets.get(ip)
if (!b || now > b.resetAt) {
authBuckets.set(ip, { count: 1, resetAt: now + 60000 })
return false
}
b.count++
return b.count > 20 // 20 auth attempts per minute per IP
}
// ------------------------------------------------------------------ OIDC
function base64urlDecode(s) {
return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64')
}
async function fetchJson(url) {
const res = await fetch(url, { headers: { Accept: 'application/json' } })
if (!res.ok) throw new Error(`fetch ${url} -> ${res.status}`)
return res.json()
}
async function oidcDiscover() {
const now = Date.now()
if (oidcDiscovery && now - oidcDiscoveryAt < 10 * 60 * 1000) return oidcDiscovery
const d = await fetchJson(`${OIDC.issuer}/.well-known/openid-configuration`)
if (!d.authorization_endpoint || !d.token_endpoint) throw new Error('invalid OIDC discovery')
oidcDiscovery = d
oidcDiscoveryAt = now
return d
}
async function verifyIdToken(idToken, discovery) {
const parts = idToken.split('.')
if (parts.length !== 3) throw new Error('malformed id_token')
const [h, p, sig] = parts
const header = JSON.parse(base64urlDecode(h).toString('utf8'))
const payload = JSON.parse(base64urlDecode(p).toString('utf8'))
if (header.alg !== 'RS256') throw new Error('unsupported id_token alg')
const jwks = await fetchJson(discovery.jwks_uri)
const jwk = jwks.keys.find((k) => k.kid === header.kid) || jwks.keys[0]
if (!jwk) throw new Error('no matching JWKS key')
const publicKey = crypto.createPublicKey({ key: jwk, format: 'jwk' })
const ok = crypto.verify('RSA-SHA256', Buffer.from(`${h}.${p}`), publicKey, base64urlDecode(sig))
if (!ok) throw new Error('id_token signature invalid')
if (payload.iss !== OIDC.issuer && payload.iss !== discovery.issuer) throw new Error('issuer mismatch')
const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud]
if (!aud.includes(OIDC.clientId)) throw new Error('audience mismatch')
if (payload.exp && payload.exp * 1000 < Date.now()) throw new Error('id_token expired')
return payload
}
// ------------------------------------------------------------------ REST API
async function handleApi(req, res, url) {
const ip = clientIp(req)
const p = url.pathname
// --- public config (does OIDC exist?) ---
if (p === '/api/config' && req.method === 'GET') {
return json(res, 200, {
oidcEnabled: OIDC.enabled,
oidcLabel: OIDC.buttonLabel,
})
}
// --- current session ---
if (p === '/api/auth/me' && req.method === 'GET') {
const sess = getSession(sessionTokenFromReq(req))
if (!sess) return json(res, 200, { user: null })
return json(res, 200, { user: publicUser(getUserById(sess.user_id)) })
}
// --- logout ---
if (p === '/api/auth/logout' && req.method === 'POST') {
deleteSession(sessionTokenFromReq(req))
clearSessionCookie(res)
return json(res, 200, { ok: true })
}
// --- register (local) ---
if (p === '/api/auth/register' && req.method === 'POST') {
if (authRateLimited(ip)) return json(res, 429, { error: 'Zu viele Versuche. Bitte warte kurz.' })
const body = await readJsonBody(req).catch(() => null)
if (!body) return json(res, 400, { error: 'Ungültige Eingabe.' })
const username = String(body.username || '').trim().toLowerCase()
const password = String(body.password || '')
const displayName = String(body.displayName || username).trim().slice(0, 24) || username
if (!/^[a-z0-9_-]{3,16}$/.test(username)) return json(res, 400, { error: 'Benutzername: 316 Zeichen (az, 09, _ -).' })
if (password.length < 8) return json(res, 400, { error: 'Passwort: mindestens 8 Zeichen.' })
if (getUserByUsername(username)) return json(res, 409, { error: 'Benutzername bereits vergeben.' })
const hash = await hashPassword(password)
const user = createUserLocal(username, displayName, hash)
const sess = createSession(user.id)
setSessionCookie(res, sess.token, sess.expiresAt, req)
return json(res, 200, { user: publicUser(user) })
}
// --- login (local) ---
if (p === '/api/auth/login' && req.method === 'POST') {
if (authRateLimited(ip)) return json(res, 429, { error: 'Zu viele Versuche. Bitte warte kurz.' })
const body = await readJsonBody(req).catch(() => null)
if (!body) return json(res, 400, { error: 'Ungültige Eingabe.' })
const username = String(body.username || '').trim().toLowerCase()
const password = String(body.password || '')
const row = getUserByUsername(username)
if (!row || !row.password_hash) return json(res, 401, { error: 'Benutzername oder Passwort falsch.' })
const ok = await verifyPassword(password, row.password_hash)
if (!ok) return json(res, 401, { error: 'Benutzername oder Passwort falsch.' })
const user = getUserById(row.id)
const sess = createSession(user.id)
setSessionCookie(res, sess.token, sess.expiresAt, req)
return json(res, 200, { user: publicUser(user) })
}
// --- OIDC: start login ---
if (p === '/api/auth/oidc/login' && req.method === 'GET') {
if (!OIDC.enabled) return json(res, 400, { error: 'OIDC ist nicht aktiviert.' })
try {
const d = await oidcDiscover()
const state = crypto.randomBytes(16).toString('hex')
const verifier = crypto.randomBytes(32).toString('base64url')
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
oidcPending.set(state, { verifier, createdAt: Date.now() })
const q = new URLSearchParams({
response_type: 'code',
client_id: OIDC.clientId,
redirect_uri: OIDC.redirectUri,
scope: 'openid profile email',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
})
res.writeHead(302, { Location: `${d.authorization_endpoint}?${q.toString()}` })
return res.end()
} catch (e) {
return json(res, 502, { error: 'OIDC-Provider nicht erreichbar.' })
}
}
// --- OIDC: callback ---
if (p === '/api/auth/oidc/callback' && req.method === 'GET') {
if (!OIDC.enabled) return json(res, 400, { error: 'OIDC ist nicht aktiviert.' })
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const pending = state ? oidcPending.get(state) : null
if (!code || !pending) {
res.writeHead(302, { Location: '/?auth=error' })
return res.end()
}
oidcPending.delete(state)
try {
const d = await oidcDiscover()
const tokenRes = await fetch(d.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: OIDC.redirectUri,
client_id: OIDC.clientId,
client_secret: OIDC.clientSecret,
code_verifier: pending.verifier,
}).toString(),
})
if (!tokenRes.ok) throw new Error('token exchange failed')
const tokens = await tokenRes.json()
const payload = await verifyIdToken(tokens.id_token, d)
const user = findOrCreateUserOidc(
String(payload.sub),
OIDC.issuer,
String(payload.preferred_username || payload.email || ''),
String(payload.name || payload.preferred_username || ''),
)
const sess = createSession(user.id)
setSessionCookie(res, sess.token, sess.expiresAt, req)
res.writeHead(302, { Location: '/?auth=ok' })
return res.end()
} catch {
res.writeHead(302, { Location: '/?auth=error' })
return res.end()
}
}
// --- authenticated endpoints below ---
const sess = getSession(sessionTokenFromReq(req))
if (!sess) return json(res, 401, { error: 'Nicht angemeldet.' })
const user = getUserById(sess.user_id)
if (!user) return json(res, 401, { error: 'Nicht angemeldet.' })
// --- buy meta upgrade ---
if (p === '/api/upgrades/buy' && req.method === 'POST') {
const body = await readJsonBody(req).catch(() => null)
const upgradeId = String(body?.upgradeId || '')
const def = META_UPGRADES[upgradeId]
if (!def) return json(res, 400, { error: 'Unbekanntes Upgrade.' })
const current = user.upgrades[upgradeId] || 0
if (current >= def.maxLevel) return json(res, 400, { error: 'Bereits auf Maximalstufe.' })
const cost = def.costs[current]
const result = buyUpgrade(user.id, upgradeId, cost, def.maxLevel)
if (!result.ok) return json(res, 400, { error: result.error })
return json(res, 200, { user: publicUser(result.user) })
}
// --- merge local guest progress into the account (one-time) ---
if (p === '/api/auth/merge-guest' && req.method === 'POST') {
const body = await readJsonBody(req).catch(() => null)
const maxLevels = {}
for (const [id, def] of Object.entries(META_UPGRADES)) maxLevels[id] = def.maxLevel
const updated = mergeGuest(user.id, body?.crystals, body?.upgrades, maxLevels)
return json(res, 200, { user: publicUser(updated) })
}
// --- record finished game & grant crystals (server-authoritative) ---
if (p === '/api/game/finish' && req.method === 'POST') {
const body = await readJsonBody(req).catch(() => null)
const win = Boolean(body?.win)
const wave = Math.max(0, Math.min(9999, Number(body?.wave) || 0))
const score = Math.max(0, Math.min(10_000_000, Number(body?.score) || 0))
const kills = Math.max(0, Math.min(1_000_000, Number(body?.kills) || 0))
const crystals = calcCrystalsEarned(wave, score, win)
const updated = recordGameResult(user.id, { win, score, wave, kills, crystalsEarned: crystals })
return json(res, 200, { user: publicUser(updated), crystalsEarned: crystals })
}
return json(res, 404, { error: 'Nicht gefunden.' })
}
// ------------------------------------------------------------------ static files
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.txt': 'text/plain; charset=utf-8',
'.map': 'application/json',
}
const DIST_ROOT = path.normalize(DIST_DIR)
async function serveStatic(req, res) {
const url = new URL(req.url, 'http://localhost')
if (url.pathname === '/health') {
res.writeHead(200, { 'Content-Type': 'text/plain' })
res.end('ok')
return
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405, { 'Content-Type': 'text/plain' })
res.end('Method Not Allowed')
return
}
let rel
try {
rel = decodeURIComponent(url.pathname)
} catch {
res.writeHead(400)
res.end('Bad Request')
return
}
if (rel.includes('\0')) {
res.writeHead(400)
res.end('Bad Request')
return
}
let filePath = path.normalize(path.join(DIST_ROOT, rel))
if (filePath !== DIST_ROOT && !filePath.startsWith(DIST_ROOT + path.sep)) {
res.writeHead(403)
res.end('Forbidden')
return
}
let data = await fs.readFile(filePath).catch(() => null)
if (!data) {
// SPA fallback: unknown paths without file extension get index.html
if (!path.extname(rel)) {
filePath = path.join(DIST_ROOT, 'index.html')
data = await fs.readFile(filePath).catch(() => null)
}
if (!data) {
res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('Not Found')
return
}
}
const ext = path.extname(filePath).toLowerCase()
const type = MIME[ext] || 'application/octet-stream'
const isHashedAsset = url.pathname.startsWith('/assets/')
res.writeHead(200, {
'Content-Type': type,
'Cache-Control': isHashedAsset ? 'public, max-age=31536000, immutable' : 'no-cache',
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy': CSP,
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'no-referrer',
})
res.end(req.method === 'HEAD' ? undefined : data)
}
const httpServer = http.createServer((req, res) => {
const url = new URL(req.url, 'http://localhost')
if (url.pathname.startsWith('/api/')) {
handleApi(req, res, url).catch(() => {
if (!res.headersSent) res.writeHead(500)
res.end('Internal Server Error')
})
return
}
serveStatic(req, res).catch(() => {
if (!res.headersSent) res.writeHead(500)
res.end('Internal Server Error')
})
})
// ------------------------------------------------------------------ game rooms
const wss = new WebSocketServer({
noServer: true,
maxPayload: MAX_PAYLOAD_BYTES,
})
// concurrent websocket connection tracking (per IP + global)
const wsPerIp = new Map()
function rejectUpgrade(socket, status, reason) {
try {
socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\n\r\n`)
} catch {
/* ignore */
}
socket.destroy()
}
httpServer.on('upgrade', (req, socket, head) => {
if (!originAllowed(req)) {
console.warn(`[ws] Origin abgelehnt: ${req.headers.origin} von ${clientIp(req)}`)
return rejectUpgrade(socket, 403, 'Forbidden')
}
const ip = clientIp(req)
const perIp = wsPerIp.get(ip) || 0
if (wss.clients.size >= MAX_WS_TOTAL || perIp >= MAX_WS_PER_IP) {
console.warn(`[ws] Verbindungslimit erreicht: ${ip} (${perIp}/${MAX_WS_PER_IP}, total ${wss.clients.size}/${MAX_WS_TOTAL})`)
return rejectUpgrade(socket, 503, 'Service Unavailable')
}
wss.handleUpgrade(req, socket, head, (ws) => {
ws._ip = ip
wsPerIp.set(ip, perIp + 1)
ws.on('close', () => {
const n = (wsPerIp.get(ip) || 1) - 1
if (n <= 0) wsPerIp.delete(ip)
else wsPerIp.set(ip, n)
})
wss.emit('connection', ws, req)
})
})
/** code -> { code, mode, created, seq, players: [{ ws, id, name }] } */
const rooms = new Map()
const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
function makeCode() {
let code
let attempts = 0
do {
code = Array.from({ length: 4 }, () => CODE_CHARS[Math.floor(Math.random() * CODE_CHARS.length)]).join('')
attempts++
if (attempts > 1000) break
} while (rooms.has(code))
return code
}
const VALID_MAPS = ['meadow', 'desert', 'frostland', 'volcano']
function sanitizeName(raw) {
if (typeof raw !== 'string') return 'Spieler'
const clean = raw.trim().replace(/[^\p{L}\p{N}_\- ]/gu, '').slice(0, 12)
return clean || 'Spieler'
}
function sanitizeMapId(raw) {
return VALID_MAPS.includes(raw) ? raw : 'meadow'
}
function send(ws, obj) {
if (ws && ws.readyState === 1) {
try {
ws.send(JSON.stringify(obj))
} catch {
/* ignore socket send failure */
}
}
}
function roomOf(ws) {
const code = ws._room
return code ? rooms.get(code) : undefined
}
function broadcast(room, obj, exceptWs) {
for (const p of room.players) {
if (p.ws !== exceptWs) send(p.ws, obj)
}
}
function playerInfo(room) {
return room.players.map((p) => ({ id: p.id, name: p.name }))
}
function leaveRoom(ws) {
const room = roomOf(ws)
if (!room) return
room.players = room.players.filter((p) => p.ws !== ws)
ws._room = undefined
if (room.players.length === 0) {
rooms.delete(room.code)
} else {
broadcast(room, { t: 'peer-left' })
}
}
function validateAction(a) {
if (!a || typeof a !== 'object' || Array.isArray(a)) return false
switch (a.type) {
case 'build':
return (
['arrow', 'cannon', 'frost', 'tesla', 'laser'].includes(a.kind) &&
Number.isInteger(a.tx) &&
a.tx >= 0 &&
a.tx < 20 &&
Number.isInteger(a.ty) &&
a.ty >= 0 &&
a.ty < 11
)
case 'upgrade':
case 'sell':
return Number.isInteger(a.towerId) && a.towerId > 0
case 'targeting':
return (
Number.isInteger(a.towerId) &&
a.towerId > 0 &&
['first', 'last', 'strong', 'close'].includes(a.mode)
)
case 'obstacle':
return Number.isInteger(a.tx) && a.tx >= 0 && a.tx < 20 && Number.isInteger(a.ty) && a.ty >= 0 && a.ty < 11
case 'wave':
case 'rush':
return true
case 'speed':
return [1, 2, 3].includes(a.s)
default:
return false
}
}
// heartbeat + room timeout
const pingInterval = setInterval(() => {
const now = Date.now()
for (const [code, room] of rooms.entries()) {
if (now - room.created > ROOM_TIMEOUT_MS) {
for (const p of room.players) {
send(p.ws, { t: 'error', msg: 'Raum-Zeitüberschreitung.' })
p.ws._room = undefined
}
rooms.delete(code)
}
}
wss.clients.forEach((ws) => {
if (ws.isAlive === false) {
leaveRoom(ws)
ws.terminate()
return
}
ws.isAlive = false
try {
ws.ping()
} catch {
leaveRoom(ws)
}
})
}, 15000)
wss.on('close', () => {
clearInterval(pingInterval)
})
wss.on('connection', (ws) => {
ws.isAlive = true
ws._actBucket = { count: 0, resetAt: Date.now() + 1000 }
ws.on('pong', () => {
ws.isAlive = true
})
ws.on('message', (data, isBinary) => {
ws.isAlive = true
if (isBinary) return // only JSON text frames are allowed
let m
try {
m = JSON.parse(data.toString())
} catch {
return
}
if (!m || typeof m !== 'object' || typeof m.t !== 'string') return
// rate limiting
const now = Date.now()
if (now > ws._actBucket.resetAt) {
ws._actBucket.count = 0
ws._actBucket.resetAt = now + 1000
}
ws._actBucket.count++
if (ws._actBucket.count > MAX_ACTIONS_PER_SEC) {
send(ws, { t: 'error', msg: 'Zu viele Anfragen gesendet.' })
return
}
switch (m.t) {
case 'create': {
leaveRoom(ws)
if (m.mode !== 'coop' && m.mode !== 'duel') return
if (rooms.size >= MAX_ROOMS) {
return send(ws, { t: 'error', msg: 'Zu viele aktive Räume. Bitte später erneut versuchen.' })
}
const code = makeCode()
const mapId = sanitizeMapId(m.mapId)
const room = { code, mode: m.mode, mapId, created: Date.now(), seq: 0, players: [{ ws, id: 0, name: sanitizeName(m.name) }] }
rooms.set(code, room)
ws._room = code
send(ws, { t: 'room', code, mode: room.mode, mapId: room.mapId, players: playerInfo(room), you: 0 })
break
}
case 'set-map': {
const room = roomOf(ws)
if (!room || room.players.length === 0) return
if (room.players[0].ws !== ws) return // only host may change map
room.mapId = sanitizeMapId(m.mapId)
for (const p of room.players) {
send(p.ws, { t: 'room', code: room.code, mode: room.mode, mapId: room.mapId, players: playerInfo(room), you: p.id })
}
break
}
case 'join': {
leaveRoom(ws)
const code = String(m.code || '').toUpperCase().trim()
const room = rooms.get(code)
if (!room) return send(ws, { t: 'error', msg: 'Raum nicht gefunden.' })
if (room.players.length >= 2) return send(ws, { t: 'error', msg: 'Raum ist bereits voll.' })
room.players.push({ ws, id: 1, name: sanitizeName(m.name) })
ws._room = code
for (const p of room.players) {
send(p.ws, { t: 'room', code: room.code, mode: room.mode, mapId: room.mapId, players: playerInfo(room), you: p.id })
}
break
}
case 'start': {
const room = roomOf(ws)
if (!room || room.players.length !== 2) return
if (room.players[0].ws !== ws) return // only the host may start
const seed = (Math.random() * 1e9) | 0
for (const p of room.players) {
send(p.ws, { t: 'start', seed, mode: room.mode, mapId: room.mapId, players: playerInfo(room), you: p.id })
}
break
}
case 'act': {
const room = roomOf(ws)
if (!room || room.players.length < 2) return
const from = room.players.findIndex((p) => p.ws === ws)
if (from === -1) return
if (!validateAction(m.a)) return
if (!Number.isInteger(m.tick) || m.tick < 0) return
room.seq = (room.seq || 0) + 1
for (const p of room.players) {
send(p.ws, { t: 'act', seq: room.seq, from, tick: m.tick, a: m.a })
}
break
}
case 'prog': {
const room = roomOf(ws)
if (!room) return
const from = room.players.findIndex((p) => p.ws === ws)
if (from === -1) return
if (typeof m.tick !== 'number' || !Number.isFinite(m.tick)) return
broadcast(room, { t: 'prog', from, tick: m.tick })
break
}
case 'leave': {
leaveRoom(ws)
break
}
}
})
ws.on('close', () => leaveRoom(ws))
ws.on('error', () => leaveRoom(ws))
})
httpServer.listen(PORT, () => {
console.log(`TRXTD-Server läuft auf http://localhost:${PORT} (Static: ${DIST_DIR})`)
})