Types MIME configurables par cible d'upload
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 13s

`targets[].allowedMimeTypes` : chaque cible peut définir sa propre
politique (mêmes formes : "all", types exacts, jokers "image/*"…).
Absente => la cible hérite du `allowedMimeTypes` global, qui devient
la valeur par défaut. Résolu au parsing : chaque `DropTarget` porte
toujours une politique.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-10 22:22:30 +02:00
parent 60d7ba1c39
commit 615d0fd45a
10 changed files with 185 additions and 61 deletions

View file

@ -108,21 +108,21 @@ pouvant écrire les paquets de l'organisation.
## Endpoints ## Endpoints
| Route | Auth | Rôle | | Route | Auth | Rôle |
| -------------------------------------- | ------- | ------------------------------------------------------------------ | | -------------------------------------- | ------- | ------------------------------------------------------------------------ |
| `GET /` | non | Page de dépôt | | `GET /` | non | Page de dépôt |
| `GET /health` | non | Sonde : `{ "status": "ok", "uptime": 12.3 }` | | `GET /health` | non | Sonde : `{ "status": "ok", "uptime": 12.3 }` |
| `GET /auth/login` | non | Démarre le flux OIDC (redirige vers le fournisseur) | | `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 `/` | | `GET /auth/callback` | non | Fin du flux : ouvre la session, redirige vers `/` |
| `POST /auth/logout` | session | Ferme la session (+ déconnexion RP si disponible) | | `POST /auth/logout` | session | Ferme la session (+ déconnexion RP si disponible) |
| `GET /auth/me` | non | `{ "authenticated": bool, "name"?: string }` | | `GET /auth/me` | non | `{ "authenticated": bool, "name"?: string }` |
| `GET /config` | non | Réglages client : `{ "chunkPoolSize": number }` | | `GET /config` | non | Réglages client : `{ "chunkPoolSize": number }` |
| `GET /targets` | session | Cibles : `[{ "name", "label", "allowDirectories" }]` (sans chemin) | | `GET /targets` | session | Cibles : `[{ "name", "label", "allowDirectories", "allowedMimeTypes" }]` |
| `POST /upload?target=<nom>[&path=<p>]` | session | Dépôt direct d'un fichier `multipart/form-data`, champ `file` | | `POST /upload?target=<nom>[&path=<p>]` | session | Dépôt direct d'un fichier `multipart/form-data`, champ `file` |
| `POST /upload/init` | session | Démarre un upload fragmenté → `{ id, chunkSize, chunkCount }` | | `POST /upload/init` | session | Démarre un upload fragmenté → `{ id, chunkSize, chunkCount }` |
| `PUT /upload/:id/:index` | session | Envoie un morceau (corps brut `application/octet-stream`) | | `PUT /upload/:id/:index` | session | Envoie un morceau (corps brut `application/octet-stream`) |
| `POST /upload/:id/complete` | session | Assemble et finalise → `201` + métadonnées | | `POST /upload/:id/complete` | session | Assemble et finalise → `201` + métadonnées |
| `DELETE /upload/:id` | session | Abandonne (supprime le fichier partiel) | | `DELETE /upload/:id` | session | Abandonne (supprime le fichier partiel) |
`POST /upload` — `?target=<nom>` choisit la cible (absent → **première** cible). `POST /upload` — `?target=<nom>` choisit la cible (absent → **première** cible).
`?path=<chemin relatif>` (URL-encodé) dépose dans un **sous-dossier** recréé sous `?path=<chemin relatif>` (URL-encodé) dépose dans un **sous-dossier** recréé sous
@ -187,24 +187,25 @@ Prévoir l'espace disque sur le volume de la cible.
Un seul fichier JSON (voir [`config.example.json`](config.example.json), schéma dans 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. [`config.schema.json`](config.schema.json)). Chemin résolu depuis le dossier courant.
| Clé JSON | Défaut | Rôle | | Clé JSON | Défaut | Rôle |
| ---------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------- | | ---------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| `port` | `3000` | Port d'écoute | | `port` | `3000` | Port d'écoute |
| `host` | `0.0.0.0` | Interface d'écoute | | `host` | `0.0.0.0` | Interface d'écoute |
| `publicUrl` | — | **Obligatoire.** URL externe de base (redirect_uri, post-logout) | | `publicUrl` | — | **Obligatoire.** URL externe de base (redirect_uri, post-logout) |
| `targets` | — | **Obligatoire.** Liste `[{ name, label?, dir, allowDirectories? }]` (≥ 1) ; la 1ʳᵉ est la cible par défaut | | `targets` | — | **Obligatoire.** Liste `[{ name, label?, dir, allowDirectories?, allowedMimeTypes? }]` (≥ 1) ; la 1ʳᵉ est la cible par défaut |
| `targets[].allowDirectories` | `false` | Autoriser le dépôt de dossiers (arborescence recréée sous `dir`) | | `targets[].allowDirectories` | `false` | Autoriser le dépôt de dossiers (arborescence recréée sous `dir`) |
| `maxFileSizeMb` | `512` | Taille maximale par fichier, en Mo (`10240` = 10 Go) | | `targets[].allowedMimeTypes` | _(global)_ | Politique MIME **propre à cette cible** ; absent => hérite du `allowedMimeTypes` global. Mêmes formes |
| `logLevel` | `info` | `fatal` \| `error` \| `warn` \| `info` \| `debug` \| `trace` \| `silent` | | `maxFileSizeMb` | `512` | Taille maximale par fichier, en Mo (`10240` = 10 Go) |
| `chunkPoolSize` | `30` | Morceaux en vol simultanés (upload fragmenté), répartis entre fichiers actifs ; `1`–`256`. Utile en HTTP/2 | | `logLevel` | `info` | `fatal` \| `error` \| `warn` \| `info` \| `debug` \| `trace` \| `silent` |
| `allowedMimeTypes` | _(liste média)_ | `"all"`, ou tableau de types exacts et/ou jokers de préfixe (`"video/*"`, `"*"`) ; absent => liste par défaut | | `chunkPoolSize` | `30` | Morceaux en vol simultanés (upload fragmenté), répartis entre fichiers actifs ; `1`–`256`. Utile en HTTP/2 |
| `webhooks` | `[]` | Liste de webhooks POST notifiés à chaque upload finalisé et/ou chaque échec (voir §Notifications) | | `allowedMimeTypes` | _(liste média)_ | Politique MIME **par défaut** des cibles. `"all"`, ou tableau de types exacts et/ou jokers (`"video/*"`, `"*"`) |
| `session.key` | — | **Obligatoire.** 32 octets base64 (`openssl rand -base64 32`) | | `webhooks` | `[]` | Liste de webhooks POST notifiés à chaque upload finalisé et/ou chaque échec (voir §Notifications) |
| `session.maxAgeHours` | `12` | Durée de vie absolue de la session | | `session.key` | — | **Obligatoire.** 32 octets base64 (`openssl rand -base64 32`) |
| `oidc.issuer` | — | **Obligatoire.** Issuer OIDC | | `session.maxAgeHours` | `12` | Durée de vie absolue de la session |
| `oidc.clientId` | — | **Obligatoire.** `client_id` du client confidentiel | | `oidc.issuer` | — | **Obligatoire.** Issuer OIDC |
| `oidc.clientSecret` | — | **Obligatoire.** Secret du client confidentiel | | `oidc.clientId` | — | **Obligatoire.** `client_id` du client confidentiel |
| `oidc.scopes` | `["openid","profile","email"]` | Scopes demandés (`openid` forcé) | | `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`). Seule variable d'environnement : `MEDIADROP_CONFIG` (chemin du fichier, défaut `./config.json`).
@ -292,9 +293,9 @@ const ok =
par refresh token — reconnexion à l'expiration. par refresh token — reconnexion à l'expiration.
- Le **type MIME est celui annoncé par le client**, non vérifié via les octets de - 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`). signature. Durcissement possible avec une bibliothèque de détection (`file-type`).
- Les limites (`maxFileSizeMb`, `allowedMimeTypes`) sont **globales** : pas encore de - `allowedMimeTypes` se règle **par cible** (`targets[].allowedMimeTypes`), avec repli
réglage par cible. Le `name` de cible fourni par le client sert uniquement à sur la valeur globale. `maxFileSizeMb` reste global. Le `name` de cible fourni par le
retrouver une entrée de `targets` (jamais concaténé à un chemin). 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 - 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. production et régler `publicUrl` sur l'URL publique.
- `.env`, `config.json` et `uploads/` sont ignorés par Git. - `.env`, `config.json` et `uploads/` sont ignorés par Git.

