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:
commit
fe8bd26bb7
37 changed files with 7683 additions and 0 deletions
6
.env.example
Normal file
6
.env.example
Normal 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
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
* text=auto eol=lf
|
||||
*.png binary
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.env
|
||||
config.json
|
||||
uploads/
|
||||
*.log
|
||||
7
.prettierignore
Normal file
7
.prettierignore
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
dist
|
||||
coverage
|
||||
node_modules
|
||||
uploads
|
||||
package-lock.json
|
||||
Claude.md
|
||||
CLAUDE.md
|
||||
5
.prettierrc.json
Normal file
5
.prettierrc.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100
|
||||
}
|
||||
43
Claude.md
Normal file
43
Claude.md
Normal 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
171
README.md
Normal 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
23
config.example.json
Normal 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
83
config.schema.json
Normal 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
36
eslint.config.mjs
Normal 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
4921
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
42
package.json
Normal file
42
package.json
Normal 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
381
public/index.html
Normal 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
82
src/app.ts
Normal 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
280
src/config.ts
Normal 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
63
src/error-handler.ts
Normal 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
51
src/errors.ts
Normal 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
19
src/fastify.d.ts
vendored
Normal 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
50
src/index.ts
Normal 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
58
src/lib/media-types.ts
Normal 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
116
src/lib/storage.ts
Normal 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
112
src/oidc.ts
Normal 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
79
src/routes/auth.ts
Normal 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
12
src/routes/health.ts
Normal 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
98
src/routes/upload.ts
Normal 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
40
src/session.ts
Normal 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
98
tests/auth-flow.test.ts
Normal 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
149
tests/config.test.ts
Normal 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
205
tests/helpers.ts
Normal 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
30
tests/media-types.test.ts
Normal 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
78
tests/oidc.test.ts
Normal 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
45
tests/session.test.ts
Normal 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
73
tests/storage.test.ts
Normal 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
173
tests/upload.test.ts
Normal 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
10
tsconfig.build.json
Normal 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
26
tsconfig.json
Normal 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
9
vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
clearMocks: true,
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue