Types MIME configurables par cible d'upload
`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:
parent
60d7ba1c39
commit
615d0fd45a
10 changed files with 185 additions and 61 deletions
73
README.md
73
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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]+))$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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') } }),
|
||||||
|
|
|
||||||
|
|
@ -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[]],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue