Chaque requête authentifiée repousse l'expiration (au plus une fois par minute), côté jeton et côté cookie. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> |
||
|---|---|---|
| .forgejo/workflows | ||
| public | ||
| src | ||
| tests | ||
| .dockerignore | ||
| .env.example | ||
| .gitattributes | ||
| .gitignore | ||
| .prettierignore | ||
| .prettierrc.json | ||
| Claude.md | ||
| compose.yaml | ||
| 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 de connexion, une page 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é). GET / sert l'une ou l'autre page selon la session — jamais les deux à la
fois. 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/ : page de connexion, cliquer Se connecter
(redirection vers le fournisseur) ; de retour sur /, la page de dépôt s'affiche —
il ne reste plus qu'à 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 connexion (login.html), ou de dépôt (upload.html) si session valide |
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 /config |
non | Réglages client : { "chunkPoolSize": number } |
GET /targets |
session | Cibles : [{ "name", "label", "allowDirectories", "allowedMimeTypes" }] |
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) |
GET / décide côté serveur, à chaque requête : sans session valide, login.html
(rien d'autre que le bouton Se connecter, aucune zone de dépôt dans le HTML) ;
avec une session valide, upload.html avec le nom d'utilisateur injecté (échappé) et
Se déconnecter. Une session qui expire pendant l'utilisation de la page (401 sur
/targets ou /upload*) renvoie le navigateur sur /?login=expired.
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, chacun ré-essayé jusqu'à 3 fois. maxFileSizeMb reste la
limite (10240 = 10 Go).
- Pool de connexions partagé (
chunkPoolSize, défaut30) : budget global de morceaux en vol, réparti équitablement entre les fichiers fragmentés en cours. 1 fichier ⇒ il utilise tout le pool ;Nfichiers ⇒⌈30 / N⌉morceaux chacun ; quand un fichier se termine, sa part est redistribuée. Le navigateur lit la valeur viaGET /config. - HTTP/2 recommandé : en HTTP/1.1 le navigateur plafonne à ~6 connexions par
origine, donc un
chunkPoolSizeélevé n'a d'effet réel qu'en HTTP/2 (activer « HTTP/2 Support » sur l'hôte du reverse proxy). - É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?, allowedMimeTypes? }] (≥ 1) ; la 1ʳᵉ est la cible par défaut |
targets[].allowDirectories |
false |
Autoriser le dépôt de dossiers (arborescence recréée sous dir) |
targets[].allowedMimeTypes |
(global) | Politique MIME propre à cette cible ; absent => hérite du allowedMimeTypes global. Mêmes formes |
maxFileSizeMb |
512 |
Taille maximale par fichier, en Mo (10240 = 10 Go) |
logLevel |
info |
fatal | error | warn | info | debug | trace | silent |
chunkPoolSize |
30 |
Morceaux en vol simultanés (upload fragmenté), répartis entre fichiers actifs ; 1–256. Utile en HTTP/2 |
allowedMimeTypes |
(liste média) | Politique MIME par défaut des cibles. "all", ou tableau de types exacts et/ou jokers ("video/*", "*") |
webhooks |
[] |
Liste de webhooks POST notifiés à chaque upload finalisé et/ou chaque échec (voir §Notifications) |
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).
Notifications (webhooks)
webhooks est une liste ; chaque entrée reçoit un POST JSON à chaque upload
finalisé (upload — dépôt direct comme fragmenté) et à chaque échec d'upload
(error — toute requête /upload* terminée en 4xx/5xx).
"webhooks": [
{
"url": "https://exemple.fr/hooks/mediadrop",
"events": ["upload", "error"],
"headers": { "Authorization": "Bearer …" },
"secret": "un-secret-partagé",
"timeoutMs": 5000
}
]
| Champ | Défaut | Rôle |
|---|---|---|
url |
— | Obligatoire. Endpoint HTTP(S) appelé en POST |
events |
["upload","error"] |
Sous-ensemble d'événements souscrits |
headers |
{} |
En-têtes ajoutés à la requête (auth du récepteur…) |
secret |
— | Active la signature X-MediaDrop-Signature (voir plus bas) |
timeoutMs |
5000 |
Délai d'attente par tentative (1–30000) |
Requête émise — content-type: application/json, User-Agent: mediaDrop-webhook,
X-MediaDrop-Event: upload|error, plus les headers configurés, plus
X-MediaDrop-Signature si secret. Corps upload :
{
"event": "upload",
"timestamp": "2026-09-10T18:30:00.000Z",
"target": "videos",
"storedName": "vacances/plage/DSC_001.mp4",
"originalName": "DSC_001.mp4",
"size": 20480,
"mimeType": "video/mp4",
"user": "auth0|abc",
"via": "chunked",
"text": "✅ Upload terminé : vacances/plage/DSC_001.mp4 (20 Ko) — cible « videos »",
"content": "✅ Upload terminé : vacances/plage/DSC_001.mp4 (20 Ko) — cible « videos »"
}
Corps error : { event, timestamp, message, statusCode, method, path, target?, user?, text, content }.
text et content portent le même résumé lisible (text pour Slack, content pour
Discord) — l'endpoint peut aussi être un webhook entrant Slack/Discord/ntfy tel quel.
Livraison : en parallèle, sans bloquer la réponse d'upload ; une nouvelle tentative
sur erreur réseau / 429 / 5xx ; les échecs définitifs sont seulement journalisés
(warn). L'arrêt du serveur attend les envois en cours.
Vérifier la signature (récepteur) — HMAC-SHA256 du corps brut avec le secret :
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const ok =
sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
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). allowedMimeTypesse règle par cible (targets[].allowedMimeTypes), avec repli sur la valeur globale.maxFileSizeMbreste global. 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/
pages.ts GET / (login.html ou upload.html selon la session, nom échappé)
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)
notifications.ts envoi des webhooks (upload finalisé / échec), signature HMAC
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/
login.html page de connexion (bouton -> /auth/login, sans build)
upload.html page de dépôt, servie uniquement avec une session valide