mediaDrop/README.md
Nicolas fe8bd26bb7 Initial commit: mediaDrop — serveur de dépôt de fichiers médias
Serveur TypeScript (Fastify 5) permettant de déposer des fichiers médias
dans des dossiers nommés, via une page web de glisser-déposer.

- Authentification OpenID Connect gérée côté serveur (Authorization Code +
  PKCE, client confidentiel, openid-client) ; session via cookie chiffré
  (@fastify/secure-session).
- Configuration dans un fichier JSON (schéma fourni) ; l'environnement ne
  porte que le pointeur MEDIADROP_CONFIG.
- Cibles de dépôt nommées : liste { name, label, dir }, sélection par
  ?target=, première cible par défaut.
- Upload multipart en streaming : nom de fichier dérivé côté serveur (pas
  de path traversal), liste blanche de types MIME, plafond de taille.
- TypeScript strict, ESLint + Prettier sans warning, 46 tests Vitest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 23:06:53 +02:00

171 lines
8.7 KiB
Markdown

# 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
```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 <http://localhost:3000/>, cliquer **Se connecter** (redirection vers le
fournisseur), puis glisser des fichiers.
En production :
```bash
npm run build
npm start
```
## 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=<nom>` | session | Dépôt d'un fichier `multipart/form-data`, champ `file` |
`POST /upload` — le paramètre `?target=<nom>` 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)
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)
```