# 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 : `/auth/callback` (ex. `http://localhost:3000/auth/callback`) - post-logout redirect URI : `/` - scopes : `openid profile email` Noter l'`issuer`, le `client_id` et le `client_secret`. ## Installation ```bash 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 : ```bash 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 ```bash npm run dev ``` Ouvrir , cliquer **Se connecter** (redirection vers le fournisseur), puis glisser des fichiers. En production : ```bash 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`](compose.yaml). ```bash 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 ```bash 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. ## 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 de dépôt : `[{ "name", "label" }]` (sans chemin) | | `POST /upload?target=` | session | Dépôt d'un fichier `multipart/form-data`, champ `file` | `POST /upload` — le paramètre `?target=` choisit la cible ; absent, la **première** cible de `targets` est utilisée. Réponse `201` : ```json { "target": "photos", "storedName": "photo-1725900000000-1a2b3c4d.jpg", "originalName": "photo.jpg", "size": 20480, "mimeType": "image/jpeg" } ``` | Code | Cas | | ----- | ---------------------------------------------------------------- | | `400` | pas de fichier, mauvais champ, type non autorisé, cible inconnue | | `401` | session absente ou expirée | | `413` | fichier au-dessus de `maxFileSizeMb` | > 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. ## Configuration Un seul fichier JSON (voir [`config.example.json`](config.example.json), schéma dans [`config.schema.json`](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 }]` (≥ 1) ; la 1ʳᵉ est la cible par défaut | | `maxFileSizeMb` | `512` | Taille maximale par fichier (Mo) | | `logLevel` | `info` | `fatal` \| `error` \| `warn` \| `info` \| `debug` \| `trace` \| `silent` | | `allowedMimeTypes` | _(liste média)_ | Tableau de types MIME, ou `"all"` ; 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) 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 public/ index.html page de dépôt (login = lien vers /auth/login, sans build) ```