The bind-mount permission fix only takes effect after a rebuild, so a stale image keeps failing with the opaque "unable to open database file". This hardens both sides so any remaining failure is self-explanatory: - docker-entrypoint.sh: fall back to chmod 777 when chown is unsupported (network/9p mounts), and fall back to running as root when su-exec is unavailable, so the data dir is always writable on any filesystem. - server/db.mjs: wrap the DatabaseSync open in a try/catch and, on failure, report the exact path, whether the directory is writable, and the process UID instead of the bare SQLite error. Verified with a real container: a normal bind mount serves /health and creates trxtd.db; a read-only mount now prints the directory-permission diagnostic instead of the raw SQLite error.
318 lines
10 KiB
JavaScript
318 lines
10 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
|
|
}
|
|
}
|