isAllowedMedia gère désormais "video/*", "*/*" et "*" en plus des types
exacts (comparaison insensible à la casse). parseConfig valide la forme des
entrées et lève ConfigError sur un joker malformé ("video/**", "vi*deo/mp4",
"video/") au lieu de laisser passer une liste qui ne matche rien.
Schéma + doc mis à jour. 62 tests.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
12 KiB
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), exécutée en non-root. Le config.json et les dossiers de
dépôt sont montés, jamais inclus dans l'image.
Compose (recommandé)
Voir compose.yaml.
cp config.example.json config.json # puis éditer publicUrl, session.key, oidc.*
mkdir -p ./data && sudo chown -R 1000:1000 ./data
docker compose up -d --build
Dans config.json, faire pointer les dir des targets sous /data
(ex. "dir": "/data/photos"). Le conteneur tourne en uid 1000 (utilisateur
node) : ./data doit lui être inscriptible.
Sans Compose
docker build -t mediadrop .
docker run --rm -p 3000:3000 \
-v "$PWD/config.json:/app/config.json:ro" \
-v "$PWD/data:/data" \
mediadrop
La découverte OIDC a lieu au démarrage : si l'issuer est injoignable, le
conteneur s'arrête (d'où restart: unless-stopped). Le HEALTHCHECK interroge
GET /health sur le port 3000 — garder port: 3000 dans config.json et
remapper côté hôte si besoin.
Intégration continue
.forgejo/workflows/publish-docker.yml
teste puis publie l'image sur le registre Forgejo git.dev.kyuno.fr/kyuno/mediadrop :
- push sur
main→latest+<sha court> - push d'un tag
vX.Y.Z→X.Y.Z,X.Y,latest
Prérequis (détaillés en tête du fichier) : Actions activé pour le dépôt, un
runner ubuntu-latest avec accès à un démon Docker (socket monté), et un jeton
pouvant écrire les paquets de l'organisation.
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 : [{ "name", "label", "allowDirectories" }] (sans chemin) |
POST /upload?target=<nom>[&path=<p>] |
session | Dépôt d'un fichier multipart/form-data, champ file |
POST /upload — ?target=<nom> choisit la cible (absent → première cible).
?path=<chemin relatif> (URL-encodé) dépose dans un sous-dossier recréé sous
dir, en conservant le nom d'origine ; le fichier est écrasé s'il existe.
Refusé (400) si la cible n'a pas allowDirectories: true. Chaque segment du
chemin est assaini (pas de .., pas de séparateur) : impossible de sortir de la
cible. Réponse 201 :
{
"target": "videos",
"storedName": "vacances/plage/DSC_001.mp4",
"originalName": "DSC_001.mp4",
"size": 20480,
"mimeType": "video/mp4"
}
| Code | Cas |
|---|---|
400 |
pas de fichier, mauvais champ, type non autorisé, cible inconnue, path invalide ou non autorisé |
401 |
session absente ou expirée |
413 |
fichier au-dessus de maxFileSizeMb |
La page gère aussi le glisser-déposer d'un dossier entier et un bouton « parcourir un dossier » (visibles si la cible sélectionnée autorise les dossiers).
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.
Très gros fichiers
maxFileSizeMb accepte de grandes valeurs (10240 = 10 Go). Les fichiers sont
écrits en streaming (mémoire constante) et le serveur n'impose aucun timeout
de requête. Derrière un reverse proxy, penser à :
- nginx :
client_max_body_size 11g;,proxy_request_buffering off;,proxy_read_timeout/proxy_send_timeoutélevés - Caddy / Traefik : limites de taille de corps et timeouts de lecture
Prévoir aussi l'espace disque sur le volume de la cible.
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, allowDirectories? }] (≥ 1) ; la 1ʳᵉ est la cible par défaut |
targets[].allowDirectories |
false |
Autoriser le dépôt de dossiers (arborescence recréée sous dir) |
maxFileSizeMb |
512 |
Taille maximale par fichier, en Mo (10240 = 10 Go) |
logLevel |
info |
fatal | error | warn | info | debug | trace | silent |
allowedMimeTypes |
(liste média) | "all", ou tableau de types exacts et/ou jokers de préfixe ("video/*", "*") ; 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)
compose.yaml déploiement Docker Compose (config + /data montés)
.forgejo/workflows/ CI : test puis publication de l'image (registre Forgejo)
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
paths.ts assainissement des chemins relatifs (dépôt de dossier)
public/
index.html page de dépôt (login = lien vers /auth/login, sans build)