231 lines
7 KiB
TypeScript
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;
|