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)
829 lines
27 KiB
JavaScript
829 lines
27 KiB
JavaScript
/**
|
||
* 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: 3–16 Zeichen (a–z, 0–9, _ -).' })
|
||
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})`)
|
||
})
|