mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-11-14 11:03:53 +01:00
Wownerogue
A browser-based roguelike synchronized with crypto block timing. A run is a race against the next block: reach the exit before the block lands, or the house wins. The same codebase runs the solo dungeon, the social Tavern, and operator-selected PvP and race rulesets, with separate free and paid leaderboards. Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md for the design, and docs/DEPLOY.md to operate an instance.
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Database Connection Manager
|
||||
* Handles PostgreSQL connection pool and migration management
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Query validator to detect potential SQL injection
|
||||
*/
|
||||
class QueryValidator {
|
||||
isSafe(text, params) {
|
||||
// Check if using parameterized query properly
|
||||
// Count unique placeholders ($1, $2, etc.): PostgreSQL allows reusing $1 multiple times
|
||||
const placeholders = text.match(/\$\d+/g) || [];
|
||||
const uniquePlaceholders = new Set(placeholders);
|
||||
const placeholderCount = uniquePlaceholders.size;
|
||||
if (placeholderCount !== params.length) {
|
||||
console.warn('⚠️ Parameter count mismatch in query');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
validateQuery(query) {
|
||||
if (typeof query === 'string') {
|
||||
console.warn('⚠️ Raw string query detected - should use parameterized queries');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DatabaseManager {
|
||||
constructor() {
|
||||
this.pool = null;
|
||||
this.connected = false;
|
||||
this.lastSuccessfulQueryAt = 0;
|
||||
this.lastFailedQueryAt = 0;
|
||||
this.queryValidator = new QueryValidator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize database connection and run migrations
|
||||
*/
|
||||
async initialize() {
|
||||
const connected = await this.init();
|
||||
if (connected) {
|
||||
await this.runMigrations();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize database connection
|
||||
*/
|
||||
async init() {
|
||||
try {
|
||||
this.pool = new Pool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
database: process.env.DB_NAME || 'wownerogue',
|
||||
user: process.env.DB_USER || 'wownerogue',
|
||||
password: process.env.DB_PASSWORD,
|
||||
max: 40, // Maximum number of clients in the pool
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
|
||||
// Add query monitoring for security
|
||||
this.pool.on('query', (query) => {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
this.queryValidator.validateQuery(query);
|
||||
}
|
||||
});
|
||||
|
||||
// Test connection
|
||||
const client = await this.pool.connect();
|
||||
await client.query('SELECT NOW()');
|
||||
client.release();
|
||||
|
||||
this.connected = true;
|
||||
this.lastSuccessfulQueryAt = Date.now();
|
||||
console.log('✅ Database connected successfully');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Database connection failed:', error.message);
|
||||
this.connected = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run database migrations
|
||||
*/
|
||||
async runMigrations() {
|
||||
try {
|
||||
// Simple migration ledger
|
||||
await this.pool.query(`CREATE TABLE IF NOT EXISTS schema_migrations (filename TEXT PRIMARY KEY, applied_at TIMESTAMP DEFAULT NOW())`);
|
||||
|
||||
const migrationsPath = path.join(__dirname, '../migrations');
|
||||
const files = await fs.readdir(migrationsPath);
|
||||
const sqlFiles = files.filter(file => file.endsWith('.sql')).sort();
|
||||
|
||||
console.log(`📁 Found ${sqlFiles.length} migration files`);
|
||||
|
||||
for (const file of sqlFiles) {
|
||||
// Skip if already applied
|
||||
const existing = await this.pool.query(`SELECT 1 FROM schema_migrations WHERE filename = $1`, [file]);
|
||||
if (existing.rowCount > 0) {
|
||||
console.log(`⏩ Skipping already applied migration: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = path.join(migrationsPath, file);
|
||||
const sql = await fs.readFile(filePath, 'utf8');
|
||||
console.log(`🔄 Running migration: ${file}`);
|
||||
|
||||
// Apply the migration AND record it in the ledger atomically in one
|
||||
// transaction, so a migration can never be applied-but-unrecorded (which
|
||||
// would re-run a possibly-non-idempotent migration on the next boot).
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(sql);
|
||||
await client.query(`INSERT INTO schema_migrations (filename) VALUES ($1)`, [file]);
|
||||
await client.query('COMMIT');
|
||||
} catch (migErr) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
throw migErr;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
console.log(`✅ Migration completed: ${file}`);
|
||||
}
|
||||
|
||||
console.log('✅ Migration phase complete');
|
||||
} catch (error) {
|
||||
console.error('❌ Migration failed:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query with security validation
|
||||
*/
|
||||
async query(text, params = []) {
|
||||
if (!this.connected) {
|
||||
throw new Error('Database not connected');
|
||||
}
|
||||
|
||||
// Validate query structure for security
|
||||
if (!this.queryValidator.isSafe(text, params)) {
|
||||
throw new Error('Potentially unsafe query detected');
|
||||
}
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
const result = await this.pool.query(text, params);
|
||||
this.lastSuccessfulQueryAt = Date.now();
|
||||
const duration = Date.now() - start;
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('📊 Query executed:', { text: text.substring(0, 100), duration, rows: result.rowCount });
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.lastFailedQueryAt = Date.now();
|
||||
console.error('❌ Database query error:', error.message);
|
||||
console.error('Query:', text);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to ensure parameterized queries
|
||||
*/
|
||||
async safeQuery(template, values = {}) {
|
||||
const { text, params } = this.buildParameterizedQuery(template, values);
|
||||
return this.query(text, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build parameterized query from template
|
||||
*/
|
||||
buildParameterizedQuery(template, values) {
|
||||
let paramIndex = 1;
|
||||
const params = [];
|
||||
|
||||
const text = template.replace(/:(\w+)/g, (match, key) => {
|
||||
if (key in values) {
|
||||
params.push(values[key]);
|
||||
return `$${paramIndex++}`;
|
||||
}
|
||||
throw new Error(`Missing parameter: ${key}`);
|
||||
});
|
||||
|
||||
return { text, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a client from the pool for transactions
|
||||
*/
|
||||
async getClient() {
|
||||
if (!this.connected) {
|
||||
throw new Error('Database not connected');
|
||||
}
|
||||
return await this.pool.connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a callback within a database transaction
|
||||
* Automatically handles BEGIN, COMMIT, and ROLLBACK
|
||||
* @param {Function} callback - Async function that receives the client
|
||||
* @returns {Promise<any>} - Result from the callback
|
||||
*/
|
||||
async withTransaction(callback) {
|
||||
const client = await this.getClient();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await callback(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close database connection
|
||||
*/
|
||||
async close() {
|
||||
if (this.pool) {
|
||||
await this.pool.end();
|
||||
this.connected = false;
|
||||
console.log('📴 Database connection closed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection status
|
||||
*/
|
||||
isConnected() {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
isHealthy(maxStaleMs = Number(process.env.DB_HEALTH_MAX_STALE_MS) || 30000) {
|
||||
return this.connected
|
||||
&& this.lastSuccessfulQueryAt > 0
|
||||
&& (Date.now() - this.lastSuccessfulQueryAt) <= maxStaleMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check
|
||||
*/
|
||||
async healthCheck() {
|
||||
try {
|
||||
const result = await this.query('SELECT 1 as status, NOW() as timestamp');
|
||||
return {
|
||||
status: 'healthy',
|
||||
timestamp: result.rows[0].timestamp,
|
||||
connected: this.connected
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
error: error.message,
|
||||
connected: false
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DatabaseManager;
|
||||
@@ -0,0 +1,99 @@
|
||||
const Game = require('../game/game');
|
||||
|
||||
// Verbose logging is limited to debug and development environments.
|
||||
const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development';
|
||||
|
||||
class User {
|
||||
constructor(id, address) {
|
||||
this.id = id; // socket.id
|
||||
this.address = address;
|
||||
this.currentGame = null;
|
||||
this.blockRec = 0; // Block height used for game entry timing
|
||||
this.clientId = null; // socket.client.id, a distinct value from socket.id
|
||||
|
||||
this.stats = {
|
||||
gamesPlayed: 0,
|
||||
gamesWon: 0,
|
||||
gamesLost: 0,
|
||||
totalPlayTime: 0,
|
||||
highestScore: 0,
|
||||
treasuresFound: 0,
|
||||
monstersDefeated: 0
|
||||
};
|
||||
|
||||
// Registration happens in the constructor so every User is reachable by socket id.
|
||||
userRegistry.set(id, this);
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log(`User created and registered: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start tracking a new game for this user.
|
||||
* @param {Game} gameInstance - The game instance this user is playing
|
||||
*/
|
||||
joinGame(gameInstance) {
|
||||
this.currentGame = gameInstance;
|
||||
this.stats.gamesPlayed++;
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log(`[User.joinGame] User ${this.id} joined game ${gameInstance.id}. Total games played: ${this.stats.gamesPlayed}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the end of a game and fold its results into the user statistics.
|
||||
* Does nothing when the user has no current game.
|
||||
* @param {string} result - 'won', 'lost', or 'abandoned'
|
||||
* @param {number} score - Final score
|
||||
* @param {object} gameStats - Additional game statistics
|
||||
*/
|
||||
endGame(result, score = 0, gameStats = {}) {
|
||||
if (this.currentGame) {
|
||||
this.currentGame = null;
|
||||
|
||||
if (result === 'won') {
|
||||
this.stats.gamesWon++;
|
||||
} else if (result === 'lost') {
|
||||
this.stats.gamesLost++;
|
||||
}
|
||||
|
||||
if (score > this.stats.highestScore) {
|
||||
this.stats.highestScore = score;
|
||||
}
|
||||
|
||||
if (gameStats.treasuresFound) {
|
||||
this.stats.treasuresFound += gameStats.treasuresFound;
|
||||
}
|
||||
|
||||
if (gameStats.monstersDefeated) {
|
||||
this.stats.monstersDefeated += gameStats.monstersDefeated;
|
||||
}
|
||||
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log(`[User.endGame] User ${this.id} game ended: ${result}. Score: ${score}. Stats: ${JSON.stringify(this.stats)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {object} A copy of the stats, so callers cannot mutate the user's own counters.
|
||||
*/
|
||||
getStats() {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
isInGame() {
|
||||
return this.currentGame !== null;
|
||||
}
|
||||
}
|
||||
|
||||
// Maps socket.id to User. Declared after the class body; the constructor only reads it at call time,
|
||||
// which is always after this binding is initialised.
|
||||
const userRegistry = new Map();
|
||||
|
||||
module.exports = {
|
||||
User,
|
||||
getUserBySocketId: (socketId) => userRegistry.get(socketId),
|
||||
removeUser: (socketId) => userRegistry.delete(socketId),
|
||||
getAllUsers: () => Array.from(userRegistry.values())
|
||||
};
|
||||
Reference in New Issue
Block a user