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:
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