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 0f5bdb7080
Some checks failed
Publier l'image Docker / test (push) Successful in 18s
Publier l'image Docker / publish (push) Failing after 11s
allowedMimeTypes : jokers de préfixe
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>
2026-09-10 09:18:52 +02:00
.forgejo/workflows CI : checkout Git direct dans le job publish 2026-09-10 08:58:10 +02:00
public Dépôt de dossiers + très gros fichiers 2026-09-10 00:19:20 +02:00
src allowedMimeTypes : jokers de préfixe 2026-09-10 09:18:52 +02:00
tests allowedMimeTypes : jokers de préfixe 2026-09-10 09:18:52 +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 Dépôt de dossiers + très gros fichiers 2026-09-10 00:19:20 +02:00
config.schema.json allowedMimeTypes : jokers de préfixe 2026-09-10 09:18:52 +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 allowedMimeTypes : jokers de préfixe 2026-09-10 09:18:52 +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 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, 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).
  • Les limites (maxFileSizeMb, allowedMimeTypes) sont globales : pas encore de réglage par cible. 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/
    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)