Supprime nginx : Express sert le frontend, ports peu utilisés

- 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
This commit is contained in:
Claude committed 2026-07-10 19:52:52 +00:00
1 parent 96dca2a802
commit 499b753c22
15 files changed
+203 -145

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
**/node_modules
**/dist
.env
.git
*.log
+8 -2
View File
@@ -22,5 +22,11 @@ MEMBER_INITIAL_PASSWORD=ChangeMe-Membre-2026!
# Set to "true" when serving over HTTPS (adds Secure flag on cookies) # Set to "true" when serving over HTTPS (adds Secure flag on cookies)
COOKIE_SECURE=false COOKIE_SECURE=false
# Public port of the web frontend # Set to "true" only when running behind a reverse proxy (TLS termination)
WEB_PORT=8080 TRUST_PROXY=false
# Uncommon ports to avoid collisions with other services
# Application (public web port)
APP_PORT=8321
# PostgreSQL, bound to 127.0.0.1 only (local admin access)
DB_PORT=56432
+30
View File
@@ -0,0 +1,30 @@
# Stage 1 — build the React frontend
FROM node:22-alpine AS webbuild
WORKDIR /build
COPY web/package.json web/package-lock.json* ./
RUN npm ci 2>/dev/null || npm install
COPY web/index.html web/vite.config.js ./
COPY web/public ./public
COPY web/src ./src
RUN npm run build
# Stage 2 — install API production dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY server/package.json server/package-lock.json* ./
RUN npm ci --omit=dev 2>/dev/null || npm install --omit=dev
# Stage 3 — final image: Express serves the API, the frontend and the uploads
FROM node:22-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY server/package.json ./
COPY server/src ./src
COPY --from=webbuild /build/dist ./public
# 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
USER node
EXPOSE 3000
CMD ["node", "src/index.js"]
+25 -19
View File
@@ -6,20 +6,24 @@ back-office d'administration.
## Architecture ## Architecture
Trois conteneurs orchestrés par Docker Compose : Deux conteneurs orchestrés par Docker Compose, sur des ports peu utilisés
(configurables dans `.env`) :
| Service | Rôle | Exposition | | Service | Rôle | Port hôte |
|---------|------|------------| |---------|------|-----------|
| `db` | PostgreSQL 16 (schéma + données de démo au premier démarrage) | réseau interne uniquement, aucun port publié | | `app` | Node.js 22 / Express : API REST **et** frontend React compilé **et** images `/uploads` | `8321` (`APP_PORT`) |
| `api` | API REST Node.js 22 / Express (auth, membres, rencontres, inscriptions, catégories, contenu, uploads) | réseau interne uniquement | | `db` | PostgreSQL 16 (schéma + données de démo au premier démarrage) | `127.0.0.1:56432` (`DB_PORT`) — loopback uniquement, pour l'administration locale |
| `web` | nginx (non-root) : frontend React compilé, reverse-proxy `/api`, service des images `/uploads` | port `8080` |
``` ```
Navigateur ──> web (nginx :8080) ──> api (Express :3000) ──> db (PostgreSQL :5432) Navigateur ──> app (Express :8321) ──> db (PostgreSQL, 127.0.0.1:56432)
│ /uploads (volume partagé, lecture seule) ├─ /api/… API REST
└─ fichiers statiques React ├─ /uploads/… images (volume persistant)
└─ /… frontend React (fallback SPA)
``` ```
Le mapping PostgreSQL est lié à `127.0.0.1` : la base reste inaccessible depuis
le réseau. Supprimez la section `ports:` du service `db` pour la fermer totalement.
- **Frontend** : React 18 + Vite + React Router — reproduction fidèle de la maquette - **Frontend** : React 18 + Vite + React Router — reproduction fidèle de la maquette
(`SLUC Business Club.dc.html`). (`SLUC Business Club.dc.html`).
- **Backend** : Express, `pg` (requêtes paramétrées), `zod` (validation), `bcryptjs` - **Backend** : Express, `pg` (requêtes paramétrées), `zod` (validation), `bcryptjs`
@@ -39,7 +43,7 @@ cp .env.example .env
docker compose up -d --build docker compose up -d --build
``` ```
L'application est disponible sur <http://localhost:8080> (port configurable via `WEB_PORT`). L'application est disponible sur <http://localhost:8321> (port configurable via `APP_PORT`).
## Comptes ## Comptes
@@ -98,12 +102,12 @@ formulaire « Mot de passe », endpoint `POST /api/auth/change-password`).
(20 / h). (20 / h).
- **Uploads** : taille ≤ 2 Mo, type vérifié par octets magiques (jamais le MIME client), - **Uploads** : taille ≤ 2 Mo, type vérifié par octets magiques (jamais le MIME client),
nom de fichier aléatoire généré côté serveur (aucune traversée de chemin possible), nom de fichier aléatoire généré côté serveur (aucune traversée de chemin possible),
servis par nginx avec `X-Content-Type-Options: nosniff` et types MIME forcés. servis avec `X-Content-Type-Options: nosniff` et une CSP `default-src 'none'`.
- **En-têtes** : CSP stricte, `X-Frame-Options: DENY`, `Referrer-Policy`, - **En-têtes** : CSP stricte, `X-Frame-Options: DENY` et toute la panoplie `helmet`
`Permissions-Policy`, `helmet` côté API, `server_tokens off`. sur l'ensemble des réponses (API, frontend, uploads).
- **Conteneurs** : API en utilisateur non-root avec système de fichiers en lecture - **Conteneurs** : application en utilisateur non-root avec système de fichiers en
seule (`read_only` + tmpfs), nginx non privilégié, `no-new-privileges`, PostgreSQL lecture seule (`read_only` + tmpfs), `no-new-privileges`, PostgreSQL publié
sans port publié sur un réseau interne (`internal: true`). uniquement sur `127.0.0.1` (inaccessible depuis le réseau).
- **Base de données** : l'API se connecte avec un rôle dédié `sbc_app` limité au DML - **Base de données** : l'API se connecte avec un rôle dédié `sbc_app` limité au DML
(pas de DDL, pas de superuser). (pas de DDL, pas de superuser).
- **Secrets** : uniquement via `.env` (ignoré par git) ; `docker compose` refuse de - **Secrets** : uniquement via `.env` (ignoré par git) ; `docker compose` refuse de
@@ -113,7 +117,8 @@ formulaire « Mot de passe », endpoint `POST /api/auth/change-password`).
### Pour la production ### Pour la production
- Placez l'application derrière HTTPS (reverse-proxy TLS) et passez `COOKIE_SECURE=true`. - Placez l'application derrière HTTPS (reverse-proxy TLS) et passez `COOKIE_SECURE=true`
et `TRUST_PROXY=true`.
- Changez immédiatement les mots de passe initiaux. - Changez immédiatement les mots de passe initiaux.
- Sauvegardez les volumes `db_data` (base) et `uploads` (images). - Sauvegardez les volumes `db_data` (base) et `uploads` (images).
@@ -130,8 +135,9 @@ cd web && npm install && npm run dev # proxy /api → localhost:3000
``` ```
├── docker-compose.yml ├── docker-compose.yml
├── Dockerfile # multi-étages : build React → dépendances API → image finale
├── .env.example ├── .env.example
├── db/init/ # 01 rôle applicatif · 02 schéma · 03 données de démo ├── db/init/ # 01 rôle applicatif · 02 schéma · 03 données de démo
├── server/ # API Express (src/routes, src/middleware, uploads) ├── server/ # API Express (src/routes, src/middleware, uploads, statique)
└── web/ # React + Vite, nginx.conf, Dockerfile multi-étages └── web/ # sources React + Vite (compilées dans l'image)
``` ```
+9 -28
View File
@@ -12,17 +12,18 @@ services:
volumes: volumes:
- db_data:/var/lib/postgresql/data - db_data:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d:ro - ./db/init:/docker-entrypoint-initdb.d:ro
networks: ports:
- backend # Loopback only: reachable from the host machine (psql, backups),
# never from the network. Remove this mapping to close it entirely.
- "127.0.0.1:${DB_PORT:-56432}:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d sbc"] test: ["CMD-SHELL", "pg_isready -U postgres -d sbc"]
interval: 5s interval: 5s
timeout: 3s timeout: 3s
retries: 12 retries: 12
# No published ports: the database is reachable only from the backend network.
api: app:
build: ./server build: .
restart: unless-stopped restart: unless-stopped
environment: environment:
NODE_ENV: production NODE_ENV: production
@@ -32,15 +33,15 @@ services:
ADMIN_INITIAL_PASSWORD: ${ADMIN_INITIAL_PASSWORD:-} ADMIN_INITIAL_PASSWORD: ${ADMIN_INITIAL_PASSWORD:-}
MEMBER_INITIAL_PASSWORD: ${MEMBER_INITIAL_PASSWORD:-} MEMBER_INITIAL_PASSWORD: ${MEMBER_INITIAL_PASSWORD:-}
COOKIE_SECURE: ${COOKIE_SECURE:-false} COOKIE_SECURE: ${COOKIE_SECURE:-false}
TRUST_PROXY: ${TRUST_PROXY:-false}
UPLOAD_DIR: /data/uploads UPLOAD_DIR: /data/uploads
volumes: volumes:
- uploads:/data/uploads - uploads:/data/uploads
ports:
- "${APP_PORT:-8321}:3000"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
networks:
- backend
- frontend
read_only: true read_only: true
tmpfs: tmpfs:
- /tmp - /tmp
@@ -52,26 +53,6 @@ services:
timeout: 5s timeout: 5s
retries: 6 retries: 6
web:
build: ./web
restart: unless-stopped
ports:
- "${WEB_PORT:-8080}:8080"
volumes:
- uploads:/var/www/uploads:ro
depends_on:
- api
networks:
- frontend
security_opt:
- no-new-privileges:true
networks:
backend:
# internal: the db network never routes to the outside world
internal: true
frontend:
volumes: volumes:
db_data: db_data:
uploads: uploads:
-2
View File
@@ -1,2 +0,0 @@
node_modules
*.log
-18
View File
@@ -1,18 +0,0 @@
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"]
+49
View File
@@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"express": "^4.19.2", "express": "^4.19.2",
"express-rate-limit": "^7.4.0", "express-rate-limit": "^7.4.0",
@@ -135,6 +136,45 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/concat-stream": { "node_modules/concat-stream": {
"version": "1.6.2", "version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
@@ -805,6 +845,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+1
View File
@@ -9,6 +9,7 @@
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"express": "^4.19.2", "express": "^4.19.2",
"express-rate-limit": "^7.4.0", "express-rate-limit": "^7.4.0",
+3
View File
@@ -12,7 +12,10 @@ export const config = {
databaseUrl: required('DATABASE_URL'), databaseUrl: required('DATABASE_URL'),
jwtSecret: required('JWT_SECRET'), jwtSecret: required('JWT_SECRET'),
cookieSecure: process.env.COOKIE_SECURE === 'true', cookieSecure: process.env.COOKIE_SECURE === 'true',
// set to "true" only when running behind a reverse proxy (TLS termination)
trustProxy: process.env.TRUST_PROXY === 'true',
uploadDir: process.env.UPLOAD_DIR || '/data/uploads', uploadDir: process.env.UPLOAD_DIR || '/data/uploads',
staticDir: process.env.STATIC_DIR || new URL('../public', import.meta.url).pathname,
adminInitialPassword: process.env.ADMIN_INITIAL_PASSWORD || '', adminInitialPassword: process.env.ADMIN_INITIAL_PASSWORD || '',
memberInitialPassword: process.env.MEMBER_INITIAL_PASSWORD || '', memberInitialPassword: process.env.MEMBER_INITIAL_PASSWORD || '',
sessionTtlSeconds: 12 * 60 * 60, sessionTtlSeconds: 12 * 60 * 60,
+69 -4
View File
@@ -1,5 +1,8 @@
import path from 'node:path';
import fs from 'node:fs';
import express from 'express'; import express from 'express';
import helmet from 'helmet'; import helmet from 'helmet';
import compression from 'compression';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import { config } from './config.js'; import { config } from './config.js';
import { waitForDb } from './db.js'; import { waitForDb } from './db.js';
@@ -14,11 +17,33 @@ import { adminRouter } from './routes/admin.js';
const app = express(); const app = express();
app.disable('x-powered-by'); app.disable('x-powered-by');
app.set('trust proxy', 1); // behind nginx if (config.trustProxy) app.set('trust proxy', 1);
app.use(helmet());
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(express.json({ limit: '64kb' }));
app.use(cookieParser()); app.use(cookieParser());
app.use(globalLimiter); app.use('/api', globalLimiter);
app.use(csrfOriginCheck); app.use(csrfOriginCheck);
app.use(attachUser); app.use(attachUser);
@@ -27,6 +52,43 @@ app.use('/api/public', publicRouter);
app.use('/api/auth', authRouter); app.use('/api/auth', authRouter);
app.use('/api/member', memberRouter); app.use('/api/member', memberRouter);
app.use('/api/admin', adminRouter); 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' })); app.use((_req, res) => res.status(404).json({ error: 'Introuvable' }));
@@ -37,6 +99,9 @@ app.use((err, _req, res, _next) => {
} }
if (err.code === '23503') return res.status(400).json({ error: 'Référence 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.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); console.error(err);
res.status(500).json({ error: 'Erreur interne du serveur' }); res.status(500).json({ error: 'Erreur interne du serveur' });
}); });
@@ -45,7 +110,7 @@ try {
await waitForDb(); await waitForDb();
await applyInitialPasswords(); await applyInitialPasswords();
app.listen(config.port, () => { app.listen(config.port, () => {
console.log(`SBC API listening on :${config.port}`); console.log(`SBC app listening on :${config.port}`);
}); });
} catch (err) { } catch (err) {
console.error('Startup failed:', err); console.error('Startup failed:', err);
+4 -1
View File
@@ -1,11 +1,14 @@
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import { config } from '../config.js';
// CSRF defense-in-depth: session cookie is SameSite=Strict, and every // CSRF defense-in-depth: session cookie is SameSite=Strict, and every
// state-changing request must additionally come from our own origin. // state-changing request must additionally come from our own origin.
// X-Forwarded-Host is only honoured when explicitly running behind a proxy.
export function csrfOriginCheck(req, res, next) { export function csrfOriginCheck(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next(); if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const origin = req.headers.origin || ''; const origin = req.headers.origin || '';
const host = req.headers['x-forwarded-host'] || req.headers.host || ''; const host =
(config.trustProxy && req.headers['x-forwarded-host']) || req.headers.host || '';
if (origin) { if (origin) {
let originHost; let originHost;
try { try {
-3
View File
@@ -1,3 +0,0 @@
node_modules
dist
*.log
-13
View File
@@ -1,13 +0,0 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci 2>/dev/null || npm install
COPY index.html vite.config.js ./
COPY public ./public
COPY src ./src
RUN npm run build
FROM nginxinc/nginx-unprivileged:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 8080
-55
View File
@@ -1,55 +0,0 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
server_tokens off;
client_max_body_size 3m;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
# API proxied to the backend container
location /api/ {
proxy_pass http://api:3000;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Uploaded images: served directly from the shared volume, never executed
location /uploads/ {
alias /var/www/uploads/;
types { image/jpeg jpg; image/png png; image/webp webp; }
default_type application/octet-stream;
# user-uploaded content: never allow it to script or be framed
add_header Content-Security-Policy "default-src 'none'; frame-ancestors 'none'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Disposition "inline" always;
add_header Cache-Control "public, max-age=86400" always;
}
# Static assets (immutable hashed filenames from Vite)
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header X-Content-Type-Options "nosniff" always;
try_files $uri =404;
}
# SPA fallback
location / {
try_files $uri /index.html;
}
}