const path = require('path'); const express = require('express'); const cookieParser = require('cookie-parser'); const rateLimit = require('express-rate-limit'); const db = require('./db'); const { hashPassword, verifyPassword, signToken, setAuthCookie, clearAuthCookie, authMiddleware, requireAuth, } = require('./auth'); const PORT = process.env.PORT || 3000; const DIFFICULTIES = ['easy', 'medium', 'hard', 'expert']; const BASE_POINTS = { easy: 1200, medium: 2200, hard: 3400, expert: 4800 }; const MIN_TIME_SECONDS = { easy: 20, medium: 30, hard: 45, expert: 60 }; const PSEUDO_RE = /^[a-zA-Z0-9_]{3,20}$/; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; function computePoints(difficulty, timeSeconds, mistakes) { const base = BASE_POINTS[difficulty] || BASE_POINTS.medium; const raw = (base / Math.max(timeSeconds, 1)) * 10 - mistakes * 3; return Math.max(0, Math.round(raw)); } const app = express(); app.disable('x-powered-by'); app.use(express.json()); app.use(cookieParser()); app.use(authMiddleware); const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false, message: { error: 'RATE_LIMITED' }, }); // ---------- Auth routes ---------- app.post('/api/auth/register', authLimiter, (req, res) => { const pseudo = String(req.body.pseudo || '').trim(); const email = String(req.body.email || '').trim().toLowerCase(); const password = String(req.body.password || ''); if (!PSEUDO_RE.test(pseudo)) { return res.status(400).json({ error: 'INVALID_PSEUDO' }); } if (!EMAIL_RE.test(email)) { return res.status(400).json({ error: 'INVALID_EMAIL' }); } if (password.length < 6) { return res.status(400).json({ error: 'INVALID_PASSWORD' }); } try { const passwordHash = hashPassword(password); const info = db .prepare('INSERT INTO users (pseudo, email, password_hash) VALUES (?, ?, ?)') .run(pseudo, email, passwordHash); const user = { id: info.lastInsertRowid, pseudo }; const token = signToken(user); setAuthCookie(res, token); res.json({ id: user.id, pseudo: user.pseudo, email, totalPoints: 0, wins: 0 }); } catch (e) { if (String(e.message).includes('users.pseudo')) { return res.status(409).json({ error: 'PSEUDO_TAKEN' }); } if (String(e.message).includes('users.email')) { return res.status(409).json({ error: 'EMAIL_TAKEN' }); } console.error(e); res.status(500).json({ error: 'REGISTER_FAILED' }); } }); app.post('/api/auth/login', authLimiter, (req, res) => { const identifier = String(req.body.identifier || '').trim().toLowerCase(); const password = String(req.body.password || ''); if (!identifier || !password) { return res.status(400).json({ error: 'MISSING_CREDENTIALS' }); } const user = db .prepare('SELECT * FROM users WHERE lower(pseudo) = ? OR email = ?') .get(identifier, identifier); if (!user || !verifyPassword(password, user.password_hash)) { return res.status(401).json({ error: 'INVALID_CREDENTIALS' }); } const token = signToken(user); setAuthCookie(res, token); res.json({ id: user.id, pseudo: user.pseudo, email: user.email }); }); app.post('/api/auth/logout', (req, res) => { clearAuthCookie(res); res.json({ ok: true }); }); app.get('/api/auth/me', (req, res) => { if (!req.user) return res.json({ user: null }); const row = db .prepare( `SELECT u.id, u.pseudo, u.email, COALESCE(SUM(g.points), 0) AS totalPoints, COALESCE(SUM(g.won), 0) AS wins FROM users u LEFT JOIN games g ON g.user_id = u.id WHERE u.id = ? GROUP BY u.id` ) .get(req.user.id); if (!row) return res.json({ user: null }); res.json({ user: row }); }); // ---------- Games routes ---------- app.post('/api/games', requireAuth, (req, res) => { const difficulty = String(req.body.difficulty || ''); const timeSeconds = Math.round(Number(req.body.time_seconds)); const mistakes = Math.round(Number(req.body.mistakes)); const won = !!req.body.won; if (!DIFFICULTIES.includes(difficulty)) { return res.status(400).json({ error: 'INVALID_DIFFICULTY' }); } if (!Number.isFinite(timeSeconds) || timeSeconds < 1 || timeSeconds > 36000) { return res.status(400).json({ error: 'INVALID_TIME' }); } if (!Number.isFinite(mistakes) || mistakes < 0 || mistakes > 999) { return res.status(400).json({ error: 'INVALID_MISTAKES' }); } if (won && timeSeconds < MIN_TIME_SECONDS[difficulty]) { return res.status(400).json({ error: 'TIME_TOO_SHORT' }); } const points = won ? computePoints(difficulty, timeSeconds, mistakes) : 0; const info = db .prepare( 'INSERT INTO games (user_id, difficulty, time_seconds, mistakes, won, points) VALUES (?, ?, ?, ?, ?, ?)' ) .run(req.user.id, difficulty, timeSeconds, mistakes, won ? 1 : 0, points); res.json({ id: info.lastInsertRowid, difficulty, time_seconds: timeSeconds, mistakes, won, points }); }); app.get('/api/games/history', requireAuth, (req, res) => { const rows = db .prepare( `SELECT difficulty, time_seconds, mistakes, won, points, created_at FROM games WHERE user_id = ? ORDER BY created_at DESC LIMIT 50` ) .all(req.user.id); res.json({ history: rows }); }); // ---------- Leaderboard routes ---------- app.get('/api/leaderboard/best-times', (req, res) => { const difficulty = String(req.query.difficulty || ''); if (!DIFFICULTIES.includes(difficulty)) { return res.status(400).json({ error: 'INVALID_DIFFICULTY' }); } const rows = db .prepare( `SELECT u.pseudo AS pseudo, g.time_seconds AS time_seconds, g.mistakes AS mistakes, g.created_at AS created_at FROM games g JOIN users u ON u.id = g.user_id WHERE g.difficulty = ? AND g.won = 1 AND g.time_seconds = ( SELECT MIN(g2.time_seconds) FROM games g2 WHERE g2.user_id = g.user_id AND g2.difficulty = g.difficulty AND g2.won = 1 ) GROUP BY g.user_id ORDER BY g.time_seconds ASC LIMIT 10` ) .all(difficulty); res.json({ difficulty, entries: rows }); }); app.get('/api/leaderboard/points', (req, res) => { const rows = db .prepare( `SELECT u.pseudo AS pseudo, COALESCE(SUM(g.points), 0) AS total_points, COALESCE(SUM(g.won), 0) AS wins, COUNT(*) AS games_played FROM users u JOIN games g ON g.user_id = u.id GROUP BY u.id ORDER BY total_points DESC LIMIT 20` ) .all(); res.json({ entries: rows }); }); // ---------- Static frontend ---------- const publicDir = path.join(__dirname, 'public'); app.use(express.static(publicDir)); app.get('*', (req, res) => { res.sendFile(path.join(publicDir, 'index.html')); }); app.listen(PORT, () => { console.log(`Sudoku server listening on port ${PORT}`); });