Serveur :
- POST /upload/init -> session { id, chunkSize (16 Mo), chunkCount }
- PUT /upload/:id/:index -> écrit le morceau à son offset (octet-stream brut,
bufferisé, parser dédié) ; renvoie { received, total }
- POST /upload/:id/complete -> vérifie tous les morceaux + la taille, renomme
le .mediadrop-part vers le chemin final ; 201 + métadonnées
- DELETE /upload/:id -> abandon, supprime le fichier partiel
- src/lib/upload-sessions.ts : registre en mémoire, purge après 30 min
d'inactivité, 100 sessions max
- src/lib/targets.ts : resolveTarget / resolvePlacement extraits et partagés
- writeChunkAt / createEmptyFile / finalizeFile / removeFile dans storage.ts
- limites, allowDirectories, jokers MIME et confinement dans la cible
s'appliquent aussi au chemin fragmenté
Page :
- bascule automatique en fragmenté au-delà de 32 Mo : 3 morceaux en parallèle,
3 réessais par morceau, progression et annulation agrégées
- makeReporter partagé (débit + ETA) ; job.cancel unifié (simple / fragmenté /
en file d'attente)
Tests : +tests/upload-chunked.test.ts (9), +writeChunkAt (storage) ; 73 tests.
Vérifié en live : 40 Mio -> 3 morceaux // -> octets exacts, DELETE nettoie.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
14 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 direct d'un fichier multipart/form-data, champ file |
POST /upload/init |
session | Démarre un upload fragmenté → { id, chunkSize, chunkCount } |
PUT /upload/:id/:index |
session | Envoie un morceau (corps brut application/octet-stream) |
POST /upload/:id/complete |
session | Assemble et finalise → 201 + métadonnées |
DELETE /upload/:id |
session | Abandonne (supprime le fichier partiel) |
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.
Gros fichiers — upload fragmenté
La page bascule automatiquement en upload fragmenté au-delà de 32 Mo :
morceaux de 16 Mo, 3 en parallèle, chaque morceau ré-essayé jusqu'à 3 fois.
Aucune configuration ; maxFileSizeMb reste la limite (10240 = 10 Go).
- Écriture en place à l'offset du morceau, mémoire constante ; assemblage sans
seconde passe. Fichier partiel en
<nom>.mediadrop-part, renommé à la finalisation. - Sessions d'upload en mémoire, purgées après 30 min d'inactivité (le
.partest alors supprimé) ; 100 uploads simultanés au maximum. - Chaque requête reste petite (≤ 16 Mo) : plus besoin de régler
client_max_body_sizeni les timeouts du reverse proxy pour les gros fichiers.
Le chemin direct (POST /upload, fichiers ≤ 32 Mo ou clients tiers) envoie
tout le corps d'un coup — le serveur n'impose alors aucun timeout, mais un
reverse proxy peut nécessiter un client_max_body_size adapté et
proxy_request_buffering off.
Prévoir 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 (dépôt direct)
upload-chunked.ts POST /upload/init, PUT /upload/:id/:index, .../complete, DELETE
lib/
storage.ts écriture disque, nom sûr, plafond, morceaux (writeChunkAt)
upload-sessions.ts registre en mémoire des uploads fragmentés (+ purge TTL)
targets.ts résolution cible / chemin relatif (partagé par les deux routes)
media-types.ts liste blanche des types + extensions + jokers
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)