🚨 ¡Nueva review! ✨ Mi ratón favorito para programar: el Logitech MX Master 3S . ¡Échale un ojo! 👀

File Upload y Streaming en NestJS

Serie NestJS #16 : Multer, FileInterceptor, @UploadedFile, validación, almacenamiento, StreamableFile y descarga de archivos

Escrito por domin el 14 de abril de 2026

Vamos con el post número 16 de la serie NestJS y último del bloque de Funcionalidades Avanzadas, uno ya tiene ganas de acabar la serie! Después de Exception Filters (post 13), Interceptors (post 14) y serialización (post 15), cerramos el bloque con algo que seguramente vamos a necesitar en nuestra api que es subir y descargar ficheros.

Avatares de usuario, documentos PDF, imágenes de productos, exports CSV… La subida de archivos tiene sus propios retos como validación de tipos, límites de tamaño, almacenamiento, seguridad, y cómo servir los archivos de vuelta sin reventar la memoria del servidor.

Como en cada post de la serie vamos a repasar toda la base que traemos: Docker (post 1), controllers (post 2), DI (post 3), módulos (post 4), middleware (post 5), validación (post 6), TypeORM (posts 7-9), seguridad (posts 10-12), Exception Filters (post 13), Interceptors (post 14) y serialización (post 15).

EA, al lío.

Diagrama de subida de archivos a una API NestJS con Multer y streaming de descarga.

1. ¿Cómo funcionan los file uploads en HTTP?

Los archivos se envían como , no como JSON. El body se divide en partes separadas por un boundary:

POST /users/avatar HTTP/1.1
Content-Type: multipart/form-data; boundary=----formBoundary

------formBoundary
Content-Disposition: form-data; name="file"; filename="avatar.jpg"
Content-Type: image/jpeg

(datos binarios del archivo)
------formBoundary--

NestJS usa para parsear este formato. Multer extrae los archivos y los pone a disposición de tu handler.


2. Instalación

Multer ya viene con @nestjs/platform-express, pero necesitas los tipos:

Instalación de tipos de Multer 0 / 1
$
Pulsa para ejecutar el siguiente comando

Y con eso ya instalas multer como dependencia de @nestjs/platform-express.


3. Subir un solo archivo: FileInterceptor

El caso más básico es un endpoint que recibe un archivo:

// src/users/users.controller.ts
import { Controller, Post, UseInterceptors, UploadedFile, Param, ParseUUIDPipe } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
    constructor(private readonly usersService: UsersService) {}

    @Post(':id/avatar')
    @UseInterceptors(FileInterceptor('file'))
    uploadAvatar(
        @Param('id', ParseUUIDPipe) id: string,
        @UploadedFile() file: Express.Multer.File
    ): Promise<{ url: string }> {
        return this.usersService.updateAvatar(id, file);
    }
}

Amo a desglosarlo:

El objeto Express.Multer.File

interface File {
    fieldname: string; // Nombre del campo: 'file'
    originalname: string; // Nombre original: 'avatar.jpg'
    encoding: string; // Encoding: '7bit'
    mimetype: string; // MIME type: 'image/jpeg'
    size: number; // Tamaño en bytes: 245678
    buffer: Buffer; // Los bytes del archivo (con memoryStorage)
    destination: string; // Carpeta destino (con diskStorage)
    filename: string; // Nombre generado (con diskStorage)
    path: string; // Ruta completa (con diskStorage)
}

4. Validación de archivos con ParseFilePipe

NestJS tiene un pipe dedicado para validar archivos. Puedes validar tipo MIME, tamaño máximo y si el archivo es requerido:

import {
    Post,
    UseInterceptors,
    UploadedFile,
    ParseFilePipe,
    MaxFileSizeValidator,
    FileTypeValidator,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';

@Post(':id/avatar')
@UseInterceptors(FileInterceptor('file'))
uploadAvatar(
    @Param('id', ParseUUIDPipe) id: string,
    @UploadedFile(
        new ParseFilePipe({
            validators: [
                new MaxFileSizeValidator({
                    maxSize: 5 * 1024 * 1024, // 5MB
                    message: 'El archivo no puede superar 5MB',
                }),
                new FileTypeValidator({
                    fileType: /^image\/(jpeg|png|webp)$/,
                }),
            ],
        }),
    )
    file: Express.Multer.File,
): Promise<{ url: string }> {
    return this.usersService.updateAvatar(id, file);
}

Si el archivo no pasa la validación, NestJS lanza BadRequestException automáticamente antes de que se ejecute el handler. El Exception Filter del post 13 lo atrapa y devuelve una respuesta limpia.

Validadores disponibles

ValidadorQué validaEjemplo
MaxFileSizeValidatorTamaño máximo en bytes

maxSize: 5 * 1024 * 1024 (5MB)

FileTypeValidatorMIME type (string o regex)fileType: /^image/(jpeg|png|webp)$/
Custom validatorLo que tú quierasDimensiones, contenido, magic bytes…

Archivo opcional

Por defecto, ParseFilePipe requiere que el archivo exista. Si es opcional:

@UploadedFile(
    new ParseFilePipe({
        fileIsRequired: false, // El archivo es opcional
        validators: [
            new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }),
        ],
    }),
)
file?: Express.Multer.File,

