- Un seul conteneur applicatif : Express sert l'API, le frontend React compilé (fallback SPA, cache immutable sur /assets) et les images /uploads (nosniff + CSP default-src 'none') - En-têtes de sécurité (CSP stricte, X-Frame-Options DENY, etc.) portés de nginx vers helmet, compression gzip ajoutée - Application exposée sur le port 8321 (APP_PORT), PostgreSQL sur 127.0.0.1:56432 (DB_PORT, loopback uniquement pour l'admin locale) - TRUST_PROXY pilote la confiance aux en-têtes X-Forwarded-* (désactivé par défaut hors reverse-proxy) - Dockerfile multi-étages unique à la racine (build React → deps → image finale non-root, fs en lecture seule) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fkg15RCxNgUys4ru73He2o
119 lines
4.0 KiB
JavaScript
119 lines
4.0 KiB
JavaScript
import path from 'node:path';
|
|
import fs from 'node:fs';
|
|
import express from 'express';
|
|
import helmet from 'helmet';
|
|
import compression from 'compression';
|
|
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');
|
|
if (config.trustProxy) app.set('trust proxy', 1);
|
|
|
|
app.use(
|
|
helmet({
|
|
contentSecurityPolicy: {
|
|
directives: {
|
|
defaultSrc: ["'self'"],
|
|
scriptSrc: ["'self'"],
|
|
// 'unsafe-inline' is required for React inline style attributes
|
|
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
|
|
fontSrc: ['https://fonts.gstatic.com'],
|
|
imgSrc: ["'self'", 'data:', 'blob:'],
|
|
connectSrc: ["'self'"],
|
|
frameAncestors: ["'none'"],
|
|
baseUri: ["'self'"],
|
|
formAction: ["'self'"],
|
|
objectSrc: ["'none'"],
|
|
upgradeInsecureRequests: null,
|
|
},
|
|
},
|
|
xFrameOptions: { action: 'deny' },
|
|
})
|
|
);
|
|
app.use(compression());
|
|
app.use(express.json({ limit: '64kb' }));
|
|
app.use(cookieParser());
|
|
app.use('/api', 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('/api', (_req, res) => res.status(404).json({ error: 'Introuvable' }));
|
|
|
|
// Uploaded images: validated at upload time, served here with hardened headers
|
|
app.use(
|
|
'/uploads',
|
|
express.static(config.uploadDir, {
|
|
index: false,
|
|
dotfiles: 'deny',
|
|
fallthrough: false,
|
|
setHeaders: (res) => {
|
|
res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'");
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
},
|
|
})
|
|
);
|
|
|
|
// Built React frontend (production). In dev, Vite serves it with a proxy instead.
|
|
if (fs.existsSync(config.staticDir)) {
|
|
const indexHtml = path.join(config.staticDir, 'index.html');
|
|
app.use(
|
|
express.static(config.staticDir, {
|
|
index: false,
|
|
setHeaders: (res, filePath) => {
|
|
// Vite emits hashed filenames under /assets — safe to cache forever
|
|
if (filePath.includes(`${path.sep}assets${path.sep}`)) {
|
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
}
|
|
},
|
|
})
|
|
);
|
|
// SPA fallback for client-side routes
|
|
app.get('*', (req, res, next) => {
|
|
if (req.path.startsWith('/api/') || req.path.startsWith('/uploads/')) return next();
|
|
res.sendFile(indexHtml);
|
|
});
|
|
}
|
|
|
|
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à.' });
|
|
if (err.statusCode === 404 || err.status === 404) {
|
|
return res.status(404).json({ error: 'Introuvable' });
|
|
}
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Erreur interne du serveur' });
|
|
});
|
|
|
|
try {
|
|
await waitForDb();
|
|
await applyInitialPasswords();
|
|
app.listen(config.port, () => {
|
|
console.log(`SBC app listening on :${config.port}`);
|
|
});
|
|
} catch (err) {
|
|
console.error('Startup failed:', err);
|
|
process.exit(1);
|
|
}
|