naissance-jerry/server.js

257 lines
9.8 KiB
JavaScript
Raw Normal View History

2026-10-14 21:18:47 +02:00
const path = require('node:path');
const crypto = require('node:crypto');
const express = require('express');
const cookieSession = require('cookie-session');
const { db, getSetting, setSetting } = require('./src/db');
const { leaderboard, MAX } = require('./src/scoring');
const { trends } = require('./src/trends');
const {
PORT = 3000,
BASE_URL = `http://localhost:${PORT}`,
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
SESSION_SECRET,
ADMIN_EMAILS = '',
DEV_LOGIN,
STATS_MIN_PREDICTIONS = 5,
} = process.env;
const devLogin = DEV_LOGIN === 'true';
if (!SESSION_SECRET && !devLogin) {
console.error('SESSION_SECRET est obligatoire (ex : openssl rand -hex 32)');
process.exit(1);
}
if ((!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) && !devLogin) {
console.error('GOOGLE_CLIENT_ID et GOOGLE_CLIENT_SECRET sont obligatoires');
process.exit(1);
}
const admins = new Set(ADMIN_EMAILS.split(',').map((e) => e.trim().toLowerCase()).filter(Boolean));
const isAdmin = (user) => !!user && admins.has(user.email.toLowerCase());
const app = express();
app.set('trust proxy', 1);
app.use(express.json({ limit: '10kb' }));
app.use(cookieSession({
name: 'naissance',
secret: SESSION_SECRET || 'dev-secret',
maxAge: 60 * 24 * 3600 * 1000,
sameSite: 'lax',
httpOnly: true,
secure: BASE_URL.startsWith('https://'),
}));
app.use((req, res, next) => {
const uid = req.session?.uid;
req.user = uid ? db.prepare('SELECT * FROM users WHERE id = ?').get(uid) : null;
next();
});
const requireUser = (req, res, next) => (req.user ? next() : res.status(401).json({ error: 'Connexion requise' }));
const requireAdmin = (req, res, next) => (isAdmin(req.user) ? next() : res.status(403).json({ error: 'Réservé aux administrateurs' }));
// ---------- Authentification Google (OAuth 2.0 / OpenID Connect) ----------
const redirectUri = `${BASE_URL}/auth/google/callback`;
function upsertUser({ sub, email, given_name, family_name, picture }) {
db.prepare(`
INSERT INTO users (id, email, given_name, family_name, picture) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET email = excluded.email, given_name = excluded.given_name,
family_name = excluded.family_name, picture = excluded.picture
`).run(sub, email, given_name || email.split('@')[0], family_name || null, picture || null);
}
app.get('/auth/google', (req, res) => {
if (devLogin && !GOOGLE_CLIENT_ID) return res.redirect('/auth/dev');
const state = crypto.randomBytes(16).toString('hex');
req.session.oauthState = state;
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'openid email profile',
state,
prompt: 'select_account',
});
res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`);
});
app.get('/auth/google/callback', async (req, res) => {
const { code, state } = req.query;
const expected = req.session.oauthState;
req.session.oauthState = null;
if (!code || !state || state !== expected) return res.redirect('/?erreur=connexion');
try {
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code, client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET,
redirect_uri: redirectUri, grant_type: 'authorization_code',
}),
});
if (!tokenRes.ok) throw new Error(`token ${tokenRes.status}`);
const { access_token } = await tokenRes.json();
const infoRes = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
headers: { Authorization: `Bearer ${access_token}` },
});
if (!infoRes.ok) throw new Error(`userinfo ${infoRes.status}`);
const info = await infoRes.json();
if (!info.sub || !info.email || !info.email_verified) throw new Error('email non vérifié');
upsertUser(info);
req.session.uid = info.sub;
res.redirect('/');
} catch (err) {
console.error('Échec de la connexion Google :', err.message);
res.redirect('/?erreur=connexion');
}
});
if (devLogin) {
// Connexion factice pour le développement local uniquement (DEV_LOGIN=true).
app.get('/auth/dev', (req, res) => {
const name = String(req.query.name || 'Testeur').slice(0, 30);
const email = String(req.query.email || `${name.toLowerCase()}@example.com`);
upsertUser({ sub: `dev-${email}`, email, given_name: name });
req.session.uid = `dev-${email}`;
res.redirect('/');
});
}
app.post('/auth/logout', (req, res) => {
req.session = null;
res.json({ ok: true });
});
// ---------- Validation ----------
function validatePrediction(b) {
const errors = [];
const out = {};
const d = /^\d{4}-\d{2}-\d{2}$/.test(b.birth_date || '') && new Date(`${b.birth_date}T00:00:00Z`);
if (!d || Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== b.birth_date) errors.push('Jour invalide');
else out.birth_date = b.birth_date;
const t = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(b.birth_time || '');
if (!t) errors.push('Heure invalide');
else out.birth_time = b.birth_time;
if (!['F', 'M'].includes(b.sex)) errors.push('Sexe invalide');
else out.sex = b.sex;
const w = Number(b.weight_g);
if (!Number.isInteger(w) || w < 500 || w > 6500) errors.push('Poids invalide (entre 500 et 6500 g)');
else out.weight_g = w;
const l = Number(b.length_cm);
if (!Number.isFinite(l) || l < 25 || l > 65) errors.push('Taille invalide (entre 25 et 65 cm)');
else out.length_cm = Math.round(l * 10) / 10;
const n = String(b.first_name || '').trim().replace(/\s+/g, ' ');
if (n.length < 1 || n.length > 40) errors.push('Prénom invalide');
else out.first_name = n;
return { errors, value: out };
}
// ---------- API ----------
function displayNames() {
const users = db.prepare('SELECT id, given_name, family_name FROM users').all();
const counts = {};
for (const u of users) counts[u.given_name.toLowerCase()] = (counts[u.given_name.toLowerCase()] || 0) + 1;
const map = {};
for (const u of users) {
const dup = counts[u.given_name.toLowerCase()] > 1 && u.family_name;
map[u.id] = dup ? `${u.given_name} ${u.family_name[0]}.` : u.given_name;
}
return map;
}
app.get('/api/me', (req, res) => {
const u = req.user;
res.json({
user: u ? { id: u.id, firstName: u.given_name, picture: u.picture, email: u.email } : null,
isAdmin: isAdmin(u),
});
});
app.get('/api/state', (req, res) => {
const result = getSetting('result');
const names = displayNames();
const rows = db.prepare(`
SELECT p.*, u.picture FROM predictions p JOIN users u ON u.id = p.user_id ORDER BY p.created_at
`).all().map((p) => ({ ...p, display_name: names[p.user_id], mine: req.user?.id === p.user_id }));
const base = {
locked: !!getSetting('locked') || !!result,
dueDate: getSetting('dueDate'),
result,
maxPoints: MAX,
count: rows.length,
};
if (result) return res.json({ ...base, predictions: leaderboard(rows, result) });
// Avant la naissance, personne ne voit les pronostics des autres : seulement le sien,
// le nombre de participants et, s'il y en a assez, des tendances arrondies.
const minForTrends = Number(STATS_MIN_PREDICTIONS);
res.json({
...base,
predictions: rows.filter((p) => p.mine),
trends: rows.length >= minForTrends ? trends(rows) : null,
minForTrends,
// Les administrateurs voient qui a participé (sans les valeurs) pour pouvoir modérer.
participants: isAdmin(req.user) ? rows.map((p) => ({ user_id: p.user_id, display_name: p.display_name })) : undefined,
});
});
app.put('/api/predictions/me', requireUser, (req, res) => {
if (getSetting('locked') || getSetting('result')) return res.status(409).json({ error: 'Les pronostics sont clôturés' });
const { errors, value: v } = validatePrediction(req.body || {});
if (errors.length) return res.status(400).json({ error: errors.join(', ') });
db.prepare(`
INSERT INTO predictions (user_id, birth_date, birth_time, sex, weight_g, length_cm, first_name)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET birth_date = excluded.birth_date, birth_time = excluded.birth_time,
sex = excluded.sex, weight_g = excluded.weight_g, length_cm = excluded.length_cm,
first_name = excluded.first_name, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
`).run(req.user.id, v.birth_date, v.birth_time, v.sex, v.weight_g, v.length_cm, v.first_name);
res.json({ ok: true });
});
app.delete('/api/predictions/me', requireUser, (req, res) => {
if (getSetting('locked') || getSetting('result')) return res.status(409).json({ error: 'Les pronostics sont clôturés' });
db.prepare('DELETE FROM predictions WHERE user_id = ?').run(req.user.id);
res.json({ ok: true });
});
// ---------- Administration ----------
app.put('/api/admin/settings', requireAdmin, (req, res) => {
const { locked, dueDate } = req.body || {};
if (locked !== undefined) setSetting('locked', !!locked);
if (dueDate !== undefined) {
if (dueDate && !/^\d{4}-\d{2}-\d{2}$/.test(dueDate)) return res.status(400).json({ error: 'Date de terme invalide' });
setSetting('dueDate', dueDate || null);
}
res.json({ ok: true });
});
app.put('/api/admin/result', requireAdmin, (req, res) => {
const { errors, value } = validatePrediction(req.body || {});
if (errors.length) return res.status(400).json({ error: errors.join(', ') });
setSetting('result', value);
res.json({ ok: true });
});
app.delete('/api/admin/result', requireAdmin, (req, res) => {
setSetting('result', null);
res.json({ ok: true });
});
app.delete('/api/admin/predictions/:userId', requireAdmin, (req, res) => {
db.prepare('DELETE FROM predictions WHERE user_id = ?').run(req.params.userId);
res.json({ ok: true });
});
app.use(express.static(path.join(__dirname, 'public')));
app.listen(PORT, () => {
console.log(`🍼 Naissance en écoute sur ${BASE_URL}${devLogin ? ' (DEV_LOGIN actif)' : ''}`);
});