import { Router, Request, Response } from 'express'; import rateLimit from 'express-rate-limit'; import { db } from '../db/connection.js'; import { randomBytes } from 'crypto'; const router = Router(); const isDev = process.env.NODE_ENV !== 'production'; const gameCreationLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: isDev ? 100 : 30, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many game creations, please try again later.' }, }); const gameLookupLimiter = rateLimit({ windowMs: 60 * 1000, max: isDev ? 500 : 100, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many requests, please try again later.' }, }); const SESSION_TTL_MINUTES = parseInt(process.env.GAME_SESSION_TTL_MINUTES || '5', 10); interface GameSession { pin: string; host_peer_id: string; host_secret: string; quiz_data: string; game_config: string; game_state: string; current_question_index: number; players_data: string; first_correct_player_id: string | null; created_at: string; updated_at: string; } const cleanupExpiredSessions = () => { // Use SQLite's datetime function with the same format as CURRENT_TIMESTAMP // to avoid string comparison issues between JS ISO format and SQLite format const result = db.prepare(` DELETE FROM game_sessions WHERE updated_at < datetime('now', '-' || ? || ' minutes') `).run(SESSION_TTL_MINUTES); if (result.changes > 0) { console.log(`Cleaned up ${result.changes} expired game session(s)`); } }; setInterval(cleanupExpiredSessions, 60 * 1000); router.post('/', gameCreationLimiter, (req: Request, res: Response) => { try { const { pin, hostPeerId, quiz, gameConfig } = req.body; if (!pin || !hostPeerId || !quiz || !gameConfig) { res.status(400).json({ error: 'Missing required fields' }); return; } const hostSecret = randomBytes(32).toString('hex'); db.prepare(` INSERT INTO game_sessions (pin, host_peer_id, host_secret, quiz_data, game_config, game_state, players_data) VALUES (?, ?, ?, ?, ?, 'LOBBY', '[]') `).run(pin, hostPeerId, hostSecret, JSON.stringify(quiz), JSON.stringify(gameConfig)); res.status(201).json({ success: true, hostSecret }); } catch (err: any) { if (err.code === 'SQLITE_CONSTRAINT_PRIMARYKEY') { res.status(409).json({ error: 'Game PIN already exists' }); return; } console.error('Error creating game session:', err); res.status(500).json({ error: 'Failed to create game session' }); } }); router.get('/:pin', gameLookupLimiter, (req: Request, res: Response) => { try { const { pin } = req.params; cleanupExpiredSessions(); const session = db.prepare('SELECT * FROM game_sessions WHERE pin = ?').get(pin) as GameSession | undefined; if (!session) { res.status(404).json({ error: 'Game not found' }); return; } const gameConfig = JSON.parse(session.game_config); res.json({ pin: session.pin, hostPeerId: session.host_peer_id, gameState: session.game_state, currentQuestionIndex: session.current_question_index, quizTitle: JSON.parse(session.quiz_data).title, playerCount: JSON.parse(session.players_data).length, randomNamesEnabled: gameConfig.randomNamesEnabled || false, }); } catch (err) { console.error('Error getting game session:', err); res.status(500).json({ error: 'Failed to get game session' }); } }); router.get('/:pin/host', (req: Request, res: Response) => { try { const { pin } = req.params; const hostSecret = req.headers['x-host-secret'] as string; if (!hostSecret) { res.status(401).json({ error: 'Host secret required' }); return; } const session = db.prepare('SELECT * FROM game_sessions WHERE pin = ? AND host_secret = ?').get(pin, hostSecret) as GameSession | undefined; if (!session) { res.status(404).json({ error: 'Game not found or invalid secret' }); return; } res.json({ pin: session.pin, hostPeerId: session.host_peer_id, quiz: JSON.parse(session.quiz_data), gameConfig: JSON.parse(session.game_config), gameState: session.game_state, currentQuestionIndex: session.current_question_index, players: JSON.parse(session.players_data), firstCorrectPlayerId: session.first_correct_player_id, }); } catch (err) { console.error('Error getting host session:', err); res.status(500).json({ error: 'Failed to get game session' }); } }); router.patch('/:pin', (req: Request, res: Response) => { try { const { pin } = req.params; const hostSecret = req.headers['x-host-secret'] as string; const { hostPeerId, gameState, currentQuestionIndex, players, firstCorrectPlayerId } = req.body; if (!hostSecret) { res.status(401).json({ error: 'Host secret required' }); return; } const session = db.prepare('SELECT pin FROM game_sessions WHERE pin = ? AND host_secret = ?').get(pin, hostSecret); if (!session) { res.status(404).json({ error: 'Game not found or invalid secret' }); return; } const updates: string[] = []; const values: any[] = []; if (hostPeerId !== undefined) { updates.push('host_peer_id = ?'); values.push(hostPeerId); } if (gameState !== undefined) { updates.push('game_state = ?'); values.push(gameState); } if (currentQuestionIndex !== undefined) { updates.push('current_question_index = ?'); values.push(currentQuestionIndex); } if (players !== undefined) { updates.push('players_data = ?'); values.push(JSON.stringify(players)); } if (firstCorrectPlayerId !== undefined) { updates.push("first_correct_player_id = ?"); values.push(firstCorrectPlayerId); } if (updates.length === 0) { res.status(400).json({ error: 'No updates provided' }); return; } updates.push('updated_at = CURRENT_TIMESTAMP'); values.push(pin, hostSecret); db.prepare(` UPDATE game_sessions SET ${updates.join(', ')} WHERE pin = ? AND host_secret = ? `).run(...values); res.json({ success: true }); } catch (err) { console.error('Error updating game session:', err); res.status(500).json({ error: 'Failed to update game session' }); } }); router.delete('/:pin', (req: Request, res: Response) => { try { const { pin } = req.params; const hostSecret = req.headers['x-host-secret'] as string; if (!hostSecret) { res.status(401).json({ error: 'Host secret required' }); return; } const result = db.prepare('DELETE FROM game_sessions WHERE pin = ? AND host_secret = ?').run(pin, hostSecret); if (result.changes === 0) { res.status(404).json({ error: 'Game not found or invalid secret' }); return; } res.json({ success: true }); } catch (err) { console.error('Error deleting game session:', err); res.status(500).json({ error: 'Failed to delete game session' }); } }); export default router;