TRXTD/server/db.mjs
Tronax 69fbb015ab
feat(admin): live admin dashboard with solo presence tracking
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.
2026-08-17 15:07:02 +02:00

367 lines
12 KiB
JavaScript

import { DatabaseSync } from 'node:sqlite'
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data')
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true })
}
const DB_PATH = path.join(DATA_DIR, 'trxtd.db')
// Fail fast with an actionable message when the database file cannot be
// opened (most commonly a bind-mounted volume the process user cannot
// write to). The default node:sqlite error only says "unable to open
// database file" and hides the path and permission problem.
let db
try {
db = new DatabaseSync(DB_PATH)
} catch (err) {
let writable = false
try {
fs.accessSync(DATA_DIR, fs.constants.W_OK)
writable = true
} catch {
/* directory not writable */
}
const uid = typeof process.getuid === 'function' ? process.getuid() : 'unbekannt'
console.error(
`[db] Datenbank konnte nicht geöffnet werden: ${DB_PATH}\n` +
` Verzeichnis ${DATA_DIR} ist ${writable ? 'beschreibbar' : 'NICHT beschreibbar'} ` +
`(Prozess läuft als UID ${uid}).\n` +
` Bitte prüfen, ob das Volume auf ${DATA_DIR} gemountet und beschreibbar ist.`,
)
throw err
}
// Performance optimizations (WAL mode, normal synchronous)
db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
password_hash TEXT,
oidc_sub TEXT UNIQUE,
oidc_issuer TEXT,
crystals INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_login INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_upgrades (
user_id TEXT NOT NULL,
upgrade_id TEXT NOT NULL,
level INTEGER NOT NULL DEFAULT 1,
unlocked_at INTEGER NOT NULL,
PRIMARY KEY(user_id, upgrade_id),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_stats (
user_id TEXT PRIMARY KEY,
games_played INTEGER NOT NULL DEFAULT 0,
games_won INTEGER NOT NULL DEFAULT 0,
total_kills INTEGER NOT NULL DEFAULT 0,
total_score INTEGER NOT NULL DEFAULT 0,
highest_wave INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_users_oidc ON users(oidc_sub, oidc_issuer);
`)
export function closeDb() {
db.close()
}
// Helper: Password hashing using scrypt
export async function hashPassword(password) {
const salt = crypto.randomBytes(16).toString('hex')
return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) return reject(err)
resolve(`${salt}:${derivedKey.toString('hex')}`)
})
})
}
export async function verifyPassword(password, storedHash) {
if (!storedHash || !storedHash.includes(':')) return false
const [salt, key] = storedHash.split(':')
return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) return reject(err)
const keyBuffer = Buffer.from(key, 'hex')
resolve(crypto.timingSafeEqual(derivedKey, keyBuffer))
})
})
}
// User operations
export function createUserLocal(username, displayName, passwordHash) {
const id = crypto.randomUUID()
const now = Date.now()
const insertUser = db.prepare(`
INSERT INTO users (id, username, display_name, password_hash, crystals, created_at, last_login)
VALUES (?, ?, ?, ?, 0, ?, ?)
`)
insertUser.run(id, username.toLowerCase(), displayName, passwordHash, now, now)
const insertStats = db.prepare(`
INSERT INTO user_stats (user_id, games_played, games_won, total_kills, total_score, highest_wave)
VALUES (?, 0, 0, 0, 0, 0)
`)
insertStats.run(id)
return getUserById(id)
}
export function findOrCreateUserOidc(oidcSub, oidcIssuer, preferredUsername, displayName) {
const findStmt = db.prepare(`SELECT * FROM users WHERE oidc_sub = ? AND oidc_issuer = ?`)
let user = findStmt.get(oidcSub, oidcIssuer)
const now = Date.now()
if (user) {
db.prepare(`UPDATE users SET last_login = ?, display_name = ? WHERE id = ?`).run(now, displayName || user.display_name, user.id)
return getUserById(user.id)
}
// Create unique username if taken
let cleanUsername = (preferredUsername || 'user').toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 16) || 'player'
let finalUsername = cleanUsername
let counter = 1
while (getUserByUsername(finalUsername)) {
finalUsername = `${cleanUsername}${counter++}`
}
const id = crypto.randomUUID()
const insertUser = db.prepare(`
INSERT INTO users (id, username, display_name, oidc_sub, oidc_issuer, crystals, created_at, last_login)
VALUES (?, ?, ?, ?, ?, 0, ?, ?)
`)
insertUser.run(id, finalUsername, displayName || finalUsername, oidcSub, oidcIssuer, now, now)
const insertStats = db.prepare(`
INSERT INTO user_stats (user_id, games_played, games_won, total_kills, total_score, highest_wave)
VALUES (?, 0, 0, 0, 0, 0)
`)
insertStats.run(id)
return getUserById(id)
}
export function getUserById(id) {
const user = db.prepare(`SELECT id, username, display_name, oidc_sub, crystals, created_at, last_login FROM users WHERE id = ?`).get(id)
if (!user) return null
const upgrades = db.prepare(`SELECT upgrade_id, level FROM user_upgrades WHERE user_id = ?`).all(id)
const upgradeMap = {}
for (const u of upgrades) {
upgradeMap[u.upgrade_id] = u.level
}
const stats = db.prepare(`SELECT * FROM user_stats WHERE user_id = ?`).get(id) || {
games_played: 0,
games_won: 0,
total_kills: 0,
total_score: 0,
highest_wave: 0,
}
return {
...user,
upgrades: upgradeMap,
stats: {
gamesPlayed: stats.games_played,
gamesWon: stats.games_won,
totalKills: stats.total_kills,
totalScore: stats.total_score,
highestWave: stats.highest_wave,
},
}
}
export function getUserByUsername(username) {
return db.prepare(`SELECT * FROM users WHERE username = ?`).get(username.toLowerCase())
}
// Session operations
export function createSession(userId, ttlDays = 30) {
const token = crypto.randomBytes(32).toString('hex')
const now = Date.now()
const expiresAt = now + ttlDays * 24 * 60 * 60 * 1000
db.prepare(`INSERT INTO sessions (token, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)`).run(token, userId, expiresAt, now)
return { token, expiresAt }
}
export function getSession(token) {
if (!token) return null
const now = Date.now()
const row = db.prepare(`
SELECT s.token, s.user_id, s.expires_at, u.username, u.display_name
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token = ? AND s.expires_at > ?
`).get(token, now)
return row || null
}
export function deleteSession(token) {
if (token) {
db.prepare(`DELETE FROM sessions WHERE token = ?`).run(token)
}
}
export function cleanExpiredSessions() {
const now = Date.now()
db.prepare(`DELETE FROM sessions WHERE expires_at <= ?`).run(now)
}
// Meta-Progression operations
export function addCrystals(userId, amount) {
db.prepare(`UPDATE users SET crystals = crystals + ? WHERE id = ?`).run(amount, userId)
return getUserById(userId)
}
export function buyUpgrade(userId, upgradeId, cost, maxLevel = 5) {
const user = getUserById(userId)
if (!user || user.crystals < cost) return { ok: false, error: 'Nicht genügend Kristalle.' }
const currentLevel = user.upgrades[upgradeId] || 0
if (currentLevel >= maxLevel) return { ok: false, error: 'Upgrade bereits auf Maximalstufe.' }
const nextLevel = currentLevel + 1
const now = Date.now()
db.exec('BEGIN IMMEDIATE')
try {
db.prepare(`UPDATE users SET crystals = crystals - ? WHERE id = ? AND crystals >= ?`).run(cost, userId, cost)
db.prepare(`
INSERT INTO user_upgrades (user_id, upgrade_id, level, unlocked_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, upgrade_id) DO UPDATE SET level = ?
`).run(userId, upgradeId, nextLevel, now, nextLevel)
db.exec('COMMIT')
return { ok: true, user: getUserById(userId) }
} catch (e) {
db.exec('ROLLBACK')
return { ok: false, error: e.message }
}
}
export function mergeGuest(userId, crystals, upgrades, maxLevels) {
const cappedCrystals = Math.max(0, Math.min(100000, Math.floor(Number(crystals) || 0)))
db.exec('BEGIN IMMEDIATE')
try {
if (cappedCrystals > 0) {
db.prepare(`UPDATE users SET crystals = crystals + ? WHERE id = ?`).run(cappedCrystals, userId)
}
if (upgrades && typeof upgrades === 'object') {
for (const [upgradeId, rawLevel] of Object.entries(upgrades)) {
const max = maxLevels[upgradeId]
if (!max) continue
const level = Math.max(0, Math.min(max, Math.floor(Number(rawLevel) || 0)))
if (level <= 0) continue
const now = Date.now()
db.prepare(`
INSERT INTO user_upgrades (user_id, upgrade_id, level, unlocked_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, upgrade_id) DO UPDATE SET level = MAX(level, ?)
`).run(userId, upgradeId, level, now, level)
}
}
db.exec('COMMIT')
return getUserById(userId)
} catch (e) {
db.exec('ROLLBACK')
throw e
}
}
export function recordGameResult(userId, { win, score, wave, kills, crystalsEarned }) {
const now = Date.now()
db.exec('BEGIN IMMEDIATE')
try {
if (crystalsEarned > 0) {
db.prepare(`UPDATE users SET crystals = crystals + ? WHERE id = ?`).run(crystalsEarned, userId)
}
db.prepare(`
UPDATE user_stats SET
games_played = games_played + 1,
games_won = games_won + ?,
total_kills = total_kills + ?,
total_score = total_score + ?,
highest_wave = MAX(highest_wave, ?)
WHERE user_id = ?
`).run(win ? 1 : 0, kills, score, wave, userId)
db.exec('COMMIT')
return getUserById(userId)
} catch (e) {
db.exec('ROLLBACK')
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,
}
}