- Build en 3 étapes (deps prod / compilation TS / runtime), image node:22-bookworm-slim exécutée en non-root (utilisateur `node`). - Debian slim et non Alpine : @fastify/secure-session dépend de sodium-native, dont les prebuilds linux-x64/arm64 ciblent la glibc. - config.json et dossiers de dépôt montés en volumes, jamais dans l'image (.dockerignore exclut .env, config.json, uploads, node_modules, dist...). - HEALTHCHECK sur GET /health ; section Docker ajoutée au README. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
207 lines
9.7 KiB
Markdown
207 lines
9.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
|
|
```
|
|
|
|
## Docker
|
|
|
|
Image multi-stage (Debian slim ; Alpine exclu à cause du module natif
|
|
`sodium-native`). Le `config.json` et les dossiers de dépôt sont montés, jamais
|
|
inclus dans l'image.
|
|
|
|
```bash
|
|
docker build -t mediadrop .
|
|
|
|
docker run --rm -p 3000:3000 \
|
|
-v "$PWD/config.json:/app/config.json:ro" \
|
|
-v "$PWD/data/photos:/data/photos" \
|
|
mediadrop
|
|
```
|
|
|
|
Les `dir` des `targets` doivent pointer vers des chemins montés, inscriptibles
|
|
par l'uid `1000` (utilisateur `node`). Exemple `compose.yaml` :
|
|
|
|
```yaml
|
|
services:
|
|
mediadrop:
|
|
build: .
|
|
ports:
|
|
- '3000:3000'
|
|
volumes:
|
|
- ./config.json:/app/config.json:ro
|
|
- ./data/photos:/data/photos
|
|
- ./data/videos:/data/videos
|
|
restart: unless-stopped
|
|
```
|
|
|
|
La découverte OIDC a lieu au démarrage : si l'issuer est injoignable, le
|
|
conteneur s'arrête (utiliser `restart: unless-stopped`). Le `HEALTHCHECK`
|
|
interroge `GET /health` sur le port `3000` — l'adapter si `port` diffère.
|
|
|
|
## 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)
|
|
Dockerfile image multi-stage (deps / build / runtime non-root)
|
|
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)
|
|
```
|