5. Validador custom: verificar magic bytes

El FileTypeValidator verifica el mimetype que envía el cliente, pero el cliente puede mentir. Un atacante puede enviar un .exe con mimetype: 'image/jpeg'. La solución para estos casos es verificar los magic bytes :

// src/common/validators/file-magic-bytes.validator.ts
import { FileValidator } from '@nestjs/common';

interface FileMagicBytesOptions {
    allowedTypes: Record<string, number[]>;
}

export class FileMagicBytesValidator extends FileValidator<FileMagicBytesOptions> {
    buildErrorMessage(): string {
        const types = Object.keys(this.validationOptions.allowedTypes).join(', ');
        return `El archivo no es un tipo válido. Tipos permitidos: ${types}`;
    }

    isValid(file?: Express.Multer.File): boolean {
        if (!file?.buffer) return false;

        const { allowedTypes } = this.validationOptions;

        return Object.values(allowedTypes).some((magicBytes) =>
            magicBytes.every((byte, index) => file.buffer[index] === byte)
        );
    }
}

// Magic bytes de formatos comunes
export const IMAGE_MAGIC_BYTES: Record<string, number[]> = {
    jpeg: [0xff, 0xd8, 0xff],
    png: [0x89, 0x50, 0x4e, 0x47],
    webp: [0x52, 0x49, 0x46, 0x46], // "RIFF"
    gif: [0x47, 0x49, 0x46], // "GIF"
};

export const PDF_MAGIC_BYTES: Record<string, number[]> = {
    pdf: [0x25, 0x50, 0x44, 0x46], // "%PDF"
};

Uso:

@Post(':id/avatar')
@UseInterceptors(FileInterceptor('file'))
uploadAvatar(
    @Param('id', ParseUUIDPipe) id: string,
    @UploadedFile(
        new ParseFilePipe({
            validators: [
                new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }),
                new FileTypeValidator({ fileType: /^image\/(jpeg|png|webp)$/ }),
                new FileMagicBytesValidator({
                    allowedTypes: IMAGE_MAGIC_BYTES,
                }),
            ],
        }),
    )
    file: Express.Multer.File,
): Promise<{ url: string }> {
    return this.usersService.updateAvatar(id, file);
}

Ahora se validan tres capas: tamaño, MIME type y magic bytes. Un atacante tendría que falsificar las tres cosas para colar un archivo malicioso.


6. Subir múltiples archivos

6.1. FilesInterceptor: múltiples archivos del mismo campo

import { FilesInterceptor } from '@nestjs/platform-express';
import { UploadedFiles } from '@nestjs/common';

@Post(':id/photos')
@UseInterceptors(FilesInterceptor('photos', 10)) // máximo 10 archivos
uploadPhotos(
    @Param('id', ParseUUIDPipe) id: string,
    @UploadedFiles() files: Express.Multer.File[],
): Promise<{ urls: string[] }> {
    return this.usersService.uploadPhotos(id, files);
}

El segundo argumento de FilesInterceptor es el número máximo de archivos. Si el cliente envía más, Multer lanza un error.

6.2. FileFieldsInterceptor: archivos de campos diferentes

import { FileFieldsInterceptor } from '@nestjs/platform-express';

@Post('product')
@UseInterceptors(
    FileFieldsInterceptor([
        { name: 'thumbnail', maxCount: 1 },
        { name: 'gallery', maxCount: 5 },
    ]),
)
createProduct(
    @Body() createProductDto: CreateProductDto,
    @UploadedFiles()
    files: {
        thumbnail?: Express.Multer.File[];
        gallery?: Express.Multer.File[];
    },
): Promise<Product> {
    const thumbnail = files.thumbnail?.[0];
    const gallery = files.gallery ?? [];
    return this.productsService.create(createProductDto, thumbnail, gallery);
}

Fíjate que puedes enviar archivos y datos del body en la misma petición. Multer parsea los archivos y NestJS parsea el body como si fuera un @Body() normal.

6.3. AnyFilesInterceptor: cualquier campo

import { AnyFilesInterceptor } from '@nestjs/platform-express';

@Post('upload')
@UseInterceptors(AnyFilesInterceptor())
uploadAny(@UploadedFiles() files: Express.Multer.File[]): { count: number } {
    return { count: files.length };
}

Acepta archivos de cualquier campo. Esto es útil para formularios dinámicos, pero es menos seguro porque no controlas qué campos llegan.


7. Almacenamiento: memoryStorage vs diskStorage

Multer soporta dos modos de almacenamiento:

💾 memoryStorage (por defecto)

El archivo se guarda en memoria como Buffer. Accedes con file.buffer. Rápido pero consume RAM. Ideal para archivos pequeños o para subir directamente a cloud storage.

📂 diskStorage

El archivo se guarda en disco. Accedes con file.path. No consume RAM. Ideal para archivos grandes o cuando necesitas procesarlos después.

7.1. diskStorage con nombres únicos

// src/common/config/multer.config.ts
import { diskStorage } from 'multer';
import { extname, join } from 'path';
import { existsSync, mkdirSync } from 'fs';
import { randomUUID } from 'crypto';

export const createMulterDiskStorage = (destination: string) => {
    // Crear el directorio si no existe
    const uploadPath = join(process.cwd(), 'uploads', destination);
    if (!existsSync(uploadPath)) {
        mkdirSync(uploadPath, { recursive: true });
    }

    return diskStorage({
        destination: uploadPath,
        filename: (_req, file, callback) => {
            // UUID + extensión original → nunca colisiona
            const uniqueName = `${randomUUID()}${extname(file.originalname)}`;
            callback(null, uniqueName);
        },
    });
};

Uso en el controller:

@Post(':id/avatar')
@UseInterceptors(
    FileInterceptor('file', {
        storage: createMulterDiskStorage('avatars'),
        limits: {
            fileSize: 5 * 1024 * 1024, // 5MB
        },
    }),
)
uploadAvatar(
    @Param('id', ParseUUIDPipe) id: string,
    @UploadedFile(
        new ParseFilePipe({
            validators: [
                new FileTypeValidator({ fileType: /^image\/(jpeg|png|webp)$/ }),
            ],
        }),
    )
    file: Express.Multer.File,
): Promise<{ url: string }> {
    // file.filename → "a1b2c3d4-uuid.jpg"
    // file.path → "/app/uploads/avatars/a1b2c3d4-uuid.jpg"
    return this.usersService.updateAvatar(id, file);
}

¿Por qué UUID y no el nombre original? Dos usuarios pueden subir avatar.jpg y con UUID nunca van a colisionar. Además, los nombres originales pueden contener caracteres especiales, path traversal (../../etc/passwd) o ser demasiado largos.


8. FileUploadService: servicio dedicado para archivos

Centraliza toda la lógica de archivos en un servicio reutilizable:

// src/files/files.service.ts
import { Injectable, BadRequestException, NotFoundException, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { existsSync, createReadStream, unlinkSync } from 'fs';
import { join, extname } from 'path';
import { randomUUID } from 'crypto';
import { writeFile, mkdir } from 'fs/promises';
import { type ReadStream } from 'fs';

export interface UploadResult {
    filename: string;
    originalName: string;
    mimeType: string;
    size: number;
    url: string;
}

@Injectable()
export class FilesService {
    private readonly logger = new Logger(FilesService.name);
    private readonly uploadDir: string;
    private readonly baseUrl: string;

    constructor(private readonly configService: ConfigService) {
        this.uploadDir = join(process.cwd(), 'uploads');
        this.baseUrl = configService.getOrThrow<string>('APP_URL');
    }

    async upload(file: Express.Multer.File, subfolder: string): Promise<UploadResult> {
        const dirPath = join(this.uploadDir, subfolder);
        await mkdir(dirPath, { recursive: true });

        const filename = `${randomUUID()}${extname(file.originalname)}`;
        const filePath = join(dirPath, filename);

        await writeFile(filePath, file.buffer);

        this.logger.log(`Archivo subido: ${filename} (${this.formatSize(file.size)})`);

        return {
            filename,
            originalName: file.originalname,
            mimeType: file.mimetype,
            size: file.size,
            url: `${this.baseUrl}/files/${subfolder}/${filename}`,
        };
    }

    getFileStream(subfolder: string, filename: string): ReadStream {
        const filePath = join(this.uploadDir, subfolder, filename);

        if (!existsSync(filePath)) {
            throw new NotFoundException(`Archivo "${filename}" no encontrado`);
        }

        return createReadStream(filePath);
    }

    delete(subfolder: string, filename: string): void {
        const filePath = join(this.uploadDir, subfolder, filename);

        if (!existsSync(filePath)) {
            throw new NotFoundException(`Archivo "${filename}" no encontrado`);
        }

        unlinkSync(filePath);
        this.logger.log(`Archivo eliminado: ${subfolder}/${filename}`);
    }

    private formatSize(bytes: number): string {
        if (bytes < 1024) return `${bytes} B`;
        if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
        return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
    }
}
// src/files/files.module.ts
import { Module } from '@nestjs/common';
import { FilesService } from './files.service';
import { FilesController } from './files.controller';

@Module({
    providers: [FilesService],
    controllers: [FilesController],
    exports: [FilesService],
})
export class FilesModule {}

Ahora cualquier módulo que necesite subir archivos inyecta FilesService:

// src/users/users.service.ts
@Injectable()
export class UsersService {
    constructor(
        @InjectRepository(User)
        private readonly usersRepository: Repository<User>,
        private readonly filesService: FilesService
    ) {}

    async updateAvatar(id: string, file: Express.Multer.File): Promise<{ url: string }> {
        const user = await this.findOne(id);

        // Borrar avatar anterior si existe
        if (user.avatarFilename) {
            this.filesService.delete('avatars', user.avatarFilename);
        }

        const result = await this.filesService.upload(file, 'avatars');

        user.avatarFilename = result.filename;
        await this.usersRepository.save(user);

        return { url: result.url };
    }
}

9. StreamableFile: descarga de archivos

Para servir archivos de vuelta al cliente, NestJS tiene . En vez de cargar todo el archivo en memoria y devolverlo como buffer, usa un stream:

// src/files/files.controller.ts
import { Controller, Get, Param, Res, StreamableFile, Header } from '@nestjs/common';
import { type Response } from 'express';
import { FilesService } from './files.service';
import { Public } from '../auth/decorators/public.decorator';

@Controller('files')
export class FilesController {
    constructor(private readonly filesService: FilesService) {}

    @Get(':subfolder/:filename')
    @Public()
    getFile(
        @Param('subfolder') subfolder: string,
        @Param('filename') filename: string,
        @Res({ passthrough: true }) response: Response
    ): StreamableFile {
        const stream = this.filesService.getFileStream(subfolder, filename);
        const mimeType = this.getMimeType(filename);

        response.set({
            'Content-Type': mimeType,
            'Content-Disposition': `inline; filename="${filename}"`,
            'Cache-Control': 'public, max-age=31536000, immutable',
        });

        return new StreamableFile(stream);
    }

    @Get(':subfolder/:filename/download')
    @Public()
    downloadFile(
        @Param('subfolder') subfolder: string,
        @Param('filename') filename: string,
        @Res({ passthrough: true }) response: Response
    ): StreamableFile {
        const stream = this.filesService.getFileStream(subfolder, filename);

        response.set({
            'Content-Type': 'application/octet-stream',
            'Content-Disposition': `attachment; filename="${filename}"`,
        });

        return new StreamableFile(stream);
    }

    private getMimeType(filename: string): string {
        const mimeTypes: Record<string, string> = {
            '.jpg': 'image/jpeg',
            '.jpeg': 'image/jpeg',
            '.png': 'image/png',
            '.webp': 'image/webp',
            '.gif': 'image/gif',
            '.pdf': 'application/pdf',
            '.csv': 'text/csv',
        };

        const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
        return mimeTypes[ext] ?? 'application/octet-stream';
    }
}

Puntos clave:

¿Por qué StreamableFile y no res.sendFile()?

// ❌ Pierde los interceptors, la serialización, los exception filters
@Get(':filename')
getFile(@Res() res: Response): void {
    res.sendFile('/path/to/file');
}

// ✅ Funciona con todo el pipeline de NestJS
@Get(':filename')
getFile(@Res({ passthrough: true }) res: Response): StreamableFile {
    return new StreamableFile(stream);
}

Con @Res() sin passthrough, sales del pipeline de NestJS. Los interceptors del post 14 no se ejecutan, la serialización del post 15 no aplica y los exception filters no atrapan errores. StreamableFile con passthrough: true mantiene todo el pipeline intacto.


10. Generando archivos al vuelo: exports

No solo sirves archivos que ya existen, también puedes generar archivos dinámicamente y devolverlos como stream:

10.1. Export CSV

// src/users/users.controller.ts
import { StreamableFile } from '@nestjs/common';

@Get('export/csv')
@Roles(UserRole.ADMIN)
async exportCsv(
    @Res({ passthrough: true }) response: Response,
): Promise<StreamableFile> {
    const users = await this.usersService.findAll();

    const header = 'id,name,email,role,createdAt\n';
    const rows = users
        .map(
            (user) =>
                `${user.id},${user.name},${user.email},${user.role},${user.createdAt.toISOString()}`,
        )
        .join('\n');

    const csvContent = header + rows;
    const buffer = Buffer.from(csvContent, 'utf-8');

    response.set({
        'Content-Type': 'text/csv',
        'Content-Disposition': `attachment; filename="users-export-${Date.now()}.csv"`,
        'Content-Length': buffer.length.toString(),
    });

    return new StreamableFile(buffer);
}

10.2. Export JSON grande con streaming

Para exports grandes, genera el JSON en streaming para no cargar todo en memoria:

import { Readable } from 'stream';

@Get('export/json')
@Roles(UserRole.ADMIN)
async exportJson(
    @Res({ passthrough: true }) response: Response,
): Promise<StreamableFile> {
    const users = await this.usersService.findAll();

    // Crear un readable stream que emite JSON línea por línea
    const stream = new Readable({
        read() {
            this.push('[\n');
            users.forEach((user, index) => {
                const json = JSON.stringify({
                    id: user.id,
                    name: user.name,
                    email: user.email,
                    role: user.role,
                });
                const separator = index < users.length - 1 ? ',\n' : '\n';
                this.push(`  ${json}${separator}`);
            });
            this.push(']\n');
            this.push(null); // Señal de fin
        },
    });

    response.set({
        'Content-Type': 'application/json',
        'Content-Disposition': `attachment; filename="users-${Date.now()}.json"`,
    });

    return new StreamableFile(stream);
}

11. Volúmenes Docker para uploads

Si corres la app en Docker (como desde el post 1), los archivos que subes se pierden cuando el contenedor se destruye, así que necesitas un volumen:

# docker-compose.yml
services:
    api:
        build: .
        ports:
            - '3000:3000'
        volumes:
            - uploads_data:/app/uploads # Persistir archivos subidos
        environment:
            - APP_URL=http://localhost:3000

    postgres:
        image: postgres:17
        # ...

volumes:
    uploads_data: # Volumen nombrado para uploads

Con el volumen uploads_data, los archivos persisten aunque destruyas y recrees el contenedor. En producción, considera usar un servicio de almacenamiento externo (S3, Google Cloud Storage) para no depender del disco del servidor.


12. Seguridad en file uploads

12.1. Path traversal

// ❌ VULNERABLE: el cliente puede enviar "../../../etc/passwd" como filename
const filePath = join(this.uploadDir, file.originalname);

// ✅ SEGURO: UUID generado por el servidor, nunca usar nombres del cliente
const filename = `${randomUUID()}${extname(file.originalname)}`;
const filePath = join(this.uploadDir, filename);

Nunca uses el originalname directamente como ruta de archivo. Siempre genera un nombre nuevo en el servidor.

12.2. Validar el parámetro en las descargas

@Get(':subfolder/:filename')
getFile(
    @Param('subfolder') subfolder: string,
    @Param('filename') filename: string,
): StreamableFile {
    // Validar que no haya path traversal en los parámetros
    if (subfolder.includes('..') || filename.includes('..')) {
        throw new BadRequestException('Ruta inválida');
    }

    if (!/^[a-zA-Z0-9-]+$/.test(subfolder)) {
        throw new BadRequestException('Subfolder inválido');
    }

    if (!/^[a-zA-Z0-9-]+\.[a-z]+$/.test(filename)) {
        throw new BadRequestException('Filename inválido');
    }

    return new StreamableFile(
        this.filesService.getFileStream(subfolder, filename),
    );
}

12.3. Limitar en Multer, no solo en el Pipe

@UseInterceptors(
    FileInterceptor('file', {
        limits: {
            fileSize: 5 * 1024 * 1024,  // 5MB a nivel de Multer
            files: 1,                     // Máximo 1 archivo
        },
    }),
)

El limits de Multer corta la subida mientras se recibe y el ParseFilePipe valida después de recibir. Si un archivo de 100MB pasa sin limits, Multer lo carga entero en memoria antes de que ParseFilePipe lo rechace.

12.4. No servir uploads desde la carpeta static

// ❌ PELIGROSO: servir uploads como archivos estáticos
app.useStaticAssets(join(__dirname, '..', 'uploads'));
// Un archivo .html subido podría ejecutar JavaScript en el contexto de tu dominio

// ✅ SEGURO: servir vía controller con Content-Type controlado
@Get(':subfolder/:filename')
getFile(): StreamableFile { /* ... */ }

Servir uploads como estáticos permite que un archivo .html malicioso se ejecute en tu dominio (XSS). Con un controller, tú controlas el Content-Type y puedes forzar Content-Disposition: attachment para tipos peligrosos.


13. Resumen de interceptors y decoradores de archivos

InterceptorDecoradorUso
FileInterceptor(‘field’)@UploadedFile()Un solo archivo de un campo
FilesInterceptor(‘field’, max)@UploadedFiles()Múltiples archivos del mismo campo
FileFieldsInterceptor([…])@UploadedFiles()Archivos de campos diferentes
AnyFilesInterceptor()@UploadedFiles()Cualquier archivo de cualquier campo

14. Errores comunes

Error 1: @Res() sin passthrough

// ❌ Sin passthrough, NestJS no envía el StreamableFile
@Get(':filename')
getFile(@Res() res: Response): StreamableFile {
    return new StreamableFile(stream); // Nunca se envía
}

// ✅ Con passthrough, NestJS controla el envío
@Get(':filename')
getFile(@Res({ passthrough: true }) res: Response): StreamableFile {
    return new StreamableFile(stream);
}

Error 2: No poner limits en Multer para archivos grandes

// ❌ Un atacante sube 1GB → Multer lo carga en memoria → crash
@UseInterceptors(FileInterceptor('file'))

// ✅ Multer corta la subida a nivel de stream
@UseInterceptors(
    FileInterceptor('file', {
        limits: { fileSize: 5 * 1024 * 1024 },
    }),
)

Error 3: Usar originalname como ruta

// ❌ Path traversal: originalname puede ser "../../etc/passwd"
const path = join(uploadDir, file.originalname);

// ✅ UUID generado en el servidor
const path = join(uploadDir, `${randomUUID()}${extname(file.originalname)}`);

15. Recapitulando el bloque completo

Con los posts 13-16, el bloque de funcionalidades avanzadas queda ya finiquitado:

📁 FileInterceptor + Multer

Parsea multipart/form-data. FileInterceptor para un archivo, FilesInterceptor para varios, FileFieldsInterceptor para campos diferentes.

✅ ParseFilePipe

Validación tipada de archivos: MaxFileSizeValidator, FileTypeValidator, custom validators para magic bytes.

🌊 StreamableFile

Devuelve archivos como stream sin cargar en memoria. Funciona con todo el pipeline de NestJS (interceptors, filters).

🔧 FilesService

Servicio centralizado para upload, download y delete. UUID para nombres, path traversal prevention.

🔒 Seguridad

Magic bytes, límites en Multer, path traversal prevention, no servir uploads como estáticos. Múltiples capas.

🐳 Docker volumes

Volúmenes nombrados para persistir uploads. En producción: S3 o cloud storage.

Con esto cerramos el Bloque 4: Funcionalidades Avanzadas. El siguiente bloque entra en Eventos, Colas y Tiempo Real: event-driven architecture, jobs en background con Bull + Redis y WebSockets con Socket.IO.

EA, nos vemos en los bares!! 🍺


Pon a prueba lo aprendido

1. ¿Qué Content-Type usa el navegador para enviar archivos al servidor?

2. ¿Por qué es importante poner limits.fileSize en la configuración de Multer además de MaxFileSizeValidator?

3. ¿Qué son los magic bytes y por qué son importantes para la seguridad?

4. ¿Por qué se debe usar @Res({ passthrough: true }) con StreamableFile?

5. ¿Por qué nunca deberías usar file.originalname directamente como nombre del archivo guardado?