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>
This commit is contained in:
Nicolas 2026-09-09 23:06:53 +02:00
commit fe8bd26bb7
37 changed files with 7683 additions and 0 deletions

6
.env.example Normal file
View file

@ -0,0 +1,6 @@
# Copier ce fichier en `.env`. Il ne contient qu'un pointeur vers la configuration.
# Le fichier `.env` est chargé automatiquement au démarrage (npm run dev / npm start).
# Chemin du fichier de configuration JSON.
# Optionnel : par défaut le serveur lit ./config.json dans le dossier courant.
MEDIADROP_CONFIG=./config.json

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
* text=auto eol=lf
*.png binary

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
node_modules/
dist/
coverage/
.env
config.json
uploads/
*.log

7
.prettierignore Normal file
View file

@ -0,0 +1,7 @@
dist
coverage
node_modules
uploads
package-lock.json
Claude.md
CLAUDE.md

5
.prettierrc.json Normal file
View file

@ -0,0 +1,5 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100
}

43
Claude.md Normal file
View file

@ -0,0 +1,43 @@
# Directives de Développement TypeScript & Qualité
## 1. Principes Généraux
- **Qualité & Solidité** : Code propre, explicite, auto-documenté. Pas de hacks ou de contournements temporaires.
- **DRY & KISS** : Préférer la lisibilité et la simplicité à l'abstraction prématurée.
- **Sécurité** : Validation systématique des données aux frontières de l'application (pas de données non vérifiées). Aucune clé ou secret en dur.
## 2. Standards TypeScript & Typage Strict
- **Typage strict** :
- `noImplicitAny` et `strictNullChecks` activés.
- Interdiction d'utiliser le type `any`. Utiliser `unknown` avec du narrowing (type guards) si le type est incertain.
- Préférer les `interface` pour les objets publics/extensibles et les `type` pour les unions, intersections et tuples.
- Expliciter les types de retour des fonctions publiques et des méthodes d'API.
- **Conventions de nommage** :
- `PascalCase` pour les types, interfaces, classes et composants React/Vue.
- `camelCase` pour les variables, fonctions et méthodes.
- `SNAKE_CASE` pour les constantes globales.
- Interfaces : Ne pas utiliser le préfixe `I` (écrire `User`, pas `IUser`).
- **Gestion de l'asynchronisme** :
- Préférer `async/await` aux promesses chaînées (`.then()`).
- Toujours typer le retour des fonctions asynchrones : `Promise<T>`.
## 3. Qualité, Linting & Formattage
- **Structure** : Fichiers ciblés et courts (< 200-300 lignes). Découper en modules si nécessaire.
- **Linter & Formateur** : Le code doit passer sans aucun warning ni erreur sous **ESLint** et **Prettier**.
- **Gestion des erreurs** :
- Pas de `try/catch` vides.
- Utiliser des classes d'erreur personnalisées dérivant de `Error`.
## 4. Tests
- **Règle absolue** : Tout nouveau code ou correctif doit être accompagné de ses tests (ex. **Vitest** ou **Jest**).
- Exécuter la suite de tests avant toute validation.
## 5. Commandes du Projet
- **Installation** : `npm install` (ou `pnpm install` / `yarn install`)
- **Vérification de type** : `npx tsc --noEmit`
- **Linter** : `npm run lint`
- **Tests** : `npm test`
- **Build** : `npm run build`
## 6. Format des Réponses de Claude
- Proposer du code complet et directement exploitable.
- Expliciter les choix de typage ou de refactorisation complexes.

171
README.md Normal file
View file

