`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>
333 lines
18 KiB
Markdown
333 lines
18 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`), 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)
|
||
```
|