Solo games run entirely in the browser, so the server previously had no idea who was playing right now (it only saw logins and finished games). This adds lightweight presence reporting and an admin dashboard. Presence (server): - POST /api/presence (logged-in users): heartbeat while a solo game runs, stores username, map (validated), difficulty, wave, since/lastSeen per user; entries expire automatically after 90s without a heartbeat - POST /api/presence/stop: explicit removal when the player exits Admin API (ADMIN_USERS env, comma-separated usernames): - GET /api/admin/overview: live solo players, multiplayer room summaries (code, mode, map, players — the server already tracks rooms), and global stats (accounts, rounds, crystals in circulation) - GET /api/admin/users?limit=100: user list with stats, newest login first - publicUser now carries an admin flag; non-admins get 403 Admin UI (src/components/AdminDashboard.vue, served at /admin): - Login gate for guests/non-admins (guest profiles are detected via isLoggedIn, not just user presence) - KPI cards, live solo table (player, map, difficulty, wave, duration), room table, and account table; auto-refresh every 5 seconds - App.vue renders the dashboard for /admin instead of the game and runs a screen watcher that starts/stops the solo presence heartbeat Config: ADMIN_USERS documented in docker-compose.yml and README. Tests: 6 new integration checks (admin flag, presence report/stop, 403 guard, overview contents, user list) — 37/37 green, build clean. Verified end-to-end in the browser: guest gate, admin login, and a live second player (map/difficulty/wave) appearing in the dashboard.
917 lines
30 KiB
JavaScript
917 lines
30 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,
|
||
globalStats,
|
||
hashPassword,
|
||
listUsers,
|
||
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
|
||
|
||
// ------------------------------------------------------------------ admin & presence
|
||
|
||
/** usernames (comma-separated via ADMIN_USERS env) allowed to open /admin */
|
||
const ADMIN_USERS = String(process.env.ADMIN_USERS || '')
|
||
.toLowerCase()
|
||
.split(',')
|
||
.map((s) => s.trim())
|
||
.filter(Boolean)
|
||
|
||
function isAdminUser(username) {
|
||
return ADMIN_USERS.includes(String(username || '').toLowerCase())
|
||
}
|
||
|
||
/** live solo presence: userId -> { username, displayName, map, difficulty, wave, since, lastSeen } */
|
||
const soloPresence = new Map()
|
||
const PRESENCE_TTL_MS = 90 * 1000
|
||
|
||
function activeSoloPlayers() {
|
||
const now = Date.now()
|
||
const out = []
|
||
for (const [userId, p] of soloPresence.entries()) {
|
||
if (now - p.lastSeen > PRESENCE_TTL_MS) {
|
||
soloPresence.delete(userId) // expired: missed heartbeats
|
||
continue
|
||
}
|
||
out.push({ userId, ...p })
|
||
}
|
||
out.sort((a, b) => a.since - b.since)
|
||
return out
|
||
}
|
||
|
||
function roomSummaries() {
|
||
const out = []
|
||
for (const room of rooms.values()) {
|
||
out.push({
|
||
code: room.code,
|
||
mode: room.mode,
|
||
mapId: room.mapId,
|
||
created: room.created,
|
||
players: room.players.map((p) => ({ id: p.id, name: p.name })),
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// 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),
|
||
admin: isAdminUser(user.username),
|
||
}
|
||
}
|
||
|
||
// 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.' })
|
||
|
||
// --- live presence: solo players heartbeat here while playing ---
|
||
if (p === '/api/presence' && req.method === 'POST') {
|
||
const body = await readJsonBody(req).catch(() => null)
|
||
const now = Date.now()
|
||
const prev = soloPresence.get(user.id)
|
||
soloPresence.set(user.id, {
|
||
username: user.username,
|
||
displayName: user.display_name,
|
||
map: sanitizeMapId(String(body?.map || 'meadow')),
|
||
difficulty: String(body?.difficulty || 'normal').replace(/[^a-z_]/g, '').slice(0, 12) || 'normal',
|
||
wave: Math.max(0, Math.min(9999, Number(body?.wave) || 0)),
|
||
since: prev?.since ?? now,
|
||
lastSeen: now,
|
||
})
|
||
return json(res, 200, { ok: true })
|
||
}
|
||
|
||
if (p === '/api/presence/stop' && req.method === 'POST') {
|
||
soloPresence.delete(user.id)
|
||
return json(res, 200, { ok: true })
|
||
}
|
||
|
||
// --- admin dashboard data (ADMIN_USERS only) ---
|
||
if (p.startsWith('/api/admin/')) {
|
||
if (!isAdminUser(user.username)) return json(res, 403, { error: 'Kein Administrator.' })
|
||
if (p === '/api/admin/overview' && req.method === 'GET') {
|
||
return json(res, 200, {
|
||
soloPlayers: activeSoloPlayers(),
|
||
rooms: roomSummaries(),
|
||
stats: globalStats(),
|
||
})
|
||
}
|
||
if (p === '/api/admin/users' && req.method === 'GET') {
|
||
const limit = Number(url.searchParams.get('limit')) || 100
|
||
return json(res, 200, { users: listUsers(limit) })
|
||
}
|
||
return json(res, 404, { error: 'Nicht gefunden.' })
|
||
}
|
||
|
||
// --- 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',
|
||
'.webmanifest': 'application/manifest+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})`)
|
||
})
|