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:
2
server/.dockerignore
Normal file
2
server/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
data
|
||||
60
server/auth.js
Normal file
60
server/auth.js
Normal 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
38
server/db.js
Normal 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
17
server/package.json
Normal 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
219
server/server.js
Normal 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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user