@ -0,0 +1,171 @@
# 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)
```

23
config.example.json Normal file
View file

@ -0,0 +1,23 @@
{
"$schema": "./config.schema.json",
"port": 3000,
"host": "0.0.0.0",
"publicUrl": "http://localhost:3000",
"targets": [
{ "name": "photos", "label": "Photos", "dir": "./uploads/photos" },
{ "name": "videos", "label": "Vidéos", "dir": "./uploads/videos" }
],
"maxFileSizeMb": 512,
"logLevel": "info",
"allowedMimeTypes": ["image/jpeg", "image/png", "image/webp", "video/mp4", "audio/mpeg"],
"session": {
"key": "REMPLACER_PAR_32_OCTETS_BASE64_openssl_rand_base64_32",
"maxAgeHours": 12
},
"oidc": {
"issuer": "https://auth.exemple.fr/realms/mon-realm",
"clientId": "mediadrop",
"clientSecret": "REMPLACER_PAR_LE_SECRET_DU_CLIENT",
"scopes": ["openid", "profile", "email"]
}
}

83
config.schema.json Normal file
View file

@ -0,0 +1,83 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://mediadrop.local/config.schema.json",
"title": "Configuration mediaDrop",
"type": "object",
"additionalProperties": false,
"required": ["publicUrl", "targets", "session", "oidc"],
"properties": {
"$schema": { "type": "string" },
"port": { "type": "integer", "minimum": 1, "maximum": 65535, "default": 3000 },
"host": { "type": "string", "default": "0.0.0.0" },
"publicUrl": {
"type": "string",
"format": "uri",
"description": "URL externe de base, ex. https://media.exemple.fr (sert à redirect_uri / post-logout)."
},
"targets": {
"type": "array",
"minItems": 1,
"description": "Dossiers nommés dans lesquels déposer. Le premier est la cible par défaut.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name", "dir"],
"properties": {
"name": {
"type": "string",
"pattern": "^[A-Za-z0-9._-]+$",
"description": "Identifiant transmis par le client (?target=...)."
},
"label": { "type": "string", "description": "Libellé affiché (défaut : name)." },
"dir": { "type": "string", "description": "Chemin du dossier de destination." }
}
}
},
"maxFileSizeMb": { "type": "integer", "minimum": 1, "default": 512 },
"logLevel": {
"enum": ["fatal", "error", "warn", "info", "debug", "trace", "silent"],
"default": "info"
},
"allowedMimeTypes": {
"description": "Liste blanche de types MIME, ou \"all\". Absent => liste média par défaut.",
"oneOf": [
{ "const": "all" },
{ "type": "array", "items": { "type": "string" }, "minItems": 1 }
]
},
"session": {
"type": "object",
"additionalProperties": false,
"required": ["key"],
"properties": {
"key": {
"type": "string",
"description": "32 octets encodés en base64. Générer avec : openssl rand -base64 32"
},
"maxAgeHours": { "type": "number", "exclusiveMinimum": 0, "default": 12 }
}
},
"oidc": {
"type": "object",
"additionalProperties": false,
"required": ["issuer", "clientId", "clientSecret"],
"properties": {
"issuer": {
"type": "string",
"format": "uri",
"description": "Issuer OIDC (le serveur lit <issuer>/.well-known/openid-configuration)."
},
"clientId": { "type": "string" },
"clientSecret": {
"type": "string",
"description": "Secret du client confidentiel déclaré côté fournisseur."
},
"scopes": {
"type": "array",
"items": { "type": "string" },
"default": ["openid", "profile", "email"]
}
}
}
}
}

36
eslint.config.mjs Normal file
View file

@ -0,0 +1,36 @@
import js from '@eslint/js';
import globals from 'globals';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
export default tseslint.config(
{ ignores: ['dist/**', 'coverage/**', 'node_modules/**', 'uploads/**'] },
js.configs.recommended,
{
files: ['src/**/*.ts', 'tests/**/*.ts'],
extends: [...tseslint.configs.recommendedTypeChecked],
languageOptions: {
globals: { ...globals.node },
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/explicit-function-return-type': ['error', { allowExpressions: true }],
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
},
},
{
files: ['**/*.mjs', '*.config.ts'],
extends: [tseslint.configs.disableTypeChecked],
languageOptions: {
globals: { ...globals.node },
},
},
prettier,
);

4921
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

42
package.json Normal file
View file

@ -0,0 +1,42 @@
{
"name": "mediadrop",
"version": "0.1.0",
"description": "Petit serveur de dépôt de fichiers médias (images / vidéos / audio)",
"type": "module",
"engines": {
"node": ">=22.9"
},
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.build.json",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@fastify/multipart": "^9.0.3",
"@fastify/secure-session": "^8.3.0",
"fastify": "^5.2.1",
"openid-client": "^6.8.8"
},
"devDependencies": {
"@eslint/js": "^9.18.0",
"@types/node": "^22.10.7",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"form-data": "^4.0.1",
"globals": "^15.14.0",
"jose": "^6.2.12",
"prettier": "^3.4.2",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0",
"vitest": "^3.0.2"
}
}

381
public/index.html Normal file
View file

@ -0,0 +1,381 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>mediaDrop</title>
<style>
:root {
color-scheme: light dark;
--bg: #f6f7f9;
--card: #ffffff;
--border: #d7dbe0;
--text: #1c1f23;
--muted: #6b7280;
--accent: #2563eb;
--ok: #15803d;
--err: #b91c1c;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171a;
--card: #1d2125;
--border: #33393f;
--text: #e8eaed;
--muted: #9aa3ad;
--accent: #60a5fa;
--ok: #4ade80;
--err: #f87171;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 2rem 1rem;
font:
15px/1.5 system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
background: var(--bg);
color: var(--text);
}
main {
max-width: 640px;
margin: 0 auto;
display: grid;
gap: 1.25rem;
}
h1 {
margin: 0;
font-size: 1.4rem;
}
p.sub {
margin: 0.25rem 0 0;
color: var(--muted);
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem;
}
.authrow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.btn {
display: inline-block;
font: inherit;
padding: 0.5rem 0.9rem;
border-radius: 8px;
border: 1px solid var(--accent);
background: var(--accent);
color: #fff;
text-decoration: none;
cursor: pointer;
}
.btn.secondary {
background: transparent;
color: var(--accent);
}
form.inline {
margin: 0;
}
label {
display: block;
font-weight: 600;
margin-bottom: 0.4rem;
}
select {
width: 100%;
padding: 0.5rem 0.6rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font: inherit;
}
#dropzone {
border: 2px dashed var(--border);
border-radius: 10px;
padding: 2.5rem 1rem;
text-align: center;
color: var(--muted);
cursor: pointer;
transition:
border-color 0.15s,
background 0.15s,
opacity 0.15s;
}
#dropzone.hover {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
color: var(--text);
}
#dropzone.disabled {
opacity: 0.5;
cursor: not-allowed;
}
#list {
display: grid;
gap: 0.6rem;
}
.item {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.6rem 0.75rem;
display: grid;
gap: 0.4rem;
}
.item .row {
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: 0.9rem;
}
.item .name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bar {
height: 6px;
border-radius: 999px;
background: var(--border);
overflow: hidden;
}
.bar > span {
display: block;
height: 100%;
width: 0;
background: var(--accent);
transition: width 0.15s;
}
.status.ok {
color: var(--ok);
}
.status.err {
color: var(--err);
}
.muted {
color: var(--muted);
}
#banner {
color: var(--err);
font-size: 0.9rem;
}
</style>
</head>
<body>
<main>
<header>
<h1>mediaDrop</h1>
<p class="sub">Connectez-vous, puis glissez des fichiers médias pour les déposer.</p>
</header>
<div class="card">
<div class="authrow">
<span id="authState" class="muted">Chargement…</span>
<span>
<a id="loginLink" class="btn" href="/auth/login" hidden>Se connecter</a>
<form id="logoutForm" class="inline" method="post" action="/auth/logout" hidden>
<button class="btn secondary" type="submit">Se déconnecter</button>
</form>
</span>
</div>
<div id="banner" hidden></div>
</div>
<div id="targetCard" class="card" hidden>
<label for="targetSelect">Dossier de dépôt</label>
<select id="targetSelect"></select>
</div>
<div id="dropzone" class="card disabled">
<strong>Déposez vos fichiers ici</strong><br />
<span class="muted">ou cliquez pour parcourir</span>
<input id="picker" type="file" multiple hidden />
</div>
<div id="list"></div>
</main>
<script>
const authState = document.getElementById('authState');
const banner = document.getElementById('banner');
const loginLink = document.getElementById('loginLink');
const logoutForm = document.getElementById('logoutForm');
const targetCard = document.getElementById('targetCard');
const targetSelect = document.getElementById('targetSelect');
const dropzone = document.getElementById('dropzone');
const picker = document.getElementById('picker');
const list = document.getElementById('list');
let authenticated = false;
let currentTarget = null;
function showBanner(message) {
banner.textContent = message;
banner.hidden = false;
}
const loginParam = new URLSearchParams(location.search).get('login');
if (loginParam === 'error') showBanner('La connexion a échoué. Réessayez.');
else if (loginParam === 'expired') showBanner('La session de connexion a expiré. Réessayez.');
if (loginParam) history.replaceState({}, '', location.pathname);
function render(me) {
authenticated = Boolean(me && me.authenticated);
loginLink.hidden = authenticated;
logoutForm.hidden = !authenticated;
dropzone.classList.toggle('disabled', !authenticated);
authState.textContent = authenticated ? 'Connecté : ' + me.name : 'Non connecté';
if (authenticated) loadTargets();
else targetCard.hidden = true;
}
function loadTargets() {
fetch('/targets', { headers: { accept: 'application/json' } })
.then((r) => (r.ok ? r.json() : []))
.then((targets) => {
targetSelect.replaceChildren();
targets.forEach((target) => {
const option = document.createElement('option');
option.value = target.name;
option.textContent = target.label;
targetSelect.appendChild(option);
});
currentTarget = targets.length ? targets[0].name : null;
targetCard.hidden = targets.length < 2;
})
.catch(() => {
currentTarget = null;
targetCard.hidden = true;
});
}
targetSelect.addEventListener('change', () => {
currentTarget = targetSelect.value;
});
fetch('/auth/me', { headers: { accept: 'application/json' } })
.then((r) => r.json())
.then(render)
.catch(() => render({ authenticated: false }));
function formatSize(bytes) {
const units = ['o', 'Ko', 'Mo', 'Go'];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return value.toFixed(value < 10 && unit > 0 ? 1 : 0) + ' ' + units[unit];
}
function addItem(file) {
const item = document.createElement('div');
item.className = 'item';
item.innerHTML =
'<div class="row"><span class="name"></span><span class="muted"></span></div>' +
'<div class="bar"><span></span></div>' +
'<div class="row"><span class="status muted">En attente…</span></div>';
item.querySelector('.name').textContent = file.name;
item.querySelector('.row .muted').textContent = formatSize(file.size);
list.prepend(item);
return { bar: item.querySelector('.bar > span'), status: item.querySelector('.status') };
}
function setStatus(ui, text, kind) {
ui.status.textContent = text;
ui.status.className = 'status ' + (kind || 'muted');
}
function upload(file) {
const ui = addItem(file);
const form = new FormData();
form.append('file', file);
const xhr = new XMLHttpRequest();
const url = currentTarget
? '/upload?target=' + encodeURIComponent(currentTarget)
: '/upload';
xhr.open('POST', url);
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
ui.bar.style.width = (event.loaded / event.total) * 100 + '%';
}
});
xhr.addEventListener('load', () => {
let body = {};
try {
body = JSON.parse(xhr.responseText);
} catch {
body = {};
}
if (xhr.status >= 200 && xhr.status < 300) {
ui.bar.style.width = '100%';
setStatus(ui, 'Déposé : ' + (body.storedName || 'OK'), 'ok');
} else if (xhr.status === 401) {
render({ authenticated: false });
showBanner('Session expirée — reconnectez-vous.');
setStatus(ui, 'Non authentifié', 'err');
} else {
setStatus(ui, 'Erreur ' + xhr.status + ' : ' + (body.message || 'échec'), 'err');
}
});
xhr.addEventListener('error', () => setStatus(ui, 'Erreur réseau', 'err'));
setStatus(ui, 'Envoi…', 'muted');
xhr.send(form);
}
function handleFiles(files) {
if (!authenticated) {
showBanner('Connectez-vous avant de déposer des fichiers.');
return;
}
banner.hidden = true;
for (const file of files) upload(file);
}
dropzone.addEventListener('click', () => {
if (authenticated) picker.click();
});
picker.addEventListener('change', () => {
handleFiles(picker.files);
picker.value = '';
});
['dragenter', 'dragover'].forEach((type) => {
dropzone.addEventListener(type, (event) => {
event.preventDefault();
if (authenticated) dropzone.classList.add('hover');
});
});
['dragleave', 'drop'].forEach((type) => {
dropzone.addEventListener(type, (event) => {
event.preventDefault();
if (type === 'dragleave' && dropzone.contains(event.relatedTarget)) return;
dropzone.classList.remove('hover');
});
});
dropzone.addEventListener('drop', (event) => {
if (event.dataTransfer && event.dataTransfer.files.length) {
handleFiles(event.dataTransfer.files);
}
});
</script>
</body>
</html>

82
src/app.ts Normal file
View file

@ -0,0 +1,82 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import Fastify, { type FastifyInstance, type FastifyServerOptions } from 'fastify';
import multipart from '@fastify/multipart';
import secureSession from '@fastify/secure-session';
import type { AppConfig } from './config.js';
import { registerErrorHandler } from './error-handler.js';
import { ensureUploadDir } from './lib/storage.js';
import { createOidcClient, type OidcClient } from './oidc.js';
import { authRoutes } from './routes/auth.js';
import { healthRoutes } from './routes/health.js';
import { uploadRoutes } from './routes/upload.js';
const INDEX_HTML = readFileSync(
fileURLToPath(new URL('../public/index.html', import.meta.url)),
'utf8',
);
const SESSION_COOKIE_NAME = 'mediadrop_session';
export interface BuildAppOptions {
/** Passer `false` pour couper les logs (utile en test). */
logger?: FastifyServerOptions['logger'];
/** Façade OIDC injectée (test). Par défaut : découverte réelle de l'issuer. */
oidcClient?: OidcClient;
}
/**
* Construit l'instance Fastify : session chiffrée, façade OIDC, plugins, dossier
* de dépôt, gestionnaire d'erreurs, page (`GET /`) et routes (`/auth/*`,
* `/health`, `/upload`). Exportée pour les tests.
*/
export async function buildApp(
config: AppConfig,
options: BuildAppOptions = {},
): Promise<FastifyInstance> {
const oidcClient = options.oidcClient ?? (await createOidcClient(config));
const app = Fastify({
logger: options.logger ?? { level: config.logLevel },
});
app.decorate('appConfig', config);
app.decorate('oidcClient', oidcClient);
await app.register(secureSession, {
key: config.session.key,
cookieName: SESSION_COOKIE_NAME,
cookie: {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: config.publicUrl.startsWith('https://'),
maxAge: config.session.maxAgeSeconds,
},
});
await app.register(multipart, {
throwFileSizeLimit: true,
limits: {
files: 1,
fileSize: config.maxFileSizeBytes,
},
});
for (const target of config.targets) {
await ensureUploadDir(target.dir);
}
registerErrorHandler(app);
app.get('/', (_request, reply): void => {
reply.type('text/html; charset=utf-8').send(INDEX_HTML);
});
await app.register(authRoutes);
await app.register(healthRoutes);
await app.register(uploadRoutes);
return app;
}

280
src/config.ts Normal file
View file

