L'upload de fichiers est une fonctionnalité incontournable dans la plupart des applications web modernes : avatars utilisateurs, images d'articles, documents PDF, médias divers. Dans l'écosystème NestJS, la gestion des fichiers s'appuie sur Multer, le middleware de référence pour Express. Dans ce tutoriel, nous allons construire un module d'upload complet, sécurisé et prêt pour la production, en nous basant sur l'UploadModule du projet site-voyance.
Prérequis
- Une application NestJS fonctionnelle (version 10+)
- TypeORM configuré avec une base de données
- Notions de base sur les modules, contrôleurs et services NestJS

Installation des dépendances
NestJS embarque déjà @nestjs/platform-express qui expose Multer. Il faut néanmoins installer les types et quelques utilitaires complémentaires :
npm install multer uuid
npm install -D @types/multer @types/uuid
npm install file-type
La librairie uuid nous servira à générer des noms de fichiers uniques, tandis que file-type permettra de valider le type réel d'un fichier en lisant ses magic bytes, une protection essentielle contre les attaques par usurpation d'extension.
L'entité UploadedFile
Avant tout, nous avons besoin d'une entité pour stocker les métadonnées en base. Elle conserve le nom original, le nom stocké, le type MIME, la taille, le chemin physique, l'URL publique, ainsi qu'une catégorie et un texte alternatif :
// Entité UploadedFile (extrait simplifié)
@Entity('uploaded_files')
export class UploadedFile {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'original_name' })
originalName: string;
@Column()
filename: string;
@Column()
mimetype: string;
@Column()
size: number;
@Column()
path: string;
@Column()
url: string;
@Column({ nullable: true })
category?: string;
@Column({ nullable: true })
alt?: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}
Configurer le MulterModule
La configuration de Multer se fait au niveau du module. Nous utilisons registerAsync pour injecter le ConfigService et lire les paramètres depuis la configuration de l'application : destination de stockage, taille maximale autorisée, et filtre sur les types MIME.
import { MulterModule } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { v4 as uuidv4 } from 'uuid';
const ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'];
const MIME_TO_EXT: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'image/avif': '.avif',
};
@Module({
imports: [
TypeOrmModule.forFeature([UploadedFile]),
MulterModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
storage: diskStorage({
destination: configService.get<string>('upload.destination'),
filename: (req, file, callback) => {
// Sécurité : on génère un nom unique basé sur un UUID
// et on impose une extension dérivée du MIME, pas de l'extension originale
const ext = MIME_TO_EXT[file.mimetype] || '.bin';
const uniqueName = `${uuidv4()}${ext}`;
callback(null, uniqueName);
},
}),
limits: {
fileSize: configService.get<number>('upload.maxFileSize'),
},
fileFilter: (req, file, callback) => {
if (ALLOWED_MIMES.includes(file.mimetype)) {
callback(null, true);
} else {
callback(
new Error('Type de fichier non autorisé. Seules les images sont acceptées.'),
false,
);
}
},
}),
}),
],
controllers: [UploadController],
providers: [UploadService],
exports: [UploadService],
})
export class UploadModule {}
Trois protections clés dans cette configuration
- destination : dossier physique où sont écrits les fichiers, paramétré via
ConfigServicepour faciliter les déploiements multi-environnements. - filename : on remplace systématiquement le nom original par un UUID. Cela évite les collisions de noms et neutralise tout caractère exotique ou tentative de path traversal (par exemple
../../etc/passwd). - fileFilter et limits : refus immédiat des MIME non autorisés et plafond de taille pour éviter la saturation du disque.
Le contrôleur d'upload
Côté contrôleur, NestJS fournit deux décorateurs essentiels : @UseInterceptors(FileInterceptor('file')) pour un fichier unique et @UseInterceptors(FilesInterceptor('files', 10)) pour un upload multiple (le second argument étant le nombre maximal de fichiers).
@Post()
@Roles(UserRole.ADMIN)
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
async uploadFile(
@UploadedFile() file: Express.Multer.File,
@Body('category') category?: string,
@Body('alt') alt?: string,
): Promise<ApiResponseDto<UploadedFileEntity>> {
if (!file) {
throw new BadRequestException('No file provided');
}
const uploadedFile = await this.uploadService.saveFile(file, category, alt);
return ApiResponseDto.success(uploadedFile, 'File uploaded successfully');
}
Le décorateur @UploadedFile() injecte l'objet Express.Multer.File qui contient toutes les informations utiles : originalname, filename, mimetype, size et path. On notera l'usage du décorateur @Roles(UserRole.ADMIN) qui restreint l'endpoint aux administrateurs : l'upload est une opération sensible qu'on n'expose jamais publiquement sans contrôle d'accès.
Upload multiple
Pour gérer plusieurs fichiers, on remplace simplement le décorateur d'injection par @UploadedFiles() qui retourne un tableau :
@Post('multiple')
@Roles(UserRole.ADMIN)
@UseInterceptors(FilesInterceptor('files', 10))
async uploadFiles(
@UploadedFiles() files: Express.Multer.File[],
@Body('category') category?: string,
): Promise<ApiResponseDto<UploadedFileEntity[]>> {
if (!files || files.length === 0) {
throw new BadRequestException('No files provided');
}
const uploadedFiles = await this.uploadService.saveFiles(files, category);
return ApiResponseDto.success(uploadedFiles, 'Files uploaded successfully');
}
Le service : validation approfondie et persistance
Le filtre fileFilter de Multer ne vérifie que le MIME déclaré par le client, qui peut très bien mentir. Pour une vraie sécurité, on inspecte les magic bytes du fichier après écriture sur disque, grâce à la librairie file-type :
const ALLOWED_REAL_MIMES = new Set([
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif',
]);
@Injectable()
export class UploadService {
constructor(
@InjectRepository(UploadedFile)
private readonly uploadedFileRepository: Repository<UploadedFile>,
private readonly configService: ConfigService,
) {}
private async validateMagicBytes(filePath: string): Promise<string> {
const FileType = await import('file-type');
const buffer = await readFile(filePath);
const result = await FileType.fromBuffer(buffer);
if (!result || !ALLOWED_REAL_MIMES.has(result.mime)) {
// En cas de fraude : on supprime le fichier pour ne rien laisser sur disque
await unlink(filePath).catch(() => {});
throw new BadRequestException(
'Le contenu du fichier ne correspond pas à un format image autorisé.',
);
}
return result.mime;
}
async saveFile(file: Express.Multer.File, category?: string, alt?: string) {
const realMime = await this.validateMagicBytes(file.path);
const uploadedFile = this.uploadedFileRepository.create({
originalName: file.originalname,
filename: file.filename,
mimetype: realMime, // on stocke le MIME réel, pas celui annoncé
size: file.size,
path: file.path,
url: `/uploads/${file.filename}`,
category,
alt,
});
return this.uploadedFileRepository.save(uploadedFile);
}
}
Cette double vérification — fileFilter côté Multer puis validateMagicBytes côté service — constitue une défense en profondeur. Un fichier .exe renommé en .jpg passerait le premier contrôle mais serait détecté et supprimé par le second.
Alternative : ParseFilePipe et validateurs intégrés
NestJS propose depuis la version 9 un ParseFilePipe qui permet de chaîner plusieurs validateurs directement dans la signature du contrôleur, sans toucher au service. C'est une approche plus déclarative pour les cas simples :
@Post('avatar')
@UseInterceptors(FileInterceptor('file'))
async uploadAvatar(
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }), // 5 Mo
new FileTypeValidator({ fileType: /^image\/(jpeg|png|webp)$/ }),
],
}),
)
file: Express.Multer.File,
) {
return this.uploadService.saveFile(file);
}
Attention : FileTypeValidator se base sur le MIME envoyé par le client. Pour les contextes critiques, il faut le compléter par une vérification des magic bytes, comme dans notre service.
Suppression et gestion du cycle de vie
Lorsqu'un fichier est supprimé, il faut nettoyer à la fois la base de données et le disque, sans quoi le système accumulerait des fichiers orphelins :
async delete(id: string): Promise<void> {
const file = await this.findOne(id);
try {
await unlink(file.path);
} catch (error) {
this.logger.error(`Failed to delete physical file: ${file.path}`, error);
}
await this.uploadedFileRepository.remove(file);
}
On encapsule la suppression physique dans un try/catch pour que la base reste cohérente même si le fichier a déjà disparu du disque.
Récapitulatif des bonnes pratiques de sécurité
- Restreindre l'accès avec un guard d'authentification et le décorateur
@Roles. - Renommer les fichiers avec un UUID pour éviter collisions et path traversal.
- Forcer l'extension à partir du MIME déclaré, jamais à partir du nom original.
- Limiter la taille via
limits.fileSizedans Multer. - Filtrer les MIME avec une liste blanche dans
fileFilter. - Vérifier les magic bytes après écriture pour détecter les fraudes.
- Stocker hors de la racine web et exposer les fichiers via une route contrôlée si possible.
Conclusion
Nous avons construit un module d'upload complet pour NestJS : configuration Multer avec stockage disque, génération de noms uniques, filtrage des types MIME, gestion des uploads simples et multiples, validation approfondie via les magic bytes et persistance des métadonnées en base via TypeORM. Cette architecture combine la souplesse des décorateurs NestJS avec une approche défensive de la sécurité, indispensable dès que l'on accepte des données arbitraires venant du client.
Pour aller plus loin, vous pouvez explorer le stockage cloud (S3, Cloudinary) en implémentant un StorageEngine personnalisé, ajouter un traitement d'image automatique avec sharp (redimensionnement, conversion en WebP), ou encore mettre en place un système de quotas par utilisateur. La documentation officielle de NestJS sur les file uploads reste une excellente référence pour approfondir.

Commentaires