Application complète SLUC Business Club (React + Express + PostgreSQL, Docker)
Implémente l'intégralité de la maquette « SLUC Business Club.dc.html » : - Site public : accueil (héro administrable, carrousel membres, agenda avec inscription en ligne, rencontres passées, demande d'adhésion), annuaire avec recherche/filtres/fiche détaillée, page association - Espace membre : connexion, édition de fiche avec aperçu direct, upload logo/photo, statut d'adhésion par saison, changement de mot de passe - Espace admin : tableau de bord, membres (validation par saison), rencontres (CRUD + inscrits + impression + export Excel), inscriptions, catégories, contenu du site Architecture : 3 conteneurs Docker Compose — PostgreSQL 16 (réseau interne, rôle applicatif restreint), API Express (non-root, read-only fs), nginx non privilégié (frontend React + reverse-proxy + CSP stricte). Sécurité : requêtes 100 % paramétrées, bcrypt + JWT httpOnly SameSite=Strict, vérification d'Origin (CSRF), validation zod, rate limiting, uploads vérifiés par octets magiques avec noms aléatoires, aucun secret committé. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fkg15RCxNgUys4ru73He2o
This commit is contained in:
50 files changed
+6887
No files matched your search
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { query } from './db.js';
|
||||
import { config } from './config.js';
|
||||
|
||||
// Seeded accounts carry the unusable placeholder hash '*seed*'.
|
||||
// On startup we give them a real bcrypt hash from environment variables —
|
||||
// but never overwrite a password that has already been changed.
|
||||
export async function applyInitialPasswords() {
|
||||
if (config.adminInitialPassword) {
|
||||
const hash = await bcrypt.hash(config.adminInitialPassword, 12);
|
||||
const r = await query(
|
||||
`UPDATE users SET password_hash = $1 WHERE role = 'admin' AND password_hash = '*seed*'`,
|
||||
[hash]
|
||||
);
|
||||
if (r.rowCount > 0) console.log(`Initialized password for ${r.rowCount} admin account(s).`);
|
||||
}
|
||||
if (config.memberInitialPassword) {
|
||||
const hash = await bcrypt.hash(config.memberInitialPassword, 12);
|
||||
const r = await query(
|
||||
`UPDATE users SET password_hash = $1 WHERE role = 'member' AND password_hash = '*seed*'`,
|
||||
[hash]
|
||||
);
|
||||
if (r.rowCount > 0) console.log(`Initialized password for ${r.rowCount} member account(s).`);
|
||||
}
|
||||
const locked = await query(`SELECT COUNT(*)::int AS n FROM users WHERE password_hash = '*seed*'`);
|
||||
if (locked.rows[0].n > 0) {
|
||||
console.warn(
|
||||
`${locked.rows[0].n} account(s) still locked (no initial password provided). ` +
|
||||
'Set ADMIN_INITIAL_PASSWORD / MEMBER_INITIAL_PASSWORD to activate them.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
const required = (name) => {
|
||||
const v = process.env[name];
|
||||
if (!v) {
|
||||
console.error(`Missing required environment variable: ${name}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return v;
|
||||
};
|
||||
|
||||
export const config = {
|
||||
port: Number(process.env.PORT || 3000),
|
||||
databaseUrl: required('DATABASE_URL'),
|
||||
jwtSecret: required('JWT_SECRET'),
|
||||
cookieSecure: process.env.COOKIE_SECURE === 'true',
|
||||
uploadDir: process.env.UPLOAD_DIR || '/data/uploads',
|
||||
adminInitialPassword: process.env.ADMIN_INITIAL_PASSWORD || '',
|
||||
memberInitialPassword: process.env.MEMBER_INITIAL_PASSWORD || '',
|
||||
sessionTtlSeconds: 12 * 60 * 60,
|
||||
cookieName: 'sbc_session',
|
||||
};
|
||||
|
||||
if (config.jwtSecret.length < 32) {
|
||||
console.error('JWT_SECRET must be at least 32 characters long.');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import pg from 'pg';
|
||||
import { config } from './config.js';
|
||||
|
||||
export const pool = new pg.Pool({
|
||||
connectionString: config.databaseUrl,
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30_000,
|
||||
connectionTimeoutMillis: 5_000,
|
||||
});
|
||||
|
||||
export const query = (text, params) => pool.query(text, params);
|
||||
|
||||
export async function waitForDb(retries = 30, delayMs = 1000) {
|
||||
for (let i = 1; i <= retries; i++) {
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
return;
|
||||
} catch (err) {
|
||||
if (i === retries) throw err;
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import express from 'express';
|
||||
import helmet from 'helmet';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { config } from './config.js';
|
||||
import { waitForDb } from './db.js';
|
||||
import { applyInitialPasswords } from './bootstrap.js';
|
||||
import { attachUser } from './middleware/auth.js';
|
||||
import { csrfOriginCheck, globalLimiter } from './middleware/security.js';
|
||||
import { publicRouter } from './routes/public.js';
|
||||
import { authRouter } from './routes/auth.js';
|
||||
import { memberRouter } from './routes/member.js';
|
||||
import { adminRouter } from './routes/admin.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
app.disable('x-powered-by');
|
||||
app.set('trust proxy', 1); // behind nginx
|
||||
app.use(helmet());
|
||||
app.use(express.json({ limit: '64kb' }));
|
||||
app.use(cookieParser());
|
||||
app.use(globalLimiter);
|
||||
app.use(csrfOriginCheck);
|
||||
app.use(attachUser);
|
||||
|
||||
app.get('/api/health', (_req, res) => res.json({ ok: true }));
|
||||
app.use('/api/public', publicRouter);
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use('/api/member', memberRouter);
|
||||
app.use('/api/admin', adminRouter);
|
||||
|
||||
app.use((_req, res) => res.status(404).json({ error: 'Introuvable' }));
|
||||
|
||||
// Central error handler: log details server-side, never leak them to clients
|
||||
app.use((err, _req, res, _next) => {
|
||||
if (err.type === 'entity.too.large' || err.type === 'entity.parse.failed') {
|
||||
return res.status(400).json({ error: 'Requête invalide' });
|
||||
}
|
||||
if (err.code === '23503') return res.status(400).json({ error: 'Référence invalide' });
|
||||
if (err.code === '23505') return res.status(409).json({ error: 'Cette valeur existe déjà.' });
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Erreur interne du serveur' });
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForDb();
|
||||
await applyInitialPasswords();
|
||||
app.listen(config.port, () => {
|
||||
console.log(`SBC API listening on :${config.port}`);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Startup failed:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { config } from '../config.js';
|
||||
|
||||
export function issueSession(res, payload) {
|
||||
const token = jwt.sign(payload, config.jwtSecret, {
|
||||
expiresIn: config.sessionTtlSeconds,
|
||||
algorithm: 'HS256',
|
||||
});
|
||||
res.cookie(config.cookieName, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: config.cookieSecure,
|
||||
maxAge: config.sessionTtlSeconds * 1000,
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSession(res) {
|
||||
res.clearCookie(config.cookieName, { path: '/' });
|
||||
}
|
||||
|
||||
export function attachUser(req, _res, next) {
|
||||
const token = req.cookies?.[config.cookieName];
|
||||
if (token) {
|
||||
try {
|
||||
req.user = jwt.verify(token, config.jwtSecret, { algorithms: ['HS256'] });
|
||||
} catch {
|
||||
req.user = null;
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireAuth(role) {
|
||||
return (req, res, next) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'Authentification requise' });
|
||||
if (role && req.user.role !== role) return res.status(403).json({ error: 'Accès refusé' });
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import rateLimit from 'express-rate-limit';
|
||||
|
||||
// CSRF defense-in-depth: session cookie is SameSite=Strict, and every
|
||||
// state-changing request must additionally come from our own origin.
|
||||
export function csrfOriginCheck(req, res, next) {
|
||||
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
|
||||
const origin = req.headers.origin || '';
|
||||
const host = req.headers['x-forwarded-host'] || req.headers.host || '';
|
||||
if (origin) {
|
||||
let originHost;
|
||||
try {
|
||||
originHost = new URL(origin).host;
|
||||
} catch {
|
||||
return res.status(403).json({ error: 'Origine invalide' });
|
||||
}
|
||||
if (originHost !== host) {
|
||||
return res.status(403).json({ error: 'Origine non autorisée' });
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
limit: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Trop de tentatives de connexion. Réessayez dans 15 minutes.' },
|
||||
});
|
||||
|
||||
export const publicFormLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
limit: 20,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Trop de requêtes. Réessayez plus tard.' },
|
||||
});
|
||||
|
||||
export const globalLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
limit: 300,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Trop de requêtes.' },
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export function validate(schema, source = 'body') {
|
||||
return (req, res, next) => {
|
||||
const result = schema.safeParse(req[source]);
|
||||
if (!result.success) {
|
||||
const first = result.error.issues[0];
|
||||
return res.status(400).json({
|
||||
error: `Donnée invalide : ${first.path.join('.')} — ${first.message}`,
|
||||
});
|
||||
}
|
||||
req[source === 'body' ? 'data' : 'params'] = result.data;
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { Router } from 'express';
|
||||
import { query } from '../db.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { validate } from '../middleware/validate.js';
|
||||
import {
|
||||
adminMemberSchema,
|
||||
rencontreSchema,
|
||||
inscriptionAdminSchema,
|
||||
categorySchema,
|
||||
contentSchema,
|
||||
idParam,
|
||||
} from '../schemas.js';
|
||||
import { imageUpload, saveImage, deleteImage } from '../uploads.js';
|
||||
|
||||
export const adminRouter = Router();
|
||||
|
||||
adminRouter.use(requireAuth('admin'));
|
||||
|
||||
const MEMBER_SQL = `
|
||||
SELECT m.id, m.nom, m.secteur, m.categorie_id, c.name AS categorie, m.dirigeant,
|
||||
m.adhesion, m.email, m.tel, m.site, m.presentation, m.valide,
|
||||
m.logo_path, m.photo_path
|
||||
FROM members m LEFT JOIN categories c ON c.id = m.categorie_id`;
|
||||
|
||||
const RENC_SQL = `
|
||||
SELECT r.id, r.titre, r.date_renc, r.heure, r.lieu, r.description, r.places,
|
||||
COUNT(i.id)::int AS inscrits
|
||||
FROM rencontres r LEFT JOIN inscriptions i ON i.rencontre_id = r.id`;
|
||||
|
||||
const INSCR_SQL = `
|
||||
SELECT i.id, i.nom, i.entreprise, i.email, i.tel, i.statut, i.created_at,
|
||||
i.rencontre_id, r.titre AS rencontre
|
||||
FROM inscriptions i JOIN rencontres r ON r.id = i.rencontre_id`;
|
||||
|
||||
// ---------- Dashboard ----------
|
||||
adminRouter.get('/dashboard', async (_req, res, next) => {
|
||||
try {
|
||||
const [kpis, latest, upcoming] = await Promise.all([
|
||||
query(`
|
||||
SELECT
|
||||
(SELECT COUNT(*)::int FROM members WHERE valide) AS membres_valides,
|
||||
(SELECT COUNT(*)::int FROM members WHERE NOT valide) AS a_renouveler,
|
||||
(SELECT COUNT(*)::int FROM rencontres WHERE date_renc >= CURRENT_DATE) AS rencontres_a_venir,
|
||||
(SELECT COUNT(*)::int FROM inscriptions WHERE statut = 'en_attente') AS inscriptions_attente,
|
||||
(SELECT COUNT(*)::int FROM demandes_adhesion WHERE statut = 'nouvelle') AS demandes_nouvelles,
|
||||
(SELECT MIN(date_renc) FROM rencontres WHERE date_renc >= CURRENT_DATE) AS prochaine_date
|
||||
`),
|
||||
query(`${INSCR_SQL} ORDER BY i.created_at DESC, i.id DESC LIMIT 5`),
|
||||
query(`${RENC_SQL} WHERE r.date_renc >= CURRENT_DATE GROUP BY r.id ORDER BY r.date_renc LIMIT 5`),
|
||||
]);
|
||||
res.json({ kpis: kpis.rows[0], latestInscriptions: latest.rows, upcoming: upcoming.rows });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Members ----------
|
||||
adminRouter.get('/members', async (_req, res, next) => {
|
||||
try {
|
||||
const result = await query(`${MEMBER_SQL} ORDER BY m.nom`);
|
||||
res.json({ members: result.rows });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.post('/members', validate(adminMemberSchema), async (req, res, next) => {
|
||||
try {
|
||||
const d = req.data;
|
||||
const result = await query(
|
||||
`INSERT INTO members (nom, secteur, categorie_id, dirigeant, adhesion, email, tel, site, presentation, valide)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
|
||||
[d.nom, d.secteur, d.categorie_id ?? null, d.dirigeant, new Date().getFullYear(),
|
||||
d.email || null, d.tel, d.site, d.presentation, d.valide ?? true]
|
||||
);
|
||||
res.status(201).json({ id: result.rows[0].id });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.put('/members/:id', validate(idParam, 'params'), validate(adminMemberSchema), async (req, res, next) => {
|
||||
try {
|
||||
const d = req.data;
|
||||
const result = await query(
|
||||
`UPDATE members SET nom=$1, secteur=$2, categorie_id=$3, dirigeant=$4, email=$5,
|
||||
tel=$6, site=$7, presentation=$8, valide=COALESCE($9, valide), updated_at=now()
|
||||
WHERE id=$10 RETURNING id`,
|
||||
[d.nom, d.secteur, d.categorie_id ?? null, d.dirigeant, d.email || null,
|
||||
d.tel, d.site, d.presentation, d.valide ?? null, req.params.id]
|
||||
);
|
||||
if (result.rowCount === 0) return res.status(404).json({ error: 'Membre introuvable' });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.post('/members/:id/toggle-valide', validate(idParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const result = await query(
|
||||
'UPDATE members SET valide = NOT valide, updated_at = now() WHERE id = $1 RETURNING valide',
|
||||
[req.params.id]
|
||||
);
|
||||
if (result.rowCount === 0) return res.status(404).json({ error: 'Membre introuvable' });
|
||||
res.json({ valide: result.rows[0].valide });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Rencontres ----------
|
||||
adminRouter.get('/rencontres', async (_req, res, next) => {
|
||||
try {
|
||||
const result = await query(`${RENC_SQL} GROUP BY r.id ORDER BY r.date_renc`);
|
||||
res.json({ rencontres: result.rows });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.post('/rencontres', validate(rencontreSchema), async (req, res, next) => {
|
||||
try {
|
||||
const d = req.data;
|
||||
const result = await query(
|
||||
`INSERT INTO rencontres (titre, date_renc, heure, lieu, description, places)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
[d.titre, d.date_renc, d.heure, d.lieu, d.description, d.places]
|
||||
);
|
||||
res.status(201).json({ id: result.rows[0].id });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.put('/rencontres/:id', validate(idParam, 'params'), validate(rencontreSchema), async (req, res, next) => {
|
||||
try {
|
||||
const d = req.data;
|
||||
const result = await query(
|
||||
`UPDATE rencontres SET titre=$1, date_renc=$2, heure=$3, lieu=$4, description=$5, places=$6
|
||||
WHERE id=$7 RETURNING id`,
|
||||
[d.titre, d.date_renc, d.heure, d.lieu, d.description, d.places, req.params.id]
|
||||
);
|
||||
if (result.rowCount === 0) return res.status(404).json({ error: 'Rencontre introuvable' });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.delete('/rencontres/:id', validate(idParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
await query('DELETE FROM rencontres WHERE id = $1', [req.params.id]);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.get('/rencontres/:id/inscriptions', validate(idParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const result = await query(`${INSCR_SQL} WHERE i.rencontre_id = $1 ORDER BY i.nom`, [req.params.id]);
|
||||
res.json({ inscriptions: result.rows });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Inscriptions ----------
|
||||
adminRouter.get('/inscriptions', async (_req, res, next) => {
|
||||
try {
|
||||
const result = await query(`${INSCR_SQL} ORDER BY i.created_at DESC, i.id DESC`);
|
||||
res.json({ inscriptions: result.rows });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.put('/inscriptions/:id', validate(idParam, 'params'), validate(inscriptionAdminSchema), async (req, res, next) => {
|
||||
try {
|
||||
const d = req.data;
|
||||
const result = await query(
|
||||
`UPDATE inscriptions SET nom=$1, entreprise=$2, email=$3, tel=$4, rencontre_id=$5, statut=$6
|
||||
WHERE id=$7 RETURNING id`,
|
||||
[d.nom, d.entreprise, d.email || null, d.tel, d.rencontre_id, d.statut, req.params.id]
|
||||
);
|
||||
if (result.rowCount === 0) return res.status(404).json({ error: 'Inscription introuvable' });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.post('/inscriptions/:id/confirm', validate(idParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const result = await query(
|
||||
`UPDATE inscriptions SET statut = 'confirmee' WHERE id = $1 RETURNING id`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (result.rowCount === 0) return res.status(404).json({ error: 'Inscription introuvable' });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.delete('/inscriptions/:id', validate(idParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
await query('DELETE FROM inscriptions WHERE id = $1', [req.params.id]);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Categories ----------
|
||||
adminRouter.get('/categories', async (_req, res, next) => {
|
||||
try {
|
||||
const result = await query(`
|
||||
SELECT c.id, c.name, COUNT(m.id)::int AS count
|
||||
FROM categories c LEFT JOIN members m ON m.categorie_id = c.id
|
||||
GROUP BY c.id ORDER BY c.id`);
|
||||
res.json({ categories: result.rows });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.post('/categories', validate(categorySchema), async (req, res, next) => {
|
||||
try {
|
||||
const result = await query(
|
||||
'INSERT INTO categories (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id',
|
||||
[req.data.name]
|
||||
);
|
||||
if (result.rowCount === 0) return res.status(409).json({ error: 'Cette catégorie existe déjà.' });
|
||||
res.status(201).json({ id: result.rows[0].id });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.put('/categories/:id', validate(idParam, 'params'), validate(categorySchema), async (req, res, next) => {
|
||||
try {
|
||||
const result = await query('UPDATE categories SET name = $1 WHERE id = $2 RETURNING id', [
|
||||
req.data.name,
|
||||
req.params.id,
|
||||
]);
|
||||
if (result.rowCount === 0) return res.status(404).json({ error: 'Catégorie introuvable' });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (err.code === '23505') return res.status(409).json({ error: 'Ce nom existe déjà.' });
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.delete('/categories/:id', validate(idParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
// members.categorie_id has ON DELETE SET NULL: they become "Non classée"
|
||||
await query('DELETE FROM categories WHERE id = $1', [req.params.id]);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Site content ----------
|
||||
adminRouter.put('/content', validate(contentSchema), async (req, res, next) => {
|
||||
try {
|
||||
const entries = Object.entries(req.data).filter(([, v]) => v !== undefined);
|
||||
for (const [key, value] of entries) {
|
||||
await query(
|
||||
`INSERT INTO site_content (key, value) VALUES ($1, $2)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||||
[key, value]
|
||||
);
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.post('/content/hero-photo', (req, res, next) => {
|
||||
imageUpload(req, res, async (err) => {
|
||||
if (err) return res.status(400).json({ error: 'Fichier invalide (2 Mo max).' });
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'Aucun fichier reçu.' });
|
||||
const publicPath = await saveImage(req.file.buffer);
|
||||
if (!publicPath) return res.status(400).json({ error: 'Format accepté : JPEG, PNG ou WebP.' });
|
||||
const prev = await query(`SELECT value FROM site_content WHERE key = 'hero_photo'`);
|
||||
await query(
|
||||
`INSERT INTO site_content (key, value) VALUES ('hero_photo', $1)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||||
[publicPath]
|
||||
);
|
||||
await deleteImage(prev.rows[0]?.value);
|
||||
res.json({ path: publicPath });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Demandes d'adhésion ----------
|
||||
adminRouter.get('/demandes', async (_req, res, next) => {
|
||||
try {
|
||||
const result = await query('SELECT * FROM demandes_adhesion ORDER BY created_at DESC');
|
||||
res.json({ demandes: result.rows });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { query } from '../db.js';
|
||||
import { issueSession, clearSession, requireAuth } from '../middleware/auth.js';
|
||||
import { loginLimiter } from '../middleware/security.js';
|
||||
import { validate } from '../middleware/validate.js';
|
||||
import { loginSchema, changePasswordSchema } from '../schemas.js';
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
const publicUser = (u) => ({ id: u.id, email: u.email, role: u.role, memberId: u.member_id });
|
||||
|
||||
authRouter.post('/login', loginLimiter, validate(loginSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { email, password } = req.data;
|
||||
const result = await query(
|
||||
'SELECT id, email, password_hash, role, member_id FROM users WHERE email = $1',
|
||||
[email]
|
||||
);
|
||||
// Always run a bcrypt comparison to keep timing uniform
|
||||
const user = result.rows[0];
|
||||
const hash = user?.password_hash?.startsWith('$2') ? user.password_hash : '$2a$12$invalidinvalidinvalidinvalidinvalidinvalidinvalidinva';
|
||||
const ok = await bcrypt.compare(password, hash);
|
||||
if (!user || !ok || !user.password_hash.startsWith('$2')) {
|
||||
return res.status(401).json({ error: 'Email ou mot de passe incorrect.' });
|
||||
}
|
||||
issueSession(res, { sub: user.id, role: user.role, memberId: user.member_id });
|
||||
res.json({ user: publicUser(user) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
authRouter.post('/logout', (_req, res) => {
|
||||
clearSession(res);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
authRouter.get('/me', async (req, res, next) => {
|
||||
try {
|
||||
if (!req.user) return res.json({ user: null });
|
||||
const result = await query(
|
||||
'SELECT id, email, role, member_id FROM users WHERE id = $1',
|
||||
[req.user.sub]
|
||||
);
|
||||
if (result.rowCount === 0) return res.json({ user: null });
|
||||
res.json({ user: publicUser(result.rows[0]) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
authRouter.post(
|
||||
'/change-password',
|
||||
requireAuth(),
|
||||
validate(changePasswordSchema),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { currentPassword, newPassword } = req.data;
|
||||
const result = await query('SELECT password_hash FROM users WHERE id = $1', [req.user.sub]);
|
||||
if (result.rowCount === 0) return res.status(401).json({ error: 'Session invalide' });
|
||||
const ok = await bcrypt.compare(currentPassword, result.rows[0].password_hash);
|
||||
if (!ok) return res.status(401).json({ error: 'Mot de passe actuel incorrect.' });
|
||||
const hash = await bcrypt.hash(newPassword, 12);
|
||||
await query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, req.user.sub]);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Router } from 'express';
|
||||
import { query } from '../db.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { validate } from '../middleware/validate.js';
|
||||
import { memberProfileSchema } from '../schemas.js';
|
||||
import { imageUpload, saveImage, deleteImage } from '../uploads.js';
|
||||
|
||||
export const memberRouter = Router();
|
||||
|
||||
memberRouter.use(requireAuth('member'));
|
||||
|
||||
const PROFILE_SQL = `
|
||||
SELECT m.id, m.nom, m.secteur, m.categorie_id, c.name AS categorie, m.dirigeant,
|
||||
m.adhesion, m.email, m.tel, m.site, m.presentation, m.valide,
|
||||
m.logo_path, m.photo_path
|
||||
FROM members m LEFT JOIN categories c ON c.id = m.categorie_id
|
||||
WHERE m.id = $1`;
|
||||
|
||||
memberRouter.get('/profile', async (req, res, next) => {
|
||||
try {
|
||||
const result = await query(PROFILE_SQL, [req.user.memberId]);
|
||||
if (result.rowCount === 0) return res.status(404).json({ error: 'Profil introuvable' });
|
||||
res.json({ member: result.rows[0] });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
memberRouter.put('/profile', validate(memberProfileSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { nom, secteur, categorie_id, dirigeant, email, tel, site, presentation } = req.data;
|
||||
await query(
|
||||
`UPDATE members SET nom=$1, secteur=$2, categorie_id=$3, dirigeant=$4, email=$5,
|
||||
tel=$6, site=$7, presentation=$8, updated_at=now()
|
||||
WHERE id=$9`,
|
||||
[nom, secteur, categorie_id ?? null, dirigeant, email || null, tel, site, presentation, req.user.memberId]
|
||||
);
|
||||
const result = await query(PROFILE_SQL, [req.user.memberId]);
|
||||
res.json({ member: result.rows[0] });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
function imageRoute(column) {
|
||||
return (req, res, next) => {
|
||||
imageUpload(req, res, async (err) => {
|
||||
if (err) return res.status(400).json({ error: 'Fichier invalide (2 Mo max).' });
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'Aucun fichier reçu.' });
|
||||
const publicPath = await saveImage(req.file.buffer);
|
||||
if (!publicPath) {
|
||||
return res.status(400).json({ error: 'Format accepté : JPEG, PNG ou WebP.' });
|
||||
}
|
||||
const prev = await query(`SELECT ${column} FROM members WHERE id = $1`, [req.user.memberId]);
|
||||
await query(`UPDATE members SET ${column} = $1, updated_at = now() WHERE id = $2`, [
|
||||
publicPath,
|
||||
req.user.memberId,
|
||||
]);
|
||||
await deleteImage(prev.rows[0]?.[column]);
|
||||
res.json({ path: publicPath });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
memberRouter.post('/profile/logo', imageRoute('logo_path'));
|
||||
memberRouter.post('/profile/photo', imageRoute('photo_path'));
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Router } from 'express';
|
||||
import { query } from '../db.js';
|
||||
import { validate } from '../middleware/validate.js';
|
||||
import { publicFormLimiter } from '../middleware/security.js';
|
||||
import { demandeSchema, inscriptionPublicSchema, idParam } from '../schemas.js';
|
||||
|
||||
export const publicRouter = Router();
|
||||
|
||||
// Single bootstrap payload for the public site
|
||||
publicRouter.get('/bootstrap', async (_req, res, next) => {
|
||||
try {
|
||||
const [content, categories, members, rencontres, passees] = await Promise.all([
|
||||
query('SELECT key, value FROM site_content'),
|
||||
query('SELECT id, name FROM categories ORDER BY id'),
|
||||
query(
|
||||
`SELECT m.id, m.nom, m.secteur, m.dirigeant, m.adhesion, m.email, m.tel, m.site,
|
||||
m.presentation, m.logo_path, m.photo_path, c.name AS categorie
|
||||
FROM members m LEFT JOIN categories c ON c.id = m.categorie_id
|
||||
WHERE m.valide = true ORDER BY m.nom`
|
||||
),
|
||||
query(
|
||||
`SELECT r.id, r.titre, r.date_renc, r.heure, r.lieu, r.description, r.places,
|
||||
COUNT(i.id)::int AS inscrits
|
||||
FROM rencontres r LEFT JOIN inscriptions i ON i.rencontre_id = r.id
|
||||
WHERE r.date_renc >= CURRENT_DATE
|
||||
GROUP BY r.id ORDER BY r.date_renc`
|
||||
),
|
||||
query('SELECT * FROM rencontres_passees ORDER BY id'),
|
||||
]);
|
||||
const contentMap = Object.fromEntries(content.rows.map((r) => [r.key, r.value]));
|
||||
res.json({
|
||||
content: contentMap,
|
||||
categories: categories.rows,
|
||||
members: members.rows,
|
||||
rencontres: rencontres.rows,
|
||||
rencontresPassees: passees.rows,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Membership request (home page form)
|
||||
publicRouter.post(
|
||||
'/demandes-adhesion',
|
||||
publicFormLimiter,
|
||||
validate(demandeSchema),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { nom, fonction, entreprise, email } = req.data;
|
||||
await query(
|
||||
'INSERT INTO demandes_adhesion (nom, fonction, entreprise, email) VALUES ($1, $2, $3, $4)',
|
||||
[nom, fonction, entreprise, email]
|
||||
);
|
||||
res.status(201).json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Event registration (public modal)
|
||||
publicRouter.post(
|
||||
'/rencontres/:id/inscriptions',
|
||||
publicFormLimiter,
|
||||
validate(idParam, 'params'),
|
||||
validate(inscriptionPublicSchema),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { nom, entreprise, email, tel } = req.data;
|
||||
const renc = await query(
|
||||
`SELECT r.places, COUNT(i.id)::int AS inscrits
|
||||
FROM rencontres r LEFT JOIN inscriptions i ON i.rencontre_id = r.id
|
||||
WHERE r.id = $1 AND r.date_renc >= CURRENT_DATE
|
||||
GROUP BY r.id`,
|
||||
[id]
|
||||
);
|
||||
if (renc.rowCount === 0) return res.status(404).json({ error: 'Rencontre introuvable' });
|
||||
if (renc.rows[0].inscrits >= renc.rows[0].places) {
|
||||
return res.status(409).json({ error: 'Cette rencontre est complète.' });
|
||||
}
|
||||
const dup = await query(
|
||||
'SELECT 1 FROM inscriptions WHERE rencontre_id = $1 AND email = $2',
|
||||
[id, email]
|
||||
);
|
||||
if (dup.rowCount > 0) {
|
||||
return res.status(409).json({ error: 'Une inscription existe déjà avec cet email.' });
|
||||
}
|
||||
await query(
|
||||
`INSERT INTO inscriptions (rencontre_id, nom, entreprise, email, tel, statut)
|
||||
VALUES ($1, $2, $3, $4, $5, 'en_attente')`,
|
||||
[id, nom, entreprise, email, tel]
|
||||
);
|
||||
res.status(201).json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,74 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const trimmed = (max, min = 0) => z.string().trim().min(min).max(max);
|
||||
|
||||
export const idParam = z.object({ id: z.coerce.number().int().positive() });
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: trimmed(254, 3).toLowerCase(),
|
||||
password: z.string().min(1).max(200),
|
||||
});
|
||||
|
||||
export const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1).max(200),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(10, 'au moins 10 caractères')
|
||||
.max(200)
|
||||
.regex(/[a-zA-Z]/, 'doit contenir une lettre')
|
||||
.regex(/[0-9]/, 'doit contenir un chiffre'),
|
||||
});
|
||||
|
||||
export const demandeSchema = z.object({
|
||||
nom: trimmed(120, 1),
|
||||
fonction: trimmed(120).optional().default(''),
|
||||
entreprise: trimmed(120, 1),
|
||||
email: trimmed(254, 3).email(),
|
||||
});
|
||||
|
||||
export const inscriptionPublicSchema = z.object({
|
||||
nom: trimmed(120, 1),
|
||||
entreprise: trimmed(120, 1),
|
||||
email: trimmed(254, 3).email(),
|
||||
tel: trimmed(30).optional().default(''),
|
||||
});
|
||||
|
||||
export const memberProfileSchema = z.object({
|
||||
nom: trimmed(120, 1),
|
||||
secteur: trimmed(120, 1),
|
||||
categorie_id: z.coerce.number().int().positive().nullable().optional(),
|
||||
dirigeant: trimmed(120, 1),
|
||||
email: trimmed(254).email().or(z.literal('')).optional().default(''),
|
||||
tel: trimmed(30).optional().default(''),
|
||||
site: trimmed(200).optional().default(''),
|
||||
presentation: trimmed(2000).optional().default(''),
|
||||
});
|
||||
|
||||
export const adminMemberSchema = memberProfileSchema.extend({
|
||||
valide: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const rencontreSchema = z.object({
|
||||
titre: trimmed(200, 1),
|
||||
date_renc: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'format AAAA-MM-JJ'),
|
||||
heure: trimmed(20).optional().default(''),
|
||||
lieu: trimmed(200).optional().default(''),
|
||||
description: trimmed(2000).optional().default(''),
|
||||
places: z.coerce.number().int().min(0).max(100000),
|
||||
});
|
||||
|
||||
export const inscriptionAdminSchema = z.object({
|
||||
nom: trimmed(120, 1),
|
||||
entreprise: trimmed(120, 1),
|
||||
email: trimmed(254).email().or(z.literal('')).optional().default(''),
|
||||
tel: trimmed(30).optional().default(''),
|
||||
rencontre_id: z.coerce.number().int().positive(),
|
||||
statut: z.enum(['confirmee', 'en_attente']),
|
||||
});
|
||||
|
||||
export const categorySchema = z.object({ name: trimmed(80, 1) });
|
||||
|
||||
export const contentSchema = z.object({
|
||||
hero_quote_text: trimmed(300).optional(),
|
||||
hero_quote_author: trimmed(120).optional(),
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import multer from 'multer';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { config } from './config.js';
|
||||
|
||||
const MAX_SIZE = 2 * 1024 * 1024; // 2 MB
|
||||
|
||||
// magic-byte sniffing: never trust the client-provided MIME type
|
||||
const SIGNATURES = [
|
||||
{ ext: 'jpg', mime: 'image/jpeg', check: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
|
||||
{ ext: 'png', mime: 'image/png', check: (b) => b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 },
|
||||
{
|
||||
ext: 'webp',
|
||||
mime: 'image/webp',
|
||||
check: (b) =>
|
||||
b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
|
||||
b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50,
|
||||
},
|
||||
];
|
||||
|
||||
export const imageUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_SIZE, files: 1 },
|
||||
}).single('file');
|
||||
|
||||
export function detectImageType(buffer) {
|
||||
if (!buffer || buffer.length < 12) return null;
|
||||
return SIGNATURES.find((s) => s.check(buffer)) || null;
|
||||
}
|
||||
|
||||
// Saves a validated image with a random, non-guessable filename and
|
||||
// returns the public path. The filename is fully server-generated: no
|
||||
// user input ever reaches the filesystem path.
|
||||
export async function saveImage(buffer) {
|
||||
const type = detectImageType(buffer);
|
||||
if (!type) return null;
|
||||
const name = `${crypto.randomBytes(16).toString('hex')}.${type.ext}`;
|
||||
await fs.mkdir(config.uploadDir, { recursive: true });
|
||||
await fs.writeFile(path.join(config.uploadDir, name), buffer, { flag: 'wx' });
|
||||
return `/uploads/${name}`;
|
||||
}
|
||||
|
||||
export async function deleteImage(publicPath) {
|
||||
if (!publicPath || !publicPath.startsWith('/uploads/')) return;
|
||||
const name = path.basename(publicPath);
|
||||
// basename() strips any traversal; only delete inside the upload dir
|
||||
await fs.rm(path.join(config.uploadDir, name), { force: true });
|
||||
}
|
||||
Reference in new issue
Block a user