@ -0,0 +1,280 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { ConfigError } from './errors.js';
import { DEFAULT_ALLOWED_MIME, type MimePolicy } from './lib/media-types.js';
const LOG_LEVELS = ['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent'] as const;
export type LogLevel = (typeof LOG_LEVELS)[number];
const DEFAULT_SCOPES = ['openid', 'profile', 'email'] as const;
const SESSION_KEY_BYTES = 32;
export interface SessionConfig {
/** Clé de chiffrement du cookie de session (32 octets). */
key: Buffer;
maxAgeSeconds: number;
}
export interface OidcConfig {
/** Issuer OIDC, sans slash final. */
issuer: string;
clientId: string;
clientSecret: string;
scopes: string[];
}
/** Dossier nommé dans lequel déposer des fichiers. */
export interface DropTarget {
/** Identifiant stable, transmis par le client (`?target=...`). */
name: string;
/** Libellé d'affichage (défaut : `name`). */
label: string;
/** Chemin du dossier de destination. */
dir: string;
}
/** Liste non vide de cibles de dépôt ; la première est la cible par défaut. */
export type DropTargets = readonly [DropTarget, ...DropTarget[]];
/** Configuration applicative résolue et validée. */
export interface AppConfig {
port: number;
host: string;
/** URL externe de base (sert à construire redirect_uri et post_logout_redirect_uri). */
publicUrl: string;
targets: DropTargets;
maxFileSizeBytes: number;
allowedMimeTypes: MimePolicy;
logLevel: LogLevel;
session: SessionConfig;
oidc: OidcConfig;
}
/** Nom de la variable d'environnement pointant vers le fichier de configuration. */
export const CONFIG_PATH_ENV = 'MEDIADROP_CONFIG';
const DEFAULT_CONFIG_PATH = './config.json';
const DEFAULTS = {
port: 3000,
host: '0.0.0.0',
maxFileSizeMb: 512,
logLevel: 'info',
sessionMaxAgeHours: 12,
} as const;
const TARGET_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
function asRecord(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new ConfigError(`${label} doit être un objet JSON`);
}
return value as Record<string, unknown>;
}
function optionalString(value: unknown, name: string): string | undefined {
if (value === undefined || value === null) {
return undefined;
}
if (typeof value !== 'string' || value.trim().length === 0) {
throw new ConfigError(`${name} doit être une chaîne non vide`);
}
return value.trim();
}
function requiredString(value: unknown, name: string): string {
const parsed = optionalString(value, name);
if (parsed === undefined) {
throw new ConfigError(`${name} est obligatoire`);
}
return parsed;
}
function optionalPositiveNumber(value: unknown, fallback: number, name: string): number {
if (value === undefined || value === null) {
return fallback;
}
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
throw new ConfigError(`${name} doit être un nombre strictement positif`);
}
return value;
}
function optionalPositiveInt(value: unknown, fallback: number, name: string): number {
const parsed = optionalPositiveNumber(value, fallback, name);
if (!Number.isInteger(parsed)) {
throw new ConfigError(`${name} doit être un entier`);
}
return parsed;
}
function parseLogLevel(value: unknown): LogLevel {
if (value === undefined || value === null) {
return DEFAULTS.logLevel;
}
if (typeof value !== 'string' || !(LOG_LEVELS as readonly string[]).includes(value)) {
throw new ConfigError(`logLevel doit être l'un de : ${LOG_LEVELS.join(', ')}`);
}
return value as LogLevel;
}
function parseMimePolicy(value: unknown): MimePolicy {
if (value === undefined || value === null) {
return DEFAULT_ALLOWED_MIME;
}
if (value === 'all') {
return 'all';
}
if (
!Array.isArray(value) ||
!value.every((entry): entry is string => typeof entry === 'string')
) {
throw new ConfigError('allowedMimeTypes doit être "all" ou un tableau de chaînes');
}
const list = value.map((entry) => entry.trim().toLowerCase()).filter((entry) => entry.length > 0);
if (list.length === 0) {
throw new ConfigError('allowedMimeTypes ne contient aucun type MIME valide');
}
return list;
}
function normalizeHttpUrl(value: unknown, name: string): string {
const raw = requiredString(value, name);
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new ConfigError(`${name} doit être une URL absolue (reçu : "${raw}")`);
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
throw new ConfigError(`${name} doit utiliser le schéma http ou https`);
}
return raw.replace(/\/+$/, '');
}
function parseSessionKey(value: unknown): Buffer {
const raw = requiredString(value, 'session.key');
const key = Buffer.from(raw, 'base64');
if (key.length !== SESSION_KEY_BYTES) {
throw new ConfigError(
`session.key doit être ${String(SESSION_KEY_BYTES)} octets encodés en base64 ` +
`(décodé : ${String(key.length)} octets). Générer avec : openssl rand -base64 32`,
);
}
return key;
}
function parseScopes(value: unknown): string[] {
if (value === undefined || value === null) {
return [...DEFAULT_SCOPES];
}
if (
!Array.isArray(value) ||
!value.every((entry): entry is string => typeof entry === 'string')
) {
throw new ConfigError('oidc.scopes doit être un tableau de chaînes');
}
const scopes = value.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
return scopes.includes('openid') ? scopes : ['openid', ...scopes];
}
function parseSession(value: unknown): SessionConfig {
const raw = asRecord(value, 'session');
return {
key: parseSessionKey(raw.key),
maxAgeSeconds: Math.round(
optionalPositiveNumber(raw.maxAgeHours, DEFAULTS.sessionMaxAgeHours, 'session.maxAgeHours') *
3600,
),
};
}
function parseOidc(value: unknown): OidcConfig {
const raw = asRecord(value, 'oidc');
return {
issuer: normalizeHttpUrl(raw.issuer, 'oidc.issuer'),
clientId: requiredString(raw.clientId, 'oidc.clientId'),
clientSecret: requiredString(raw.clientSecret, 'oidc.clientSecret'),
scopes: parseScopes(raw.scopes),
};
}
function parseTargets(value: unknown): DropTargets {
if (!Array.isArray(value) || value.length === 0) {
throw new ConfigError('targets doit être un tableau non vide');
}
const entries: unknown[] = value;
const seen = new Set<string>();
const parseOne = (entry: unknown, index: number): DropTarget => {
const raw = asRecord(entry, `targets[${String(index)}]`);
const name = requiredString(raw.name, `targets[${String(index)}].name`);
if (!TARGET_NAME_PATTERN.test(name)) {
throw new ConfigError(
`targets[${String(index)}].name doit correspondre à ${TARGET_NAME_PATTERN.source}`,
);
}
if (seen.has(name)) {
throw new ConfigError(`targets : nom en double « ${name} »`);
}
seen.add(name);
return {
name,
label: optionalString(raw.label, `targets[${String(index)}].label`) ?? name,
dir: requiredString(raw.dir, `targets[${String(index)}].dir`),
};
};
const [first, ...rest] = entries;
return [parseOne(first, 0), ...rest.map((entry, index) => parseOne(entry, index + 1))];
}
/**
* Valide un objet de configuration déjà parsé (issu du JSON) et le convertit en
* `AppConfig`. Fonction pure : lève `ConfigError` en cas d'incohérence.
*/
export function parseConfig(raw: unknown): AppConfig {
const root = asRecord(raw, 'La configuration');
return {
port: optionalPositiveInt(root.port, DEFAULTS.port, 'port'),
host: optionalString(root.host, 'host') ?? DEFAULTS.host,
publicUrl: normalizeHttpUrl(root.publicUrl, 'publicUrl'),
targets: parseTargets(root.targets),
maxFileSizeBytes:
optionalPositiveInt(root.maxFileSizeMb, DEFAULTS.maxFileSizeMb, 'maxFileSizeMb') *
1024 *
1024,
allowedMimeTypes: parseMimePolicy(root.allowedMimeTypes),
logLevel: parseLogLevel(root.logLevel),
session: parseSession(root.session),
oidc: parseOidc(root.oidc),
};
}
/** Chemin absolu du fichier de configuration (variable d'env, sinon `./config.json`). */
export function resolveConfigPath(env: NodeJS.ProcessEnv): string {
const fromEnv = env[CONFIG_PATH_ENV]?.trim();
return path.resolve(fromEnv !== undefined && fromEnv.length > 0 ? fromEnv : DEFAULT_CONFIG_PATH);
}
/** Lit et valide le fichier de configuration JSON désigné par l'environnement. */
export function loadConfig(env: NodeJS.ProcessEnv): AppConfig {
const file = resolveConfigPath(env);
let contents: string;
try {
contents = readFileSync(file, 'utf8');
} catch (cause) {
throw new ConfigError(`Fichier de configuration introuvable ou illisible : ${file}`, { cause });
}
let raw: unknown;
try {
raw = JSON.parse(contents);
} catch (cause) {
throw new ConfigError(`JSON invalide dans ${file}`, { cause });
}
return parseConfig(raw);
}

63
src/error-handler.ts Normal file
View file

