kaboot/server/src/routes/games.ts

231 lines
7 KiB
TypeScript

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;