View file

@ -4,8 +4,20 @@
"host": "0.0.0.0", "host": "0.0.0.0",
"publicUrl": "http://localhost:3000", "publicUrl": "http://localhost:3000",
"targets": [ "targets": [
{ "name": "photos", "label": "Photos", "dir": "./uploads/photos" }, {
{ "name": "videos", "label": "Vidéos", "dir": "./uploads/videos", "allowDirectories": true } "name": "photos",
"label": "Photos",
"dir": "./uploads/photos",
"allowedMimeTypes": ["image/*"]
},
{
"name": "videos",
"label": "Vidéos",
"dir": "./uploads/videos",
"allowDirectories": true,
"allowedMimeTypes": ["video/*"]
},
{ "name": "divers", "label": "Divers", "dir": "./uploads/divers" }
], ],
"maxFileSizeMb": 512, "maxFileSizeMb": 512,
"logLevel": "info", "logLevel": "info",

View file

@ -34,6 +34,20 @@
"type": "boolean", "type": "boolean",
"default": false, "default": false,
"description": "Autoriser le dépôt de dossiers (arborescence recréée sous dir)." "description": "Autoriser le dépôt de dossiers (arborescence recréée sous dir)."
},
"allowedMimeTypes": {
"description": "Types MIME acceptés par cette cible. Absent => hérite du allowedMimeTypes global. Mêmes formes : \"all\", ou liste de types exacts et/ou jokers (\"image/*\", \"*/*\", \"*\").",
"oneOf": [
{ "const": "all" },
{
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"pattern": "^(\\*|\\*/\\*|[^/*\\s]+/(\\*|[^/*\\s]+))$"
}
}
]
} }
} }
} }

