Initial commit: Sudoku game with accounts, history, leaderboard, themes and i18n

Node/Express + SQLite backend, vanilla JS frontend, deployed via Docker Compose.
This commit is contained in:
2026-08-27 21:24:38 +02:00
commit 00e8dcb7d2
16 changed files with 2405 additions and 0 deletions

219
server/server.js Normal file
View File

@@ -0,0 +1,219 @@
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}`);
});