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, } }