- Build en 3 étapes (deps prod / compilation TS / runtime), image node:22-bookworm-slim exécutée en non-root (utilisateur `node`). - Debian slim et non Alpine : @fastify/secure-session dépend de sodium-native, dont les prebuilds linux-x64/arm64 ciblent la glibc. - config.json et dossiers de dépôt montés en volumes, jamais dans l'image (.dockerignore exclut .env, config.json, uploads, node_modules, dist...). - HEALTHCHECK sur GET /health ; section Docker ajoutée au README. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|---|---|---|
| public | ||
| src | ||
| tests | ||
| .dockerignore | ||
| .env.example | ||
| .gitattributes | ||
| .gitignore | ||
| .prettierignore | ||
| .prettierrc.json | ||
| Claude.md | ||
| config.example.json | ||
| config.schema.json | ||
| Dockerfile | ||
| eslint.config.mjs | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.build.json | ||
| tsconfig.json | ||
| vitest.config.ts | ||
mediaDrop
Petit serveur pour déposer des fichiers médias (images / vidéos / audio) sur une
machine : une page web de glisser-déposer et un endpoint POST /upload.
Authentification OpenID Connect gérée côté serveur : le serveur effectue le flux
Authorization Code + PKCE, puis maintient une session (cookie chiffré). Les
fichiers sont écrits dans un dossier local. Pas de base de données, pas de listing.
Prérequis
- Node.js >= 22.9
- Un fournisseur OIDC (Keycloak, Authentik, Auth0, Okta…) joignable au démarrage.
Déclarer le client côté fournisseur
Créer un client confidentiel (« web application ») :
- type : confidentiel — avec client_secret
- PKCE : activé (
S256) - flux : Authorization Code
- redirect URI :
<publicUrl>/auth/callback(ex.http://localhost:3000/auth/callback) - post-logout redirect URI :
<publicUrl>/ - scopes :
openid profile email
Noter l'issuer, le client_id et le client_secret.
Installation
npm install
cp config.example.json config.json
Éditer config.json : au minimum publicUrl, session.key, oidc.issuer,
oidc.clientId, oidc.clientSecret. Générer la clé de session :
openssl rand -base64 32
Toute la configuration vit dans ce fichier JSON. Le serveur lit ./config.json
par défaut ; pour un autre emplacement, créer un .env (cp .env.example .env) et y
définir MEDIADROP_CONFIG=/chemin/vers/config.json. Le .env ne contient que ce
pointeur.
Lancer
npm run dev
Ouvrir http://localhost:3000/, cliquer Se connecter (redirection vers le fournisseur), puis glisser des fichiers.
En production :
npm run build
npm start
Docker
Image multi-stage (Debian slim ; Alpine exclu à cause du module natif
sodium-native). Le config.json et les dossiers de dépôt sont montés, jamais
inclus dans l'image.
docker build -t mediadrop .
docker run --rm -p 3000:3000 \
-v "$PWD/config.json:/app/config.json:ro" \
-v "$PWD/data/photos:/data/photos" \
mediadrop
Les dir des targets doivent pointer vers des chemins montés, inscriptibles
par l'uid 1000 (utilisateur node). Exemple compose.yaml :
services:
mediadrop:
build: .
ports:
- '3000:3000'
volumes:
- ./config.json:/app/config.json:ro
- ./data/photos:/data/photos
- ./data/videos:/data/videos
restart: unless-stopped
La découverte OIDC a lieu au démarrage : si l'issuer est injoignable, le
conteneur s'arrête (utiliser restart: unless-stopped). Le HEALTHCHECK
interroge GET /health sur le port 3000 — l'adapter si port diffère.
Endpoints
| Route | Auth | Rôle |
|---|---|---|
GET / |
non | Page de dépôt |
GET /health |
non | Sonde : { "status": "ok", "uptime": 12.3 } |
GET /auth/login |
non | Démarre le flux OIDC (redirige vers le fournisseur) |
GET /auth/callback |
non | Fin du flux : ouvre la session, redirige vers / |
POST /auth/logout |
session | Ferme la session (+ déconnexion RP si disponible) |
GET /auth/me |
non | { "authenticated": bool, "name"?: string } |
GET /targets |
session | Cibles de dépôt : [{ "name", "label" }] (sans chemin) |
POST /upload?target=<nom> |
session | Dépôt d'un fichier multipart/form-data, champ file |
POST /upload — le paramètre ?target=<nom> choisit la cible ; absent, la
première cible de targets est utilisée. Réponse 201 :
{
"target": "photos",
"storedName": "photo-1725900000000-1a2b3c4d.jpg",
"originalName": "photo.jpg",
"size": 20480,
"mimeType": "image/jpeg"
}
| Code | Cas |
|---|---|
400 |
pas de fichier, mauvais champ, type non autorisé, cible inconnue |
401 |
session absente ou expirée |
413 |
fichier au-dessus de maxFileSizeMb |
Le dépôt passe uniquement par la session établie via la page. Il n'y a pas d'accès par jeton Bearer pour les scripts.
Configuration
Un seul fichier JSON (voir config.example.json, schéma dans
config.schema.json). Chemin résolu depuis le dossier courant.
| Clé JSON | Défaut | Rôle |
|---|---|---|
port |
3000 |
Port d'écoute |
host |
0.0.0.0 |
Interface d'écoute |
publicUrl |
— | Obligatoire. URL externe de base (redirect_uri, post-logout) |
targets |
— | Obligatoire. Liste [{ name, label?, dir }] (≥ 1) ; la 1ʳᵉ est la cible par défaut |
maxFileSizeMb |
512 |
Taille maximale par fichier (Mo) |
logLevel |
info |
fatal | error | warn | info | debug | trace | silent |
allowedMimeTypes |
(liste média) | Tableau de types MIME, ou "all" ; absent => liste par défaut |
session.key |
— | Obligatoire. 32 octets base64 (openssl rand -base64 32) |
session.maxAgeHours |
12 |
Durée de vie absolue de la session |
oidc.issuer |
— | Obligatoire. Issuer OIDC |
oidc.clientId |
— | Obligatoire. client_id du client confidentiel |
oidc.clientSecret |
— | Obligatoire. Secret du client confidentiel |
oidc.scopes |
["openid","profile","email"] |
Scopes demandés (openid forcé) |
Seule variable d'environnement : MEDIADROP_CONFIG (chemin du fichier, défaut ./config.json).
Scripts
| Commande | Effet |
|---|---|
npm run dev |
Serveur en mode watch |
npm run build |
Compilation TypeScript vers dist/ |
npm start |
Exécute dist/index.js |
npm run typecheck |
tsc --noEmit |
npm run lint |
ESLint |
npm run format |
Prettier (écriture) |
npm test |
Suite Vitest |
Sécurité — limites connues
- Le serveur ne vérifie pas les rôles/scopes : tout compte valide du fournisseur
peut se connecter. Restreindre l'accès au niveau du client OIDC, ou faire évoluer
src/oidc.ts(handleCallback) pour exiger un scope / une claim. - Session = cookie chiffré
@fastify/secure-session(httpOnly,SameSite=Lax,SecuresipublicUrlest en https). Durée de vie absolue ; pas de rafraîchissement par refresh token — reconnexion à l'expiration. - Le type MIME est celui annoncé par le client, non vérifié via les octets de
signature. Durcissement possible avec une bibliothèque de détection (
file-type). - Les limites (
maxFileSizeMb,allowedMimeTypes) sont globales : pas encore de réglage par cible. Lenamede cible fourni par le client sert uniquement à retrouver une entrée detargets(jamais concaténé à un chemin). - Pas de limitation de débit. Placer le service derrière un reverse proxy HTTPS en
production et régler
publicUrlsur l'URL publique. .env,config.jsonetuploads/sont ignorés par Git.
Structure
config.example.json modèle de configuration (à copier en config.json)
config.schema.json schéma JSON (validation / autocomplétion éditeur)
Dockerfile image multi-stage (deps / build / runtime non-root)
src/
index.ts entrée : config -> app -> écoute + arrêt propre
app.ts construction de l'instance Fastify (exportée pour les tests)
config.ts lecture du fichier JSON + validation -> AppConfig
oidc.ts découverte de l'issuer + flux Authorization Code (openid-client)
session.ts type de session + hook preHandler requireSession
errors.ts classes d'erreur (AppError + dérivées)
error-handler.ts gestionnaire d'erreurs global
routes/
auth.ts GET /auth/login, /auth/callback, /auth/me ; POST /auth/logout
health.ts GET /health
upload.ts GET /targets ; POST /upload?target=... (session requise)
lib/
storage.ts écriture disque dans la cible, nom de fichier sûr, plafond de taille
media-types.ts liste blanche des types + extensions
public/
index.html page de dépôt (login = lien vers /auth/login, sans build)