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