View file

@ -327,6 +327,13 @@
currentTarget = target ? target.name : null; currentTarget = target ? target.name : null;
currentTargetAllowsDirs = Boolean(target && target.allowDirectories); currentTargetAllowsDirs = Boolean(target && target.allowDirectories);
dirRow.hidden = !authenticated || !currentTargetAllowsDirs; dirRow.hidden = !authenticated || !currentTargetAllowsDirs;
// Filtre le sélecteur natif de fichiers selon les types acceptés par la cible
// (le serveur reste l'autorité ; l'attribut ne bloque pas le glisser-déposer).
const mimes = target && target.allowedMimeTypes;
picker.accept =
Array.isArray(mimes) && !mimes.includes('*') && !mimes.includes('*/*')
? mimes.join(',')
: '';
} }
function loadTargets() { function loadTargets() {

View file

@ -55,6 +55,11 @@ export interface DropTarget {
* (`?path=...`), l'arborescence est recréée sous `dir`. Défaut : `false`. * (`?path=...`), l'arborescence est recréée sous `dir`. Défaut : `false`.
*/ */
allowDirectories: boolean; allowDirectories: boolean;
/**
* Types MIME acceptés par cette cible. Absent dans le JSON -> hérite de la
* valeur globale `allowedMimeTypes`. Toujours résolu après parsing.
*/
allowedMimeTypes: MimePolicy;
} }
/** Liste non vide de cibles de dépôt ; la première est la cible par défaut. */ /** Liste non vide de cibles de dépôt ; la première est la cible par défaut. */
@ -68,6 +73,7 @@ export interface AppConfig {
publicUrl: string; publicUrl: string;
targets: DropTargets; targets: DropTargets;
maxFileSizeBytes: number; maxFileSizeBytes: number;
/** Politique MIME par défaut, héritée par les cibles sans `allowedMimeTypes` propre. */
allowedMimeTypes: MimePolicy; allowedMimeTypes: MimePolicy;
logLevel: LogLevel; logLevel: LogLevel;
/** /**
@ -176,7 +182,7 @@ function parseLogLevel(value: unknown): LogLevel {
return value as LogLevel; return value as LogLevel;
} }
function parseMimePolicy(value: unknown): MimePolicy { function parseMimePolicy(value: unknown, label = 'allowedMimeTypes'): MimePolicy {
if (value === undefined || value === null) { if (value === undefined || value === null) {
return DEFAULT_ALLOWED_MIME; return DEFAULT_ALLOWED_MIME;
} }
@ -187,17 +193,17 @@ function parseMimePolicy(value: unknown): MimePolicy {
!Array.isArray(value) || !Array.isArray(value) ||
!value.every((entry): entry is string => typeof entry === 'string') !value.every((entry): entry is string => typeof entry === 'string')
) { ) {
throw new ConfigError('allowedMimeTypes doit être "all" ou un tableau de chaînes'); throw new ConfigError(`${label} doit être "all" ou un tableau de chaînes`);
} }
const list = value.map((entry) => entry.trim().toLowerCase()).filter((entry) => entry.length > 0); const list = value.map((entry) => entry.trim().toLowerCase()).filter((entry) => entry.length > 0);
if (list.length === 0) { if (list.length === 0) {
throw new ConfigError('allowedMimeTypes ne contient aucun type MIME valide'); throw new ConfigError(`${label} ne contient aucun type MIME valide`);
} }
for (const entry of list) { for (const entry of list) {
const valid = entry === '*' || entry === '*/*' || /^[^/*\s]+\/(\*|[^/*\s]+)$/.test(entry); const valid = entry === '*' || entry === '*/*' || /^[^/*\s]+\/(\*|[^/*\s]+)$/.test(entry);
if (!valid) { if (!valid) {
throw new ConfigError( throw new ConfigError(
`allowedMimeTypes : entrée invalide « ${entry} » ` + `${label} : entrée invalide « ${entry} » ` +
'(formes acceptées : "type/sous-type", "type/*", "*/*", "*")', '(formes acceptées : "type/sous-type", "type/*", "*/*", "*")',
); );
} }
@ -348,7 +354,7 @@ function parseWebhooks(value: unknown): readonly WebhookConfig[] {
}); });
} }
function parseTargets(value: unknown): DropTargets { function parseTargets(value: unknown, defaultMimePolicy: MimePolicy): DropTargets {
if (!Array.isArray(value) || value.length === 0) { if (!Array.isArray(value) || value.length === 0) {
throw new ConfigError('targets doit être un tableau non vide'); throw new ConfigError('targets doit être un tableau non vide');
} }
@ -376,6 +382,10 @@ function parseTargets(value: unknown): DropTargets {
false, false,
`targets[${String(index)}].allowDirectories`, `targets[${String(index)}].allowDirectories`,
), ),
allowedMimeTypes:
raw.allowedMimeTypes === undefined || raw.allowedMimeTypes === null
? defaultMimePolicy
: parseMimePolicy(raw.allowedMimeTypes, `targets[${String(index)}].allowedMimeTypes`),
}; };
}; };
@ -389,17 +399,18 @@ function parseTargets(value: unknown): DropTargets {
*/ */
export function parseConfig(raw: unknown): AppConfig { export function parseConfig(raw: unknown): AppConfig {
const root = asRecord(raw, 'La configuration'); const root = asRecord(raw, 'La configuration');
const allowedMimeTypes = parseMimePolicy(root.allowedMimeTypes);
return { return {
port: optionalPositiveInt(root.port, DEFAULTS.port, 'port'), port: optionalPositiveInt(root.port, DEFAULTS.port, 'port'),
host: optionalString(root.host, 'host') ?? DEFAULTS.host, host: optionalString(root.host, 'host') ?? DEFAULTS.host,
publicUrl: normalizeHttpUrl(root.publicUrl, 'publicUrl'), publicUrl: normalizeHttpUrl(root.publicUrl, 'publicUrl'),
targets: parseTargets(root.targets), targets: parseTargets(root.targets, allowedMimeTypes),
maxFileSizeBytes: maxFileSizeBytes:
optionalPositiveInt(root.maxFileSizeMb, DEFAULTS.maxFileSizeMb, 'maxFileSizeMb') * optionalPositiveInt(root.maxFileSizeMb, DEFAULTS.maxFileSizeMb, 'maxFileSizeMb') *
1024 * 1024 *
1024, 1024,
allowedMimeTypes: parseMimePolicy(root.allowedMimeTypes), allowedMimeTypes,
logLevel: parseLogLevel(root.logLevel), logLevel: parseLogLevel(root.logLevel),
chunkPoolSize: parseChunkPoolSize(root.chunkPoolSize), chunkPoolSize: parseChunkPoolSize(root.chunkPoolSize),
webhooks: parseWebhooks(root.webhooks), webhooks: parseWebhooks(root.webhooks),

View file

@ -88,7 +88,7 @@ export const chunkedUploadRoutes: FastifyPluginCallback = (app, _options, done)
if (totalSize > cfg.maxFileSizeBytes) { if (totalSize > cfg.maxFileSizeBytes) {
throw new PayloadTooLargeError(); throw new PayloadTooLargeError();
} }
if (!isAllowedMedia(mimeType, cfg.allowedMimeTypes)) { if (!isAllowedMedia(mimeType, target.allowedMimeTypes)) {
throw new ValidationError(`Type de média non autorisé : ${mimeType}`); throw new ValidationError(`Type de média non autorisé : ${mimeType}`);
} }

View file

@ -2,7 +2,7 @@ import type { FastifyPluginCallback } from 'fastify';
import type { DropTarget } from '../config.js'; import type { DropTarget } from '../config.js';
import { ValidationError } from '../errors.js'; import { ValidationError } from '../errors.js';
import { isAllowedMedia } from '../lib/media-types.js'; import { isAllowedMedia, type MimePolicy } from '../lib/media-types.js';
import { resolvePlacement, resolveTarget } from '../lib/targets.js'; import { resolvePlacement, resolveTarget } from '../lib/targets.js';
import { getSessionUser, requireSession } from '../session.js'; import { getSessionUser, requireSession } from '../session.js';
import { saveFile, type StoredFile } from '../lib/storage.js'; import { saveFile, type StoredFile } from '../lib/storage.js';
@ -21,6 +21,8 @@ interface TargetSummary {
name: string; name: string;
label: string; label: string;
allowDirectories: boolean; allowDirectories: boolean;
/** `"all"`, ou la liste des types/jokers acceptés par cette cible. */
allowedMimeTypes: MimePolicy;
} }
interface ClientConfig { interface ClientConfig {
@ -45,7 +47,7 @@ function toResponse(target: DropTarget, stored: StoredFile): UploadResponse {
/** /**
* - `GET /config` — réglages client non sensibles (taille du pool de morceaux) * - `GET /config` — réglages client non sensibles (taille du pool de morceaux)
* - `GET /targets` — liste des cibles (nom, libellé, dépôt de dossier autorisé) * - `GET /targets` — cibles : nom, libellé, dépôt de dossier autorisé, types MIME acceptés
* - `POST /upload?target=<nom>[&path=<chemin relatif>]` — dépôt d'un fichier multipart * - `POST /upload?target=<nom>[&path=<chemin relatif>]` — dépôt d'un fichier multipart
*/ */
export const uploadRoutes: FastifyPluginCallback = (app, _options, done) => { export const uploadRoutes: FastifyPluginCallback = (app, _options, done) => {
@ -56,6 +58,7 @@ export const uploadRoutes: FastifyPluginCallback = (app, _options, done) => {
name: target.name, name: target.name,
label: target.label, label: target.label,
allowDirectories: target.allowDirectories, allowDirectories: target.allowDirectories,
allowedMimeTypes: target.allowedMimeTypes,
})), })),
); );
@ -79,7 +82,7 @@ export const uploadRoutes: FastifyPluginCallback = (app, _options, done) => {
part.file.resume(); part.file.resume();
throw new ValidationError(`Le fichier doit être envoyé dans le champ « ${FIELD_NAME} »`); throw new ValidationError(`Le fichier doit être envoyé dans le champ « ${FIELD_NAME} »`);
} }
if (!isAllowedMedia(part.mimetype, app.appConfig.allowedMimeTypes)) { if (!isAllowedMedia(part.mimetype, target.allowedMimeTypes)) {
part.file.resume(); part.file.resume();
throw new ValidationError(`Type de média non autorisé : ${part.mimetype}`); throw new ValidationError(`Type de média non autorisé : ${part.mimetype}`);
} }

View file

@ -34,8 +34,15 @@ describe('parseConfig', () => {
chunkPoolSize: 30, chunkPoolSize: 30,
}); });
expect(config.targets).toEqual([ expect(config.targets).toEqual([
{ name: 'photos', label: 'photos', dir: './uploads/photos', allowDirectories: false }, {
name: 'photos',
label: 'photos',
dir: './uploads/photos',
allowDirectories: false,
allowedMimeTypes: DEFAULT_ALLOWED_MIME,
},
]); ]);
expect(config.targets[0]?.allowedMimeTypes).toBe(DEFAULT_ALLOWED_MIME);
expect(config.allowedMimeTypes).toBe(DEFAULT_ALLOWED_MIME); expect(config.allowedMimeTypes).toBe(DEFAULT_ALLOWED_MIME);
expect(config.session.maxAgeSeconds).toBe(12 * 3600); expect(config.session.maxAgeSeconds).toBe(12 * 3600);
expect(config.session.key).toHaveLength(32); expect(config.session.key).toHaveLength(32);
@ -84,13 +91,37 @@ describe('parseConfig', () => {
).toThrow(ConfigError); ).toThrow(ConfigError);
expect( expect(
parseConfig({ ...BASE, targets: [{ name: 'x', label: 'X', dir: './x' }] }).targets[0], parseConfig({ ...BASE, targets: [{ name: 'x', label: 'X', dir: './x' }] }).targets[0],
).toEqual({ name: 'x', label: 'X', dir: './x', allowDirectories: false }); ).toEqual({
name: 'x',
label: 'X',
dir: './x',
allowDirectories: false,
allowedMimeTypes: DEFAULT_ALLOWED_MIME,
});
expect( expect(
parseConfig({ ...BASE, targets: [{ name: 'x', dir: './x', allowDirectories: true }] }) parseConfig({ ...BASE, targets: [{ name: 'x', dir: './x', allowDirectories: true }] })
.targets[0].allowDirectories, .targets[0].allowDirectories,
).toBe(true); ).toBe(true);
}); });
it('allowedMimeTypes par cible : hérite du global, ou le surcharge', () => {
const config = parseConfig({
...BASE,
allowedMimeTypes: ['image/png'],
targets: [
{ name: 'photos', dir: './a' },
{ name: 'videos', dir: './b', allowedMimeTypes: ['video/*'] },
{ name: 'tout', dir: './c', allowedMimeTypes: 'all' },
],
});
expect(config.targets[0].allowedMimeTypes).toEqual(['image/png']); // hérité
expect(config.targets[1]?.allowedMimeTypes).toEqual(['video/*']); // surchargé
expect(config.targets[2]?.allowedMimeTypes).toBe('all');
expect(() =>
parseConfig({ ...BASE, targets: [{ name: 'x', dir: './x', allowedMimeTypes: ['bad**'] }] }),
).toThrow(ConfigError);
});
it('rejette une session.key qui ne fait pas 32 octets', () => { it('rejette une session.key qui ne fait pas 32 octets', () => {
expect(() => expect(() =>
parseConfig({ ...BASE, session: { key: Buffer.alloc(16).toString('base64') } }), parseConfig({ ...BASE, session: { key: Buffer.alloc(16).toString('base64') } }),

View file

@ -5,7 +5,8 @@ import path from 'node:path';
import Fastify, { type FastifyInstance } from 'fastify'; import Fastify, { type FastifyInstance } from 'fastify';
import { SignJWT, exportJWK, generateKeyPair } from 'jose'; import { SignJWT, exportJWK, generateKeyPair } from 'jose';
import type { AppConfig, DropTargets } from '../src/config.js'; import type { AppConfig, DropTarget } from '../src/config.js';
import type { MimePolicy } from '../src/lib/media-types.js';
export const TEST_PUBLIC_URL = 'http://localhost:3000'; export const TEST_PUBLIC_URL = 'http://localhost:3000';
export const TEST_CLIENT_ID = 'mediadrop-test'; export const TEST_CLIENT_ID = 'mediadrop-test';
@ -31,19 +32,36 @@ export async function removeDir(dir: string): Promise<void> {
await rm(dir, { recursive: true, force: true }); await rm(dir, { recursive: true, force: true });
} }
/** Cible de test : `name` et `dir` requis, le reste prend des valeurs par défaut. */
export type TestTarget = Pick<DropTarget, 'name' | 'dir'> & Partial<DropTarget>;
export interface ConfigOverrides extends Partial<Omit<AppConfig, 'targets'>> {
targets?: readonly TestTarget[];
}
const DEFAULT_TEST_MIME: MimePolicy = ['image/png', 'image/jpeg'];
/** Liste à une seule cible pointant vers `dir`. */ /** Liste à une seule cible pointant vers `dir`. */
export function oneTarget(dir: string, allowDirectories = false): DropTargets { export function oneTarget(dir: string, allowDirectories = false): readonly TestTarget[] {
return [{ name: 'main', label: 'Principal', dir, allowDirectories }]; return [{ name: 'main', label: 'Principal', dir, allowDirectories }];
} }
export function makeConfig(overrides: Partial<AppConfig> = {}): AppConfig { export function makeConfig(overrides: ConfigOverrides = {}): AppConfig {
const { targets, ...rest } = overrides;
const allowedMimeTypes = rest.allowedMimeTypes ?? DEFAULT_TEST_MIME;
const resolved = (targets ?? oneTarget('./uploads')).map((target): DropTarget => ({
name: target.name,
label: target.label ?? target.name,
dir: target.dir,
allowDirectories: target.allowDirectories ?? false,
allowedMimeTypes: target.allowedMimeTypes ?? allowedMimeTypes,
}));
return { return {
port: 0, port: 0,
host: '127.0.0.1', host: '127.0.0.1',
publicUrl: TEST_PUBLIC_URL, publicUrl: TEST_PUBLIC_URL,
targets: oneTarget('./uploads'),
maxFileSizeBytes: 1024 * 1024, maxFileSizeBytes: 1024 * 1024,
allowedMimeTypes: ['image/png', 'image/jpeg'],
logLevel: 'silent', logLevel: 'silent',
chunkPoolSize: 30, chunkPoolSize: 30,
webhooks: [], webhooks: [],
@ -54,7 +72,9 @@ export function makeConfig(overrides: Partial<AppConfig> = {}): AppConfig {
clientSecret: TEST_CLIENT_SECRET, clientSecret: TEST_CLIENT_SECRET,
scopes: ['openid', 'profile', 'email'], scopes: ['openid', 'profile', 'email'],
}, },
...overrides, ...rest,
allowedMimeTypes,
targets: resolved as [DropTarget, ...DropTarget[]],
}; };
} }

View file

@ -49,7 +49,13 @@ beforeEach(async () => {
makeConfig({ makeConfig({
targets: [ targets: [
{ name: 'a', label: 'Dossier A', dir: dirA, allowDirectories: true }, { name: 'a', label: 'Dossier A', dir: dirA, allowDirectories: true },
{ name: 'b', label: 'Dossier B', dir: dirB, allowDirectories: false }, {
name: 'b',
label: 'Dossier B',
dir: dirB,
allowDirectories: false,
allowedMimeTypes: 'all',
},
], ],
maxFileSizeBytes: 2000, maxFileSizeBytes: 2000,
oidc: oidcOverride(idp), oidc: oidcOverride(idp),
@ -121,7 +127,7 @@ describe('GET /targets', () => {
expect(res.statusCode).toBe(401); expect(res.statusCode).toBe(401);
}); });
it('renvoie nom, libellé et allowDirectories (sans chemin disque)', async () => { it('renvoie nom, libellé, allowDirectories et allowedMimeTypes (sans chemin disque)', async () => {
const res = await app.inject({ const res = await app.inject({
method: 'GET', method: 'GET',
url: '/targets', url: '/targets',
@ -129,8 +135,13 @@ describe('GET /targets', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.json()).toEqual([ expect(res.json()).toEqual([
{ name: 'a', label: 'Dossier A', allowDirectories: true }, {
{ name: 'b', label: 'Dossier B', allowDirectories: false }, name: 'a',
label: 'Dossier A',
allowDirectories: true,
allowedMimeTypes: ['image/png', 'image/jpeg'],
},
{ name: 'b', label: 'Dossier B', allowDirectories: false, allowedMimeTypes: 'all' },
]); ]);
}); });
}); });
@ -185,6 +196,20 @@ describe('POST /upload', () => {
expect(res.statusCode).toBe(400); expect(res.statusCode).toBe(400);
}); });
it('applique la politique MIME propre à chaque cible', async () => {
const zip = (): FormData => {
const form = new FormData();
form.append('file', Buffer.from('PK\x03\x04'), {
filename: 'archive.zip',
contentType: 'application/zip',
});
return form;
};
// « a » n'accepte que les images ; « b » accepte tout.
expect((await postUpload(zip(), { cookie: session, target: 'a' })).statusCode).toBe(400);
expect((await postUpload(zip(), { cookie: session, target: 'b' })).statusCode).toBe(201);
});
it('refuse un fichier trop volumineux (413)', async () => { it('refuse un fichier trop volumineux (413)', async () => {
const res = await postUpload(pngForm('file', Buffer.alloc(5000, 1)), { cookie: session }); const res = await postUpload(pngForm('file', Buffer.alloc(5000, 1)), { cookie: session });
expect(res.statusCode).toBe(413); expect(res.statusCode).toBe(413);