mediaDrop/README.md
Nicolas 615d0fd45a
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 13s
Types MIME configurables par cible d'upload
`targets[].allowedMimeTypes` : chaque cible peut définir sa propre
politique (mêmes formes : "all", types exacts, jokers "image/*"…).
Absente => la cible hérite du `allowedMimeTypes` global, qui devient
la valeur par défaut. Résolu au parsing : chaque `DropTarget` porte
toujours une politique.

- `POST /upload` et `POST /upload/init` valident contre
  `target.allowedMimeTypes` (au lieu de la valeur globale).
- `GET /targets` renvoie `allowedMimeTypes` ; la page en déduit
  l'attribut `accept` du sélecteur de fichiers (le serveur reste
  l'autorité — le glisser-déposer n'est pas filtré).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 22:22:30 +02:00

333 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`), 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.
### Intégration continue
[`.forgejo/workflows/publish-docker.yml`](.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 /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) |
`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` :
```json
{
"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`](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, 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).
```json
"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` :
```json
{
"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` :
```js
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/
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/
index.html page de dépôt (login = lien vers /auth/login, sans build)
```