@ -0,0 +1,63 @@
import type { FastifyError, FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { AppError } from './errors.js';
interface ErrorBody {
error: string;
message: string;
}
const TOO_LARGE_MESSAGE = 'Le fichier dépasse la taille maximale autorisée';
/**
* Installe le gestionnaire d'erreurs global.
*
* - `AppError` -> `statusCode` + `{ error, message }`.
* - Erreurs `@fastify/multipart` connues -> 4xx explicites.
* - Erreurs de validation de schéma Fastify -> 400.
* - Tout le reste -> 500 générique (détails seulement dans les logs).
*/
export function registerErrorHandler(app: FastifyInstance): void {
app.setErrorHandler((error: FastifyError, request: FastifyRequest, reply: FastifyReply): void => {
if (error instanceof AppError) {
reply
.status(error.statusCode)
.send({ error: error.name, message: error.message } satisfies ErrorBody);
return;
}
switch (error.code) {
case 'FST_REQ_FILE_TOO_LARGE':
reply
.status(413)
.send({ error: 'PayloadTooLargeError', message: TOO_LARGE_MESSAGE } satisfies ErrorBody);
return;
case 'FST_FILES_LIMIT':
reply.status(400).send({
error: 'ValidationError',
message: 'Un seul fichier par requête',
} satisfies ErrorBody);
return;
case 'FST_INVALID_MULTIPART_CONTENT_TYPE':
reply.status(400).send({
error: 'ValidationError',
message: 'Requête multipart/form-data attendue',
} satisfies ErrorBody);
return;
default:
break;
}
if (error.validation) {
reply
.status(400)
.send({ error: 'ValidationError', message: error.message } satisfies ErrorBody);
return;
}
request.log.error({ err: error }, 'Erreur non gérée');
reply
.status(500)
.send({ error: 'InternalServerError', message: 'Erreur interne' } satisfies ErrorBody);
});
}

51
src/errors.ts Normal file
View file

@ -0,0 +1,51 @@
/**
* Hiérarchie d'erreurs de l'application.
*
* Toutes dérivent de `AppError`, qui porte un `statusCode` HTTP. Le gestionnaire
* d'erreurs Fastify (`src/error-handler.ts`) s'appuie dessus pour formater la réponse.
* Les messages ne doivent jamais contenir de donnée sensible (jeton, chemin absolu...).
*/
export abstract class AppError extends Error {
public readonly statusCode: number;
protected constructor(message: string, statusCode: number, options?: { cause?: unknown }) {
super(message, options);
this.name = this.constructor.name;
this.statusCode = statusCode;
}
}
/** Configuration invalide au démarrage (fichier JSON absent, illisible ou incohérent). */
export class ConfigError extends AppError {
public constructor(message: string, options?: { cause?: unknown }) {
super(message, 500, options);
}
}
/** Découverte OIDC impossible au démarrage (issuer injoignable ou réponse invalide). */
export class OidcDiscoveryError extends AppError {
public constructor(message: string, options?: { cause?: unknown }) {
super(message, 500, options);
}
}
/** Jeton d'accès absent, mal formé, expiré ou invalide. */
export class UnauthorizedError extends AppError {
public constructor(message = "Jeton d'accès absent ou invalide") {
super(message, 401);
}
}
/** Requête mal formée (fichier manquant, type non autorisé...). */
export class ValidationError extends AppError {
public constructor(message: string) {
super(message, 400);
}
}
/** Fichier au-dessus de la taille maximale autorisée. */
export class PayloadTooLargeError extends AppError {
public constructor(message = 'Le fichier dépasse la taille maximale autorisée') {
super(message, 413);
}
}

19
src/fastify.d.ts vendored Normal file
View file

@ -0,0 +1,19 @@
import type { AppConfig } from './config.js';
import type { OidcClient } from './oidc.js';
import type { LoginTransaction, SessionUser } from './session.js';
declare module 'fastify' {
interface FastifyInstance {
/** Configuration applicative, injectée par `buildApp`. */
appConfig: AppConfig;
/** Façade OIDC (découverte effectuée), injectée par `buildApp`. */
oidcClient: OidcClient;
}
}
declare module '@fastify/secure-session' {
interface SessionData {
user?: SessionUser;
txn?: LoginTransaction;
}
}

50
src/index.ts Normal file
View file

@ -0,0 +1,50 @@
import { buildApp } from './app.js';
import { loadConfig } from './config.js';
import { AppError } from './errors.js';
/** Charge `.env` depuis le dossier courant s'il existe (silencieux sinon). */
function loadDotEnv(): void {
try {
process.loadEnvFile();
} catch (error) {
const code =
typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined;
if (code !== 'ENOENT') {
throw error;
}
}
}
async function main(): Promise<void> {
loadDotEnv();
const config = loadConfig(process.env);
const app = await buildApp(config);
const shutdown = async (signal: string): Promise<void> => {
app.log.info(`Signal ${signal} reçu, arrêt en cours…`);
try {
await app.close();
process.exit(0);
} catch (error) {
app.log.error({ err: error }, "Échec de l'arrêt propre");
process.exit(1);
}
};
process.on('SIGINT', () => {
void shutdown('SIGINT');
});
process.on('SIGTERM', () => {
void shutdown('SIGTERM');
});
await app.listen({ port: config.port, host: config.host });
}
void main().catch((error: unknown) => {
if (error instanceof AppError) {
console.error(`[mediaDrop] ${error.name}: ${error.message}`);
} else {
console.error(error);
}
process.exitCode = 1;
});

58
src/lib/media-types.ts Normal file
View file

@ -0,0 +1,58 @@
/**
* Liste blanche des types de médias acceptés et correspondance type MIME -> extension.
*
* Note : le type MIME utilisé ici est celui *annoncé par le client*. Il n'est pas
* vérifié via les octets de signature du fichier (voir README, section durcissement).
*/
/** Types MIME autorisés par défaut lorsque `ALLOWED_MIME_TYPES` n'est pas défini. */
export const DEFAULT_ALLOWED_MIME: readonly string[] = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/avif',
'video/mp4',
'video/webm',
'video/quicktime',
'audio/mpeg',
'audio/ogg',
'audio/wav',
'audio/flac',
'audio/mp4',
];
const MIME_TO_EXTENSION: Readonly<Record<string, string>> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'image/avif': '.avif',
'video/mp4': '.mp4',
'video/webm': '.webm',
'video/quicktime': '.mov',
'audio/mpeg': '.mp3',
'audio/ogg': '.ogg',
'audio/wav': '.wav',
'audio/flac': '.flac',
'audio/mp4': '.m4a',
};
/** Politique de filtrage : une liste blanche explicite, ou `'all'` pour tout accepter. */
export type MimePolicy = readonly string[] | 'all';
/** Indique si un type MIME est accepté selon la politique fournie. */
export function isAllowedMedia(mimeType: string, policy: MimePolicy): boolean {
if (policy === 'all') {
return true;
}
return policy.includes(mimeType);
}
/**
* Extension de fichier sûre pour un type MIME connu.
* Renvoie `null` si le type n'est pas répertorié (l'appelant choisit un repli).
*/
export function extensionForMime(mimeType: string): string | null {
return MIME_TO_EXTENSION[mimeType] ?? null;
}

116
src/lib/storage.ts Normal file
View file

