256 lines
9.8 KiB
JavaScript
256 lines
9.8 KiB
JavaScript
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)' : ''}`);
|
|
});
|