diff --git a/README.md b/README.md index e90dd1c..8548e7f 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,17 @@ npm run build npm run server # serves dist/ + ws auf :3001 ``` +### 🛡️ Admin-Dashboard (`/admin`) + +Live-Übersicht über den Server: wer gerade **Solo spielt** (Karte, Schwierigkeit, Welle — angemeldete Spieler senden dazu alle 20 s einen Heartbeat), offene **Multiplayer-Räume**, Account-**Statistiken** und die **Nutzerliste**. Aktualisiert sich alle 5 s automatisch. + +Freischalten über die Umgebungsvariable `ADMIN_USERS` (Komma-getrennte Benutzernamen): +```yaml +environment: + - ADMIN_USERS=deinaccount +``` +Dann als eines dieser Konten unter `https://deine-domain/admin` anmelden. Solo-Spiele laufen zwar lokal im Browser, aber angemeldete Spieler melden ihren Spielstatus an den Server; Gäste bleiben unsichtbar. + --- ## Spielmodi diff --git a/docker-compose.yml b/docker-compose.yml index 5562058..272b701 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,9 @@ services: - ./data:/app/data environment: - PORT=3001 + # --- Optional: Admin-Dashboard (/admin) --- + # Komma-getrennte Benutzernamen mit Admin-Zugriff auf das Live-Dashboard + # - ADMIN_USERS=deinaccount # --- Optional: OpenID Connect (Single Sign-On) --- # - OIDC_ENABLED=true # - OIDC_ISSUER=https://auth.example.com diff --git a/scripts/test-auth.mjs b/scripts/test-auth.mjs index ec63b3a..81af8ad 100644 --- a/scripts/test-auth.mjs +++ b/scripts/test-auth.mjs @@ -23,7 +23,7 @@ let child = null function startServer() { child = spawn(process.execPath, ['server/server.mjs'], { cwd: ROOT, - env: { ...process.env, PORT: String(PORT), DATA_DIR: tmp, OIDC_ENABLED: 'false' }, + env: { ...process.env, PORT: String(PORT), DATA_DIR: tmp, OIDC_ENABLED: 'false', ADMIN_USERS: 'siteadmin' }, stdio: ['ignore', 'ignore', 'pipe'], }) child.stderr.on('data', () => {}) // drain (experimental-warning etc.) @@ -247,6 +247,61 @@ try { is(json.user.upgrades.wave_bonus === 1 && json.user.crystals === 42, JSON.stringify(json)) }) + // ------------------------------------------------------- admin & presence + const admin = makeClient() + await check('Admin: ADMIN_USERS-Konto bekommt admin-Flag', async () => { + const { status, json } = await admin.req('POST', '/api/auth/register', { + username: 'siteadmin', + password: 'adminpass123', + displayName: 'Site Admin', + }) + is(status === 200 && json.user.admin === true, JSON.stringify(json)) + const me = await client.req('GET', '/api/auth/me') + is(me.json.user.admin === false, 'Normaler Account darf kein Admin sein') + }) + + await check('Presence: Solo-Spiel wird gemeldet', async () => { + const { status, json } = await client.req('POST', '/api/presence', { + mode: 'solo', + map: 'volcano', + difficulty: 'hard', + wave: 7, + }) + eq({ status, json }, { status: 200, json: { ok: true } }) + }) + + await check('Admin: Übersicht ohne Admin-Rechte -> 403', async () => { + const { status } = await client.req('GET', '/api/admin/overview') + is(status === 403, `status ${status}`) + }) + + await check('Admin: Übersicht zeigt Live-Solo-Spieler & Räume', async () => { + const { status, json } = await admin.req('GET', '/api/admin/overview') + is(status === 200, `status ${status}`) + const p = json.soloPlayers.find((x) => x.username === 'testuser') + if (!p) throw new Error('testuser nicht in soloPlayers') + is(p.map === 'volcano', `map ${p.map}`) + is(p.difficulty === 'hard', `difficulty ${p.difficulty}`) + is(p.wave === 7, `wave ${p.wave}`) + is(Array.isArray(json.rooms), 'rooms fehlen') + is(json.stats.users >= 2, `stats.users ${json.stats.users}`) + }) + + await check('Admin: Nutzerliste mit Statistiken', async () => { + const { status, json } = await admin.req('GET', '/api/admin/users') + is(status === 200 && json.users.length >= 2, JSON.stringify({ status, n: json.users?.length })) + const t = json.users.find((u) => u.username === 'testuser') + if (!t) throw new Error('testuser nicht in Nutzerliste') + is(t.stats.gamesPlayed === 3, `gamesPlayed ${t.stats.gamesPlayed}`) + }) + + await check('Presence: Stop entfernt Spieler aus Live-Übersicht', async () => { + const stop = await client.req('POST', '/api/presence/stop') + is(stop.status === 200, `status ${stop.status}`) + const { json } = await admin.req('GET', '/api/admin/overview') + is(!json.soloPlayers.some((x) => x.username === 'testuser'), 'testuser noch aktiv') + }) + await check('OIDC: Login-Endpunkt ohne Aktivierung -> 400', async () => { const { status, json } = await anon.req('GET', '/api/auth/oidc/login') is(status === 400 && json.error.includes('nicht aktiviert'), JSON.stringify(json)) diff --git a/server/db.mjs b/server/db.mjs index 90cda15..3be203c 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -316,3 +316,52 @@ export function recordGameResult(userId, { win, score, wave, kills, crystalsEarn throw e } } + +// ---------------------------------------------------------------- admin dashboard + +export function listUsers(limit = 100) { + const rows = db + .prepare( + `SELECT u.id, u.username, u.display_name, u.crystals, u.created_at, u.last_login, + s.games_played, s.games_won, s.total_kills, s.total_score, s.highest_wave + FROM users u + LEFT JOIN user_stats s ON s.user_id = u.id + ORDER BY u.last_login DESC + LIMIT ?`, + ) + .all(Math.max(1, Math.min(500, Math.floor(Number(limit) || 100)))) + return rows.map((r) => ({ + id: r.id, + username: r.username, + displayName: r.display_name, + crystals: r.crystals, + createdAt: r.created_at, + lastLogin: r.last_login, + stats: { + gamesPlayed: r.games_played ?? 0, + gamesWon: r.games_won ?? 0, + totalKills: r.total_kills ?? 0, + totalScore: r.total_score ?? 0, + highestWave: r.highest_wave ?? 0, + }, + })) +} + +export function globalStats() { + const r = db + .prepare( + `SELECT COUNT(*) AS users, + COALESCE(SUM(s.games_played), 0) AS gamesPlayed, + COALESCE(SUM(s.games_won), 0) AS gamesWon, + COALESCE(SUM(u.crystals), 0) AS crystalsInCirculation + FROM users u + LEFT JOIN user_stats s ON s.user_id = u.id`, + ) + .get() + return { + users: r.users, + gamesPlayed: r.gamesPlayed, + gamesWon: r.gamesWon, + crystalsInCirculation: r.crystalsInCirculation, + } +} diff --git a/server/server.mjs b/server/server.mjs index 80ee93a..54ff9d1 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -29,7 +29,9 @@ import { getSession, getUserById, getUserByUsername, + globalStats, hashPassword, + listUsers, mergeGuest, recordGameResult, verifyPassword, @@ -63,6 +65,51 @@ 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() @@ -206,6 +253,7 @@ function publicUser(user) { upgrades: user.upgrades, stats: user.stats, oidc: Boolean(user.oidc_sub), + admin: isAdminUser(user.username), } } @@ -402,6 +450,45 @@ async function handleApi(req, res, url) { 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) diff --git a/src/App.vue b/src/App.vue index 6106aad..72b3aec 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,4 +1,5 @@