@ -0,0 +1,116 @@
import { createWriteStream } from 'node:fs';
import { mkdir, rm } from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { Transform, type Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { PayloadTooLargeError } from '../errors.js';
import { extensionForMime } from './media-types.js';
/** Métadonnées d'un fichier enregistré sur le disque. */
export interface StoredFile {
storedName: string;
originalName: string;
size: number;
mimeType: string;
path: string;
}
export interface SaveFileParams {
source: Readable;
originalName: string;
mimeType: string;
/** Dossier de destination (résolu depuis la cible de dépôt choisie). */
targetDir: string;
maxFileSizeBytes: number;
}
const MAX_BASE_LENGTH = 60;
/** Crée un dossier de dépôt s'il n'existe pas (idempotent). */
export async function ensureUploadDir(dir: string): Promise<void> {
await mkdir(dir, { recursive: true });
}
function sanitizeBase(originalName: string): string {
const base = path.basename(originalName, path.extname(originalName));
const cleaned = base
.replace(/[^A-Za-z0-9_-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^[_-]+|[_-]+$/g, '');
return (cleaned.length > 0 ? cleaned : 'file').slice(0, MAX_BASE_LENGTH);
}
function sanitizeExtension(originalName: string): string {
const ext = path
.extname(originalName)
.toLowerCase()
.replace(/[^a-z0-9.]/g, '');
return /^\.[a-z0-9]{1,12}$/.test(ext) ? ext : '.bin';
}
function buildStoredName(originalName: string, mimeType: string): string {
const ext = extensionForMime(mimeType) ?? sanitizeExtension(originalName);
return `${sanitizeBase(originalName)}-${Date.now()}-${randomUUID().slice(0, 8)}${ext}`;
}
function normalizeStreamError(error: unknown): Error {
if (error instanceof PayloadTooLargeError) {
return error;
}
const code =
typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined;
if (code === 'FST_REQ_FILE_TOO_LARGE') {
return new PayloadTooLargeError();
}
return error instanceof Error ? error : new Error("Échec de l'écriture du fichier");
}
/**
* `@fastify/multipart` ne lève pas sur dépassement : il coupe le flux et positionne
* `truncated` à `true`. On le vérifie donc après coup.
*/
function wasTruncated(stream: Readable): boolean {
return (stream as { truncated?: unknown }).truncated === true;
}
/**
* Écrit le flux `source` sur le disque, dans `targetDir`.
*
* - Le nom final est entièrement dérivé côté serveur : aucun segment fourni par le
* client n'entre dans le chemin (pas de path traversal possible).
* - La taille est plafonnée pendant le streaming ; au-delà de `maxFileSizeBytes`, le
* fichier partiel est supprimé et `PayloadTooLargeError` est levée.
*/
export async function saveFile(params: SaveFileParams): Promise<StoredFile> {
const { source, originalName, mimeType, targetDir, maxFileSizeBytes } = params;
const storedName = buildStoredName(originalName, mimeType);
const destination = path.join(targetDir, storedName);
let bytesWritten = 0;
const limiter = new Transform({
transform(chunk: Buffer, _encoding, callback): void {
bytesWritten += chunk.length;
if (bytesWritten > maxFileSizeBytes) {
callback(new PayloadTooLargeError());
return;
}
callback(null, chunk);
},
});
try {
await pipeline(source, limiter, createWriteStream(destination));
} catch (error) {
await rm(destination, { force: true });
throw normalizeStreamError(error);
}
if (wasTruncated(source)) {
await rm(destination, { force: true });
throw new PayloadTooLargeError();
}
return { storedName, originalName, size: bytesWritten, mimeType, path: destination };
}

112
src/oidc.ts Normal file
View file

@ -0,0 +1,112 @@
import * as client from 'openid-client';
import type { AppConfig } from './config.js';
import { OidcDiscoveryError, UnauthorizedError } from './errors.js';
export interface AuthorizationUrlParams {
redirectUri: string;
state: string;
nonce: string;
codeChallenge: string;
}
export interface CallbackParams {
/** URL complète de la requête `/auth/callback` reçue (avec `code` et `state`). */
currentUrl: URL;
state: string;
nonce: string;
codeVerifier: string;
}
export interface AuthenticatedUser {
sub: string;
/** Libellé d'affichage dérivé des claims (`name` > `preferred_username` > `email` > `sub`). */
name: string;
}
/** Façade minimale au-dessus d'`openid-client` pour le flux Authorization Code. */
export interface OidcClient {
readonly scope: string;
authorizationUrl(params: AuthorizationUrlParams): URL;
handleCallback(params: CallbackParams): Promise<AuthenticatedUser>;
/** URL de déconnexion RP-initiated, ou `null` si l'issuer n'en expose pas. */
endSessionUrl(postLogoutRedirectUri: string): URL | null;
}
function stringClaim(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
/**
* Effectue la découverte de l'issuer et renvoie une façade prête à l'emploi.
* Un issuer en `http://` (réseau interne, tests) est accepté ; `https` recommandé.
*/
export async function createOidcClient(config: AppConfig): Promise<OidcClient> {
const scope = config.oidc.scopes.join(' ');
const issuerUrl = new URL(config.oidc.issuer);
const allowInsecure = issuerUrl.protocol === 'http:';
let configuration: client.Configuration;
try {
configuration = await client.discovery(
issuerUrl,
config.oidc.clientId,
config.oidc.clientSecret,
undefined,
allowInsecure ? { execute: [client.allowInsecureRequests] } : undefined,
);
} catch (cause) {
throw new OidcDiscoveryError(`Découverte OIDC impossible (${config.oidc.issuer})`, { cause });
}
return {
scope,
authorizationUrl({ redirectUri, state, nonce, codeChallenge }): URL {
return client.buildAuthorizationUrl(configuration, {
redirect_uri: redirectUri,
scope,
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
},
async handleCallback({ currentUrl, state, nonce, codeVerifier }): Promise<AuthenticatedUser> {
let tokens: Awaited<ReturnType<typeof client.authorizationCodeGrant>>;
try {
tokens = await client.authorizationCodeGrant(configuration, currentUrl, {
expectedState: state,
expectedNonce: nonce,
pkceCodeVerifier: codeVerifier,
idTokenExpected: true,
});
} catch {
throw new UnauthorizedError('Échec de la validation de la réponse OIDC');
}
const claims = tokens.claims();
const sub = stringClaim(claims?.sub);
if (sub === undefined) {
throw new UnauthorizedError("Jeton d'identité sans sujet");
}
const name =
stringClaim(claims?.name) ??
stringClaim(claims?.preferred_username) ??
stringClaim(claims?.email) ??
sub;
return { sub, name };
},
endSessionUrl(postLogoutRedirectUri): URL | null {
try {
return client.buildEndSessionUrl(configuration, {
post_logout_redirect_uri: postLogoutRedirectUri,
});
} catch {
return null;
}
},
};
}

79
src/routes/auth.ts Normal file
View file

@ -0,0 +1,79 @@
import type { FastifyPluginCallback } from 'fastify';
import * as client from 'openid-client';
import { getSessionUser } from '../session.js';
interface MeResponse {
authenticated: boolean;
name?: string;
}
/**
* Flux OIDC côté serveur :
* - `GET /auth/login` démarre le flux Authorization Code + PKCE
* - `GET /auth/callback` échange le code, ouvre la session, redirige vers `/`
* - `POST /auth/logout` ferme la session (+ déconnexion RP si disponible)
* - `GET /auth/me` état de session pour la page
*/
export const authRoutes: FastifyPluginCallback = (app, _options, done) => {
const { publicUrl } = app.appConfig;
const oidc = app.oidcClient;
const redirectUri = `${publicUrl}/auth/callback`;
const homeUrl = `${publicUrl}/`;
app.get('/auth/login', async (request, reply): Promise<void> => {
if (getSessionUser(request) !== null) {
reply.redirect(homeUrl);
return;
}
const state = client.randomState();
const nonce = client.randomNonce();
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
request.session.set('txn', { state, nonce, codeVerifier });
reply.redirect(oidc.authorizationUrl({ redirectUri, state, nonce, codeChallenge }).href);
});
app.get('/auth/callback', async (request, reply): Promise<void> => {
const txn = request.session.get('txn');
request.session.set('txn', undefined);
if (txn === undefined) {
reply.redirect(`${homeUrl}?login=expired`);
return;
}
try {
const user = await oidc.handleCallback({
currentUrl: new URL(request.url, publicUrl),
state: txn.state,
nonce: txn.nonce,
codeVerifier: txn.codeVerifier,
});
request.session.set('user', {
sub: user.sub,
name: user.name,
expiresAt: Date.now() + app.appConfig.session.maxAgeSeconds * 1000,
});
} catch {
request.session.delete();
reply.redirect(`${homeUrl}?login=error`);
return;
}
reply.redirect(homeUrl);
});
app.post('/auth/logout', (request, reply): void => {
request.session.delete();
const endSession = oidc.endSessionUrl(homeUrl);
reply.redirect(endSession !== null ? endSession.href : homeUrl);
});
app.get('/auth/me', (request): MeResponse => {
const user = getSessionUser(request);
return user !== null ? { authenticated: true, name: user.name } : { authenticated: false };
});
done();
};

12
src/routes/health.ts Normal file
View file

@ -0,0 +1,12 @@
import type { FastifyPluginCallback } from 'fastify';
interface HealthResponse {
status: 'ok';
uptime: number;
}
/** `GET /health` — sonde de disponibilité, sans authentification. */
export const healthRoutes: FastifyPluginCallback = (app, _options, done) => {
app.get('/health', (): HealthResponse => ({ status: 'ok', uptime: process.uptime() }));
done();
};

98
src/routes/upload.ts Normal file
View file

@ -0,0 +1,98 @@
import type { FastifyPluginCallback } from 'fastify';
import type { DropTarget, DropTargets } from '../config.js';
import { ValidationError } from '../errors.js';
import { isAllowedMedia } from '../lib/media-types.js';
import { requireSession } from '../session.js';
import { saveFile, type StoredFile } from '../lib/storage.js';
const FIELD_NAME = 'file';
interface UploadResponse {
target: string;
storedName: string;
originalName: string;
size: number;
mimeType: string;
}
interface TargetSummary {
name: string;
label: string;
}
/** Résout la cible demandée (`?target=`), ou la première par défaut ; 400 si inconnue. */
function resolveTarget(targets: DropTargets, requested: unknown): DropTarget {
if (requested === undefined || requested === '') {
return targets[0];
}
if (typeof requested !== 'string') {
throw new ValidationError('Paramètre « target » invalide');
}
const match = targets.find((target) => target.name === requested);
if (match === undefined) {
throw new ValidationError(`Cible de dépôt inconnue : ${requested}`);
}
return match;
}
function toResponse(target: DropTarget, stored: StoredFile): UploadResponse {
return {
target: target.name,
storedName: stored.storedName,
originalName: stored.originalName,
size: stored.size,
mimeType: stored.mimeType,
};
}
/**
* - `GET /targets` — liste des cibles (nom + libellé), session requise
* - `POST /upload?target=<nom>` — dépôt d'un fichier multipart dans la cible choisie
*/
export const uploadRoutes: FastifyPluginCallback = (app, _options, done) => {
app.get('/targets', { preHandler: requireSession }, (): TargetSummary[] =>
app.appConfig.targets.map((target) => ({ name: target.name, label: target.label })),
);
app.post(
'/upload',
{ preHandler: requireSession },
async (request, reply): Promise<UploadResponse> => {
const target = resolveTarget(
app.appConfig.targets,
(request.query as { target?: unknown }).target,
);
if (!request.isMultipart()) {
throw new ValidationError('Requête multipart/form-data attendue');
}
const part = await request.file();
if (!part) {
throw new ValidationError(`Aucun fichier reçu dans le champ « ${FIELD_NAME} »`);
}
if (part.fieldname !== FIELD_NAME) {
part.file.resume();
throw new ValidationError(`Le fichier doit être envoyé dans le champ « ${FIELD_NAME} »`);
}
if (!isAllowedMedia(part.mimetype, app.appConfig.allowedMimeTypes)) {
part.file.resume();
throw new ValidationError(`Type de média non autorisé : ${part.mimetype}`);
}
const stored = await saveFile({
source: part.file,
originalName: part.filename,
mimeType: part.mimetype,
targetDir: target.dir,
maxFileSizeBytes: app.appConfig.maxFileSizeBytes,
});
reply.status(201);
return toResponse(target, stored);
},
);
done();
};

40
src/session.ts Normal file
View file

@ -0,0 +1,40 @@
import type { FastifyReply, FastifyRequest, HookHandlerDoneFunction } from 'fastify';
import { UnauthorizedError } from './errors.js';
/** Identité stockée dans le cookie de session après une connexion réussie. */
export interface SessionUser {
sub: string;
name: string;
/** Expiration absolue de la session, en millisecondes epoch. */
expiresAt: number;
}
/** État transitoire du flux OIDC, entre `/auth/login` et `/auth/callback`. */
export interface LoginTransaction {
state: string;
nonce: string;
codeVerifier: string;
}
/** Renvoie l'utilisateur de la session si elle est présente et non expirée, sinon `null`. */
export function getSessionUser(request: FastifyRequest): SessionUser | null {
const user = request.session.get('user');
if (user === undefined || typeof user.expiresAt !== 'number' || user.expiresAt <= Date.now()) {
return null;
}
return user;
}
/** Hook `preHandler` : rejette (401) toute requête sans session valide. */
export function requireSession(
request: FastifyRequest,
_reply: FastifyReply,
done: HookHandlerDoneFunction,
): void {
if (getSessionUser(request) === null) {
done(new UnauthorizedError('Authentification requise'));
return;
}
done();
}

98
tests/auth-flow.test.ts Normal file
View file

@ -0,0 +1,98 @@
import type { FastifyInstance } from 'fastify';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { buildApp } from '../src/app.js';
import {
login,
makeConfig,
makeTempDir,
oneTarget,
removeDir,
SESSION_COOKIE,
startStubIdp,
type StubIdp,
} from './helpers.js';
let idp: StubIdp;
let app: FastifyInstance;
let uploadDir: string;
beforeAll(async () => {
idp = await startStubIdp();
});
afterAll(async () => {
await idp.close();
});
beforeEach(async () => {
uploadDir = await makeTempDir();
app = await buildApp(
makeConfig({
targets: oneTarget(uploadDir),
oidc: {
issuer: idp.issuer,
clientId: idp.clientId,
clientSecret: idp.clientSecret,
scopes: ['openid', 'profile', 'email'],
},
}),
{ logger: false },
);
await app.ready();
});
afterEach(async () => {
await app.close();
await removeDir(uploadDir);
});
describe('flux d’authentification', () => {
it('GET /auth/login redirige vers l’issuer avec un défi PKCE et pose un cookie', async () => {
const res = await app.inject({ method: 'GET', url: '/auth/login' });
expect(res.statusCode).toBe(302);
const target = new URL(res.headers.location as string);
expect(target.origin).toBe(idp.issuer);
expect(target.searchParams.get('code_challenge')).toBeTruthy();
expect(target.searchParams.get('state')).toBeTruthy();
expect(res.cookies.some((c) => c.name === SESSION_COOKIE)).toBe(true);
});
it('GET /auth/me est anonyme par défaut, authentifié après login', async () => {
const anon = await app.inject({ method: 'GET', url: '/auth/me' });
expect(anon.json()).toEqual({ authenticated: false });
const cookie = await login(app, idp, { name: 'Alice' });
const me = await app.inject({
method: 'GET',
url: '/auth/me',
cookies: { [SESSION_COOKIE]: cookie },
});
expect(me.json()).toEqual({ authenticated: true, name: 'Alice' });
});
it('POST /auth/logout vide la session', async () => {
const cookie = await login(app, idp);
const out = await app.inject({
method: 'POST',
url: '/auth/logout',
cookies: { [SESSION_COOKIE]: cookie },
});
expect(out.statusCode).toBe(302);
const cleared = out.cookies.find((c) => c.name === SESSION_COOKIE);
const afterCookie = cleared ? cleared.value : cookie;
const me = await app.inject({
method: 'GET',
url: '/auth/me',
cookies: { [SESSION_COOKIE]: afterCookie },
});
expect(me.json()).toEqual({ authenticated: false });
});
it('GET /auth/callback sans transaction redirige avec ?login=expired', async () => {
const res = await app.inject({ method: 'GET', url: '/auth/callback?code=x&state=y' });
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe('http://localhost:3000/?login=expired');
});
});

149
tests/config.test.ts Normal file
View file

@ -0,0 +1,149 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { CONFIG_PATH_ENV, loadConfig, parseConfig } from '../src/config.js';
import { DEFAULT_ALLOWED_MIME } from '../src/lib/media-types.js';
import { ConfigError } from '../src/errors.js';
const KEY_B64 = Buffer.alloc(32, 1).toString('base64');
const BASE = {
publicUrl: 'http://localhost:3000',
targets: [{ name: 'photos', dir: './uploads/photos' }],
session: { key: KEY_B64 },
oidc: {
issuer: 'https://auth.exemple.fr/realms/demo',
clientId: 'mediadrop',
clientSecret: 's3cr3t',
},
};
describe('parseConfig', () => {
it('applique les valeurs par défaut', () => {
const config = parseConfig(structuredClone(BASE));
expect(config).toMatchObject({
port: 3000,
host: '0.0.0.0',
publicUrl: 'http://localhost:3000',
maxFileSizeBytes: 512 * 1024 * 1024,
logLevel: 'info',
});
expect(config.targets).toEqual([{ name: 'photos', label: 'photos', dir: './uploads/photos' }]);
expect(config.allowedMimeTypes).toBe(DEFAULT_ALLOWED_MIME);
expect(config.session.maxAgeSeconds).toBe(12 * 3600);
expect(config.session.key).toHaveLength(32);
expect(config.oidc).toEqual({
issuer: 'https://auth.exemple.fr/realms/demo',
clientId: 'mediadrop',
clientSecret: 's3cr3t',
scopes: ['openid', 'profile', 'email'],
});
});
it('rejette une racine ou un sous-objet non-objet', () => {
expect(() => parseConfig('nope')).toThrow(ConfigError);
expect(() => parseConfig({})).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, session: [] })).toThrow(ConfigError);
});
it('exige publicUrl, session.key et le bloc oidc complet', () => {
expect(() =>
parseConfig({ session: BASE.session, oidc: BASE.oidc, targets: BASE.targets }),
).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, session: {} })).toThrow(ConfigError);
expect(() =>
parseConfig({ ...BASE, oidc: { issuer: BASE.oidc.issuer, clientId: 'x' } }),
).toThrow(ConfigError);
});
it('valide les cibles de dépôt', () => {
expect(() => parseConfig({ ...BASE, targets: [] })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, targets: 'photos' })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, targets: [{ name: 'ok' }] })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, targets: [{ name: 'a/b', dir: './x' }] })).toThrow(
ConfigError,
);
expect(() =>
parseConfig({
...BASE,
targets: [
{ name: 'dup', dir: './a' },
{ name: 'dup', dir: './b' },
],
}),
).toThrow(ConfigError);
expect(
parseConfig({ ...BASE, targets: [{ name: 'x', label: 'X', dir: './x' }] }).targets[0],
).toEqual({ name: 'x', label: 'X', dir: './x' });
});
it('rejette une session.key qui ne fait pas 32 octets', () => {
expect(() =>
parseConfig({ ...BASE, session: { key: Buffer.alloc(16).toString('base64') } }),
).toThrow(ConfigError);
});
it('rejette un issuer / publicUrl non http(s) et normalise le slash final', () => {
expect(() => parseConfig({ ...BASE, publicUrl: 'pas-une-url' })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, oidc: { ...BASE.oidc, issuer: 'ftp://x.fr' } })).toThrow(
ConfigError,
);
expect(
parseConfig({ ...BASE, oidc: { ...BASE.oidc, issuer: 'https://x.fr/realm/' } }).oidc.issuer,
).toBe('https://x.fr/realm');
});
it('force le scope openid et lit maxAgeHours', () => {
expect(
parseConfig({ ...BASE, oidc: { ...BASE.oidc, scopes: ['profile'] } }).oidc.scopes,
).toEqual(['openid', 'profile']);
expect(
parseConfig({ ...BASE, session: { key: KEY_B64, maxAgeHours: 2 } }).session.maxAgeSeconds,
).toBe(7200);
});
it('valide port, maxFileSizeMb et logLevel', () => {
expect(() => parseConfig({ ...BASE, port: 3.5 })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, maxFileSizeMb: 0 })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, logLevel: 'verbose' })).toThrow(ConfigError);
});
it('interprète allowedMimeTypes', () => {
expect(
parseConfig({ ...BASE, allowedMimeTypes: ['image/png', 'IMAGE/WEBP'] }).allowedMimeTypes,
).toEqual(['image/png', 'image/webp']);
expect(parseConfig({ ...BASE, allowedMimeTypes: 'all' }).allowedMimeTypes).toBe('all');
expect(() => parseConfig({ ...BASE, allowedMimeTypes: 'image/png' })).toThrow(ConfigError);
});
});
describe('loadConfig', () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), 'mediadrop-cfg-'));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it('lit et valide le fichier désigné par la variable d’environnement', async () => {
const file = path.join(dir, 'app.json');
await writeFile(file, JSON.stringify(BASE));
expect(loadConfig({ [CONFIG_PATH_ENV]: file }).oidc.clientId).toBe('mediadrop');
});
it('lève ConfigError si le fichier est absent ou si le JSON est cassé', async () => {
expect(() => loadConfig({ [CONFIG_PATH_ENV]: path.join(dir, 'absent.json') })).toThrow(
ConfigError,
);
const broken = path.join(dir, 'broken.json');
await writeFile(broken, '{ oidc: ');
expect(() => loadConfig({ [CONFIG_PATH_ENV]: broken })).toThrow(ConfigError);
});
});

