diff --git a/README.md b/README.md index 6ee048a..f18f43d 100644 --- a/README.md +++ b/README.md @@ -165,22 +165,22 @@ Prévoir aussi l'espace disque sur le volume de la cible. Un seul fichier JSON (voir [`config.example.json`](config.example.json), schéma dans [`config.schema.json`](config.schema.json)). Chemin résolu depuis le dossier courant. -| Clé JSON | Défaut | Rôle | -| ---------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------- | -| `port` | `3000` | Port d'écoute | -| `host` | `0.0.0.0` | Interface d'écoute | -| `publicUrl` | — | **Obligatoire.** URL externe de base (redirect_uri, post-logout) | -| `targets` | — | **Obligatoire.** Liste `[{ name, label?, dir, allowDirectories? }]` (≥ 1) ; la 1ʳᵉ est la cible par défaut | -| `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) | -| `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é) | +| Clé JSON | Défaut | Rôle | +| ---------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------- | +| `port` | `3000` | Port d'écoute | +| `host` | `0.0.0.0` | Interface d'écoute | +| `publicUrl` | — | **Obligatoire.** URL externe de base (redirect_uri, post-logout) | +| `targets` | — | **Obligatoire.** Liste `[{ name, label?, dir, allowDirectories? }]` (≥ 1) ; la 1ʳᵉ est la cible par défaut | +| `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) | +| `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 | +| `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`). diff --git a/config.schema.json b/config.schema.json index 29edb4a..66e9bc9 100644 --- a/config.schema.json +++ b/config.schema.json @@ -44,10 +44,14 @@ "default": "info" }, "allowedMimeTypes": { - "description": "Liste blanche de types MIME, ou \"all\". Absent => liste média par défaut.", + "description": "\"all\", ou liste de types exacts et/ou jokers de préfixe (\"video/*\", \"*/*\", \"*\"). Absent => liste média par défaut.", "oneOf": [ { "const": "all" }, - { "type": "array", "items": { "type": "string" }, "minItems": 1 } + { + "type": "array", + "minItems": 1, + "items": { "type": "string", "pattern": "^(\\*|\\*/\\*|[^/*\\s]+/(\\*|[^/*\\s]+))$" } + } ] }, "session": { diff --git a/src/config.ts b/src/config.ts index 3522c96..7ef214f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -150,6 +150,15 @@ function parseMimePolicy(value: unknown): MimePolicy { if (list.length === 0) { throw new ConfigError('allowedMimeTypes ne contient aucun type MIME valide'); } + for (const entry of list) { + const valid = entry === '*' || entry === '*/*' || /^[^/*\s]+\/(\*|[^/*\s]+)$/.test(entry); + if (!valid) { + throw new ConfigError( + `allowedMimeTypes : entrée invalide « ${entry} » ` + + '(formes acceptées : "type/sous-type", "type/*", "*/*", "*")', + ); + } + } return list; } diff --git a/src/lib/media-types.ts b/src/lib/media-types.ts index ea5df18..5705c54 100644 --- a/src/lib/media-types.ts +++ b/src/lib/media-types.ts @@ -38,15 +38,30 @@ const MIME_TO_EXTENSION: Readonly> = { 'audio/mp4': '.m4a', }; -/** Politique de filtrage : une liste blanche explicite, ou `'all'` pour tout accepter. */ +/** + * Politique de filtrage : `'all'`, ou une liste d'entrées exactes (ex. `video/mp4`) + * et/ou de jokers de préfixe (ex. `video/` suivi d'une étoile), l'étoile seule + * autorisant tout. + */ export type MimePolicy = readonly string[] | 'all'; +function matchesEntry(mimeType: string, entry: string): boolean { + if (entry === '*' || entry === '*/*') { + return true; + } + if (entry.endsWith('/*')) { + return mimeType.startsWith(entry.slice(0, -1)); + } + return entry === mimeType; +} + /** 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); + const normalized = mimeType.toLowerCase(); + return policy.some((entry) => matchesEntry(normalized, entry)); } /** diff --git a/tests/config.test.ts b/tests/config.test.ts index 5cdbb82..421af35 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -128,6 +128,15 @@ describe('parseConfig', () => { expect(parseConfig({ ...BASE, allowedMimeTypes: 'all' }).allowedMimeTypes).toBe('all'); expect(() => parseConfig({ ...BASE, allowedMimeTypes: 'image/png' })).toThrow(ConfigError); }); + + it('accepte les jokers valides, rejette les jokers malformés', () => { + expect( + parseConfig({ ...BASE, allowedMimeTypes: ['video/*', 'image/png', '*/*'] }).allowedMimeTypes, + ).toEqual(['video/*', 'image/png', '*/*']); + expect(() => parseConfig({ ...BASE, allowedMimeTypes: ['video/**'] })).toThrow(ConfigError); + expect(() => parseConfig({ ...BASE, allowedMimeTypes: ['vi*deo/mp4'] })).toThrow(ConfigError); + expect(() => parseConfig({ ...BASE, allowedMimeTypes: ['video/'] })).toThrow(ConfigError); + }); }); describe('loadConfig', () => { diff --git a/tests/media-types.test.ts b/tests/media-types.test.ts index eedc0cc..18cec08 100644 --- a/tests/media-types.test.ts +++ b/tests/media-types.test.ts @@ -16,6 +16,15 @@ describe('isAllowedMedia', () => { it('accepte tout avec la politique "all"', () => { expect(isAllowedMedia('application/x-msdownload', 'all')).toBe(true); }); + + it('gère les jokers de préfixe', () => { + expect(isAllowedMedia('video/x-matroska', ['video/*'])).toBe(true); + expect(isAllowedMedia('VIDEO/QUICKTIME', ['video/*'])).toBe(true); + expect(isAllowedMedia('image/png', ['video/*'])).toBe(false); + expect(isAllowedMedia('image/png', ['video/*', 'image/png'])).toBe(true); + expect(isAllowedMedia('application/octet-stream', ['*/*'])).toBe(true); + expect(isAllowedMedia('n-importe/quoi', ['*'])).toBe(true); + }); }); describe('extensionForMime', () => {