Serveur de dépôt de fichiers médias : page de glisser-déposer, auth OpenID Connect côté serveur, cibles de dépôt nommées. TypeScript / Fastify.
Find a file
Nicolas 4f2bed5932
Some checks failed
Publier l'image Docker / test (push) Failing after 17s
Publier l'image Docker / publish (push) Has been skipped
fix tests
2026-09-19 15:18:07 +02:00
.forgejo/workflows CI : login registre via un secret REGISTRY_TOKEN 2026-09-10 18:27:43 +02:00
public Page de connexion distincte avant la page de dépôt 2026-09-13 20:49:21 +02:00
src change config 2026-09-19 14:59:16 +02:00
tests fix tests 2026-09-19 15:18:07 +02:00
.dockerignore Ajout d'un compose.yaml 2026-09-09 23:25:11 +02:00
.env.example Initial commit: mediaDrop — serveur de dépôt de fichiers médias 2026-09-09 23:06:53 +02:00
.gitattributes Initial commit: mediaDrop — serveur de dépôt de fichiers médias 2026-09-09 23:06:53 +02:00
.gitignore Ajout d'un compose.yaml 2026-09-09 23:25:11 +02:00
.prettierignore Initial commit: mediaDrop — serveur de dépôt de fichiers médias 2026-09-09 23:06:53 +02:00
.prettierrc.json Initial commit: mediaDrop — serveur de dépôt de fichiers médias 2026-09-09 23:06:53 +02:00
Claude.md Initial commit: mediaDrop — serveur de dépôt de fichiers médias 2026-09-09 23:06:53 +02:00
compose.yaml Ajout d'un compose.yaml 2026-09-09 23:25:11 +02:00
config.example.json change config 2026-09-19 14:59:16 +02:00
config.schema.json change config 2026-09-19 14:59:16 +02:00
Dockerfile Ajout d'un Dockerfile multi-stage + .dockerignore 2026-09-09 23:12:21 +02:00
eslint.config.mjs Initial commit: mediaDrop — serveur de dépôt de fichiers médias 2026-09-09 23:06:53 +02:00
package-lock.json Initial commit: mediaDrop — serveur de dépôt de fichiers médias 2026-09-09 23:06:53 +02:00
package.json Initial commit: mediaDrop — serveur de dépôt de fichiers médias 2026-09-09 23:06:53 +02:00
README.md Page de connexion distincte avant la page de dépôt 2026-09-13 20:49:21 +02:00
tsconfig.build.json Initial commit: mediaDrop — serveur de dépôt de fichiers médias 2026-09-09 23:06:53 +02:00
tsconfig.json Initial commit: mediaDrop — serveur de dépôt de fichiers médias 2026-09-09 23:06:53 +02:00
vitest.config.ts Initial commit: mediaDrop — serveur de dépôt de fichiers médias 2026-09-09 23:06:53 +02:00

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éfaut 30) : budget global de morceaux en vol, réparti équitablement entre les fichiers fragmentés en cours. 1 fichier ⇒ il utilise tout le pool ; N fichiers ⇒ ⌈30 / N⌉ morceaux chacun ; quand un fichier se termine, sa part est redistribuée. Le navigateur lit la valeur via GET /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 .part est alors supprimé) ; 100 uploads simultanés au maximum.
  • Chaque requête reste petite (≤ 16 Mo) : plus besoin de régler client_max_body_size ni 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, Secure si publicUrl est 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).
  • allowedMimeTypes se règle par cible (targets[].allowedMimeTypes), avec repli sur la valeur globale. maxFileSizeMb reste global. Le name de cible fourni par le client sert uniquement à retrouver une entrée de targets (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 publicUrl sur l'URL publique.
  • .env, config.json et uploads/ 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