205
tests/helpers.ts Normal file
View file

@ -0,0 +1,205 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import Fastify, { type FastifyInstance } from 'fastify';
import { SignJWT, exportJWK, generateKeyPair } from 'jose';
import type { AppConfig, DropTargets } from '../src/config.js';
export const TEST_PUBLIC_URL = 'http://localhost:3000';
export const TEST_CLIENT_ID = 'mediadrop-test';
export const TEST_CLIENT_SECRET = 'test-client-secret';
export const SESSION_COOKIE = 'mediadrop_session';
const TEST_KID = 'test-key';
/** Clé de session déterministe (32 octets) pour @fastify/secure-session. */
export const TEST_SESSION_KEY_B64 = Buffer.alloc(32, 7).toString('base64');
/** Petit PNG 1x1 (signature valide) ; seul le type MIME annoncé compte côté serveur. */
export const TINY_PNG = Buffer.from(
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000' +
'000a49444154789c6360000000020001e221bc330000000049454e44ae426082',
'hex',
);
export async function makeTempDir(): Promise<string> {
return mkdtemp(path.join(tmpdir(), 'mediadrop-'));
}
export async function removeDir(dir: string): Promise<void> {
await rm(dir, { recursive: true, force: true });
}
/** Liste à une seule cible pointant vers `dir`. */
export function oneTarget(dir: string): DropTargets {
return [{ name: 'main', label: 'Principal', dir }];
}
export function makeConfig(overrides: Partial<AppConfig> = {}): AppConfig {
return {
port: 0,
host: '127.0.0.1',
publicUrl: TEST_PUBLIC_URL,
targets: oneTarget('./uploads'),
maxFileSizeBytes: 1024 * 1024,
allowedMimeTypes: ['image/png', 'image/jpeg'],
logLevel: 'silent',
session: { key: Buffer.alloc(32, 7), maxAgeSeconds: 3600 },
oidc: {
issuer: 'https://idp.test',
clientId: TEST_CLIENT_ID,
clientSecret: TEST_CLIENT_SECRET,
scopes: ['openid', 'profile', 'email'],
},
...overrides,
};
}
export interface NextIdToken {
nonce?: string;
sub?: string;
name?: string;
email?: string;
}
/** Fournisseur OIDC factice : `.well-known`, JWKS et `POST /token` signant un id_token RS256. */
export interface StubIdp {
issuer: string;
clientId: string;
clientSecret: string;
/** Claims du prochain id_token émis par `POST /token`. */
setNextIdToken: (claims: NextIdToken) => void;
close: () => Promise<void>;
}
export async function startStubIdp(): Promise<StubIdp> {
const { publicKey, privateKey } = await generateKeyPair('RS256', { extractable: true });
const publicJwk = { ...(await exportJWK(publicKey)), kid: TEST_KID, alg: 'RS256', use: 'sig' };
const jwks = { keys: [publicJwk] };
let next: Required<NextIdToken> = {
nonce: '',
sub: 'user-123',
name: 'Test User',
email: 'user@example.test',
};
const server = Fastify({ logger: false });
// openid-client POSTe le token request en x-www-form-urlencoded ; on accepte sans le parser.
server.addContentTypeParser(
'application/x-www-form-urlencoded',
{ parseAs: 'string' },
(_request, body, done) => {
done(null, body);
},
);
server.get('/jwks', () => jwks);
server.get('/.well-known/openid-configuration', (request) => {
const base = `http://${request.headers.host ?? '127.0.0.1'}`;
return {
issuer: base,
authorization_endpoint: `${base}/authorize`,
token_endpoint: `${base}/token`,
jwks_uri: `${base}/jwks`,
end_session_endpoint: `${base}/logout`,
userinfo_endpoint: `${base}/userinfo`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
code_challenge_methods_supported: ['S256'],
scopes_supported: ['openid', 'profile', 'email'],
};
});
server.post('/token', async (request) => {
const base = `http://${request.headers.host ?? '127.0.0.1'}`;
const idToken = await new SignJWT({
...(next.nonce.length > 0 ? { nonce: next.nonce } : {}),
name: next.name,
preferred_username: next.name,
email: next.email,
auth_time: Math.floor(Date.now() / 1000),
})
.setProtectedHeader({ alg: 'RS256', kid: TEST_KID })
.setIssuer(base)
.setSubject(next.sub)
.setAudience(TEST_CLIENT_ID)
.setIssuedAt()
.setExpirationTime('5m')
.sign(privateKey);
return {
access_token: 'stub-access-token',
token_type: 'Bearer',
expires_in: 300,
scope: 'openid profile email',
id_token: idToken,
};
});
await server.listen({ port: 0, host: '127.0.0.1' });
const address = server.server.address();
if (address === null || typeof address === 'string') {
await server.close();
throw new Error('stub IdP : adresse d’écoute inattendue');
}
return {
issuer: `http://127.0.0.1:${String(address.port)}`,
clientId: TEST_CLIENT_ID,
clientSecret: TEST_CLIENT_SECRET,
setNextIdToken(claims: NextIdToken): void {
next = {
nonce: '',
sub: 'user-123',
name: 'Test User',
email: 'user@example.test',
...claims,
};
},
close: () => server.close(),
};
}
/**
* Déroule le flux de connexion via `app.inject` et renvoie la valeur du cookie de
* session authentifié, à repasser dans `cookies` pour les requêtes protégées.
*/
export async function login(
app: FastifyInstance,
idp: StubIdp,
user: { sub?: string; name?: string } = {},
): Promise<string> {
const start = await app.inject({ method: 'GET', url: '/auth/login' });
const location = start.headers.location;
if (typeof location !== 'string') {
throw new Error(`login: /auth/login n'a pas redirigé (${String(start.statusCode)})`);
}
const authUrl = new URL(location);
const state = authUrl.searchParams.get('state') ?? '';
const nonce = authUrl.searchParams.get('nonce') ?? '';
const txnCookie = start.cookies.find((cookie) => cookie.name === SESSION_COOKIE);
if (txnCookie === undefined) {
throw new Error('login: cookie de transaction absent');
}
idp.setNextIdToken({ nonce, sub: user.sub ?? 'user-123', name: user.name ?? 'Test User' });
const callback = await app.inject({
method: 'GET',
url: `/auth/callback?code=stub-code&state=${encodeURIComponent(state)}`,
cookies: { [SESSION_COOKIE]: txnCookie.value },
});
const sessionCookie = callback.cookies.find((cookie) => cookie.name === SESSION_COOKIE);
if (sessionCookie === undefined || callback.headers.location !== `${TEST_PUBLIC_URL}/`) {
throw new Error(
`login: callback en échec (${String(callback.statusCode)} -> ${String(callback.headers.location)})`,
);
}
return sessionCookie.value;
}

