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

2
server/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
data

60
server/auth.js Normal file
View File

@@ -0,0 +1,60 @@
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me-please';
const COOKIE_NAME = 'sudoku_token';
const TOKEN_TTL = '30d';
const COOKIE_MAX_AGE = 30 * 24 * 60 * 60 * 1000;
function hashPassword(password) {
return bcrypt.hashSync(password, 10);
}
function verifyPassword(password, hash) {
return bcrypt.compareSync(password, hash);
}
function signToken(user) {
return jwt.sign({ id: user.id, pseudo: user.pseudo }, JWT_SECRET, { expiresIn: TOKEN_TTL });
}
function setAuthCookie(res, token) {
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
sameSite: 'lax',
secure: false,
maxAge: COOKIE_MAX_AGE,
});
}
function clearAuthCookie(res) {
res.clearCookie(COOKIE_NAME);
}
function authMiddleware(req, res, next) {
const token = req.cookies ? req.cookies[COOKIE_NAME] : null;
req.user = null;
if (token) {
try {
req.user = jwt.verify(token, JWT_SECRET);
} catch (e) {
req.user = null;
}
}
next();
}
function requireAuth(req, res, next) {
if (!req.user) return res.status(401).json({ error: 'AUTH_REQUIRED' });
next();
}
module.exports = {
hashPassword,
verifyPassword,
signToken,
setAuthCookie,
clearAuthCookie,
authMiddleware,
requireAuth,
};

38
server/db.js Normal file
View File

@@ -0,0 +1,38 @@
const path = require('path');
const fs = require('fs');
const Database = require('better-sqlite3');
const dataDir = process.env.DATA_DIR || path.join(__dirname, 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const db = new Database(path.join(dataDir, 'sudoku.db'));
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pseudo TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS games (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
difficulty TEXT NOT NULL,
time_seconds INTEGER NOT NULL,
mistakes INTEGER NOT NULL DEFAULT 0,
won INTEGER NOT NULL DEFAULT 0,
points INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_games_user ON games(user_id);
CREATE INDEX IF NOT EXISTS idx_games_diff ON games(difficulty, won, time_seconds);
`);
module.exports = db;

17
server/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "sudoku-server",
"version": "1.0.0",
"private": true,
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.3.0",
"cookie-parser": "^1.4.6",
"express": "^4.19.2",
"express-rate-limit": "^7.4.0",
"jsonwebtoken": "^9.0.2"
}
}

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}`);
});