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:
Claude committed 2026-07-10 19:43:07 +00:00
commit 96dca2a802
50 files changed
+6887

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
node_modules
*.log
+18
View File
@@ -0,0 +1,18 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev 2>/dev/null || npm install --omit=dev
FROM node:22-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src
# Upload dir owned by the app user; a named volume mounted here inherits
# this ownership on first use
RUN mkdir -p /data/uploads && chown node:node /data/uploads
# Run as the unprivileged built-in user
USER node
EXPOSE 3000
CMD ["node", "src/index.js"]
+1313
View File
File diff suppressed because it is too large. Load diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "sbc-api",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
"express": "^4.19.2",
"express-rate-limit": "^7.4.0",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"pg": "^8.12.0",
"zod": "^3.23.8"
}
}
+32
View File
@@ -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.'
);
}
}
+25
View File
@@ -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);
}
+23
View File
@@ -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));
}
}
}
+53
View File
@@ -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);
}
+40
View File
@@ -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();
};
}
+45
View File
@@ -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.' },
});
+13
View File
@@ -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();
};
}
+312
View File
@@ -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);
}
});
+71
View File
@@ -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);
}
}
);
+70
View File
@@ -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'));
+100
View File
@@ -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);
}
}
);
+74
View File
@@ -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(),
});
+49
View File
@@ -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 });
}