30
tests/media-types.test.ts Normal file
View file

@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_ALLOWED_MIME, extensionForMime, isAllowedMedia } from '../src/lib/media-types.js';
describe('isAllowedMedia', () => {
it('accepte un type présent dans la liste blanche', () => {
expect(isAllowedMedia('image/png', DEFAULT_ALLOWED_MIME)).toBe(true);
expect(isAllowedMedia('video/mp4', DEFAULT_ALLOWED_MIME)).toBe(true);
});
it('refuse un type absent de la liste blanche', () => {
expect(isAllowedMedia('application/zip', DEFAULT_ALLOWED_MIME)).toBe(false);
expect(isAllowedMedia('image/png', ['image/jpeg'])).toBe(false);
});
it('accepte tout avec la politique "all"', () => {
expect(isAllowedMedia('application/x-msdownload', 'all')).toBe(true);
});
});
describe('extensionForMime', () => {
it('renvoie une extension sûre pour un type connu', () => {
expect(extensionForMime('image/jpeg')).toBe('.jpg');
expect(extensionForMime('video/quicktime')).toBe('.mov');
});
it('renvoie null pour un type inconnu', () => {
expect(extensionForMime('application/x-unknown')).toBeNull();
});
});

78
tests/oidc.test.ts Normal file
View file

@ -0,0 +1,78 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { UnauthorizedError } from '../src/errors.js';
import { createOidcClient, type OidcClient } from '../src/oidc.js';
import { makeConfig, startStubIdp, type StubIdp } from './helpers.js';
let idp: StubIdp;
let oidc: OidcClient;
const REDIRECT_URI = 'http://localhost:3000/auth/callback';
const CODE_VERIFIER = 'a'.repeat(64);
beforeAll(async () => {
idp = await startStubIdp();
oidc = await createOidcClient(
makeConfig({
oidc: {
issuer: idp.issuer,
clientId: idp.clientId,
clientSecret: idp.clientSecret,
scopes: ['openid', 'profile', 'email'],
},
}),
);
});
afterAll(async () => {
await idp.close();
});
describe('createOidcClient', () => {
it('découvre un issuer en http (stub) et expose le scope', () => {
expect(oidc.scope).toBe('openid profile email');
});
it('construit une URL d’autorisation PKCE', () => {
const url = oidc.authorizationUrl({
redirectUri: REDIRECT_URI,
state: 's1',
nonce: 'n1',
codeChallenge: 'challenge',
});
expect(url.origin).toBe(idp.issuer);
expect(url.searchParams.get('response_type')).toBe('code');
expect(url.searchParams.get('state')).toBe('s1');
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
expect(url.searchParams.get('redirect_uri')).toBe(REDIRECT_URI);
});
it('échange le code et renvoie l’identité', async () => {
idp.setNextIdToken({ nonce: 'n2', sub: 'zoe', name: 'Zoé' });
const user = await oidc.handleCallback({
currentUrl: new URL(`${REDIRECT_URI}?code=abc&state=s2`),
state: 's2',
nonce: 'n2',
codeVerifier: CODE_VERIFIER,
});
expect(user).toEqual({ sub: 'zoe', name: 'Zoé' });
});
it('rejette un nonce qui ne correspond pas', async () => {
idp.setNextIdToken({ nonce: 'autre', sub: 'zoe' });
await expect(
oidc.handleCallback({
currentUrl: new URL(`${REDIRECT_URI}?code=abc&state=s3`),
state: 's3',
nonce: 'n3',
codeVerifier: CODE_VERIFIER,
}),
).rejects.toBeInstanceOf(UnauthorizedError);
});
it('expose une URL de déconnexion RP-initiated', () => {
const url = oidc.endSessionUrl('http://localhost:3000/');
expect(url?.origin).toBe(idp.issuer);
expect(url?.searchParams.get('post_logout_redirect_uri')).toBe('http://localhost:3000/');
});
});

45
tests/session.test.ts Normal file
View file

@ -0,0 +1,45 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { describe, expect, it, vi } from 'vitest';
import { UnauthorizedError } from '../src/errors.js';
import { getSessionUser, requireSession, type SessionUser } from '../src/session.js';
function requestWithSessionUser(user: SessionUser | undefined): FastifyRequest {
return {
session: { get: (key: string) => (key === 'user' ? user : undefined) },
} as unknown as FastifyRequest;
}
const reply = {} as FastifyReply;
describe('getSessionUser', () => {
it('renvoie null quand il n’y a pas de session', () => {
expect(getSessionUser(requestWithSessionUser(undefined))).toBeNull();
});
it('renvoie null quand la session est expirée', () => {
const user: SessionUser = { sub: 'u1', name: 'Alice', expiresAt: Date.now() - 1000 };
expect(getSessionUser(requestWithSessionUser(user))).toBeNull();
});
it('renvoie l’utilisateur quand la session est valide', () => {
const user: SessionUser = { sub: 'u1', name: 'Alice', expiresAt: Date.now() + 60_000 };
expect(getSessionUser(requestWithSessionUser(user))).toEqual(user);
});
});
describe('requireSession', () => {
it('appelle done(UnauthorizedError) sans session', () => {
const done = vi.fn();
requireSession(requestWithSessionUser(undefined), reply, done);
expect(done).toHaveBeenCalledTimes(1);
expect(done.mock.calls[0]?.[0]).toBeInstanceOf(UnauthorizedError);
});
it('appelle done() avec une session valide', () => {
const done = vi.fn();
const user: SessionUser = { sub: 'u1', name: 'Alice', expiresAt: Date.now() + 60_000 };
requireSession(requestWithSessionUser(user), reply, done);
expect(done).toHaveBeenCalledWith();
});
});

73
tests/storage.test.ts Normal file
View file

@ -0,0 +1,73 @@
import { readFile, readdir } from 'node:fs/promises';
import { Readable } from 'node:stream';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { saveFile } from '../src/lib/storage.js';
import { PayloadTooLargeError } from '../src/errors.js';
import { makeTempDir, removeDir, TINY_PNG } from './helpers.js';
let uploadDir: string;
beforeEach(async () => {
uploadDir = await makeTempDir();
});
afterEach(async () => {
await removeDir(uploadDir);
});
function params(maxFileSizeBytes = 1024 * 1024): { targetDir: string; maxFileSizeBytes: number } {
return { targetDir: uploadDir, maxFileSizeBytes };
}
describe('saveFile', () => {
it('écrit le fichier et renvoie ses métadonnées', async () => {
const stored = await saveFile({
source: Readable.from(TINY_PNG),
originalName: 'photo.png',
mimeType: 'image/png',
...params(),
});
expect(stored.size).toBe(TINY_PNG.length);
expect(stored.storedName).toMatch(/^photo-\d+-[0-9a-f]{8}\.png$/);
expect(await readFile(stored.path)).toEqual(TINY_PNG);
});
it('produit un nom sans séparateur de chemin même pour un nom hostile', async () => {
const stored = await saveFile({
source: Readable.from(TINY_PNG),
originalName: '../../etc/passwd',
mimeType: 'image/png',
...params(),
});
expect(stored.storedName).not.toMatch(/[/\\]/);
expect(stored.storedName).not.toContain('..');
expect(stored.storedName).toMatch(/^passwd-\d+-[0-9a-f]{8}\.png$/);
});
it('assainit les noms avec espaces et accents', async () => {
const stored = await saveFile({
source: Readable.from(TINY_PNG),
originalName: 'mon joli cliché.PNG',
mimeType: 'image/png',
...params(),
});
expect(stored.storedName).toMatch(/^[A-Za-z0-9_-]+-\d+-[0-9a-f]{8}\.png$/);
});
it('rejette au-delà de la taille maximale sans laisser de fichier partiel', async () => {
await expect(
saveFile({
source: Readable.from(Buffer.alloc(5000, 1)),
originalName: 'big.png',
mimeType: 'image/png',
...params(1000),
}),
).rejects.toBeInstanceOf(PayloadTooLargeError);
expect(await readdir(uploadDir)).toHaveLength(0);
});
});

173
tests/upload.test.ts Normal file
View file

@ -0,0 +1,173 @@
import { readdir } from 'node:fs/promises';
import type { FastifyInstance, LightMyRequestResponse } from 'fastify';
import FormData from 'form-data';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { buildApp } from '../src/app.js';
import {
login,
makeConfig,
makeTempDir,
removeDir,
SESSION_COOKIE,
startStubIdp,
type StubIdp,
TINY_PNG,
} from './helpers.js';
let idp: StubIdp;
let session: string;
let app: FastifyInstance;
let dirA: string;
let dirB: string;
beforeAll(async () => {
idp = await startStubIdp();
});
afterAll(async () => {
await idp.close();
});
beforeEach(async () => {
dirA = await makeTempDir();
dirB = await makeTempDir();
app = await buildApp(
makeConfig({
targets: [
{ name: 'a', label: 'Dossier A', dir: dirA },
{ name: 'b', label: 'Dossier B', dir: dirB },
],
maxFileSizeBytes: 2000,
oidc: {
issuer: idp.issuer,
clientId: idp.clientId,
clientSecret: idp.clientSecret,
scopes: ['openid', 'profile', 'email'],
},
}),
{ logger: false },
);
await app.ready();
session = await login(app, idp);
});
afterEach(async () => {
await app.close();
await removeDir(dirA);
await removeDir(dirB);
});
const postUpload = (
form: FormData,
opts: { cookie?: string; target?: string } = {},
): Promise<LightMyRequestResponse> =>
app.inject({
method: 'POST',
url:
opts.target !== undefined ? `/upload?target=${encodeURIComponent(opts.target)}` : '/upload',
payload: form,
headers: form.getHeaders(),
...(opts.cookie !== undefined ? { cookies: { [SESSION_COOKIE]: opts.cookie } } : {}),
});
function pngForm(field = 'file', body: Buffer = TINY_PNG): FormData {
const form = new FormData();
form.append(field, body, { filename: 'photo.png', contentType: 'image/png' });
return form;
}
describe('GET /health', () => {
it('répond 200 sans authentification', async () => {
const res = await app.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(200);
expect(res.json<{ status: string }>().status).toBe('ok');
});
});
describe('GET /targets', () => {
it('exige une session', async () => {
const res = await app.inject({ method: 'GET', url: '/targets' });
expect(res.statusCode).toBe(401);
});
it('renvoie les cibles (nom + libellé, sans chemin)', async () => {
const res = await app.inject({
method: 'GET',
url: '/targets',
cookies: { [SESSION_COOKIE]: session },
});
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual([
{ name: 'a', label: 'Dossier A' },
{ name: 'b', label: 'Dossier B' },
]);
});
});
describe('POST /upload', () => {
it('refuse sans session (401)', async () => {
const res = await postUpload(pngForm());
expect(res.statusCode).toBe(401);
});
it('refuse un cookie de session illisible (401)', async () => {
const res = await postUpload(pngForm(), { cookie: 'cookie-bidon' });
expect(res.statusCode).toBe(401);
});
it('dépose dans la cible par défaut (première) quand ?target est absent', async () => {
const res = await postUpload(pngForm(), { cookie: session });
expect(res.statusCode).toBe(201);
const body = res.json<{ target: string; size: number; mimeType: string }>();
expect(body.target).toBe('a');
expect(body.mimeType).toBe('image/png');
expect(body.size).toBe(TINY_PNG.length);
expect(await readdir(dirA)).toHaveLength(1);
expect(await readdir(dirB)).toHaveLength(0);
});
it('dépose dans la cible demandée via ?target=', async () => {
const res = await postUpload(pngForm(), { cookie: session, target: 'b' });
expect(res.statusCode).toBe(201);
expect(res.json<{ target: string }>().target).toBe('b');
expect(await readdir(dirA)).toHaveLength(0);
expect(await readdir(dirB)).toHaveLength(1);
});
it('refuse une cible inconnue (400)', async () => {
const res = await postUpload(pngForm(), { cookie: session, target: 'inconnu' });
expect(res.statusCode).toBe(400);
expect(await readdir(dirA)).toHaveLength(0);
});
it('refuse un mauvais nom de champ (400)', async () => {
const res = await postUpload(pngForm('autre'), { cookie: session });
expect(res.statusCode).toBe(400);
});
it('refuse un type de média non autorisé (400)', async () => {
const form = new FormData();
form.append('file', Buffer.from('PK\x03\x04'), {
filename: 'archive.zip',
contentType: 'application/zip',
});
const res = await postUpload(form, { cookie: session });
expect(res.statusCode).toBe(400);
});
it('refuse un fichier trop volumineux (413)', async () => {
const res = await postUpload(pngForm('file', Buffer.alloc(5000, 1)), { cookie: session });
expect(res.statusCode).toBe(413);
});
it('refuse une requête sans fichier (400)', async () => {
const form = new FormData();
form.append('title', 'juste du texte');
const res = await postUpload(form, { cookie: session });
expect(res.statusCode).toBe(400);
});
});

10
tsconfig.build.json Normal file
View file

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"exclude": ["tests", "**/*.test.ts"]
}

26
tsconfig.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"moduleDetection": "force",
"types": ["node"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"verbatimModuleSyntax": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"sourceMap": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"]
}

9
vitest.config.ts Normal file
View file

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['tests/**/*.test.ts'],
clearMocks: true,
},
});