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.

1. ¿Cómo funcionan los file uploads en HTTP?
Los archivos se envían como 💡 multipart/form-data Content-Type HTTP para enviar archivos y datos binarios. A diferencia de application/json, permite enviar archivos junto con campos de texto en la misma petición. El body se divide en partes separadas por un boundary string. Más info → , 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 💡 Multer Middleware de Node.js para manejar multipart/form-data. Parsea los archivos de la request y los hace disponibles como objetos con propiedades como originalname, mimetype, size y buffer. NestJS lo integra con interceptors y decoradores. Más info → 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:
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:
FileInterceptor('file'): Interceptor de NestJS que usa Multer para extraer el archivo del campofiledel form-data. El string'file'es el nombre del campo en el formulario.@UploadedFile(): Decorador de parámetro que inyecta el archivo parseado por Multer.Express.Multer.File: El tipo del archivo que contiene toda la información.
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
| Validador | Qué valida | Ejemplo |
|---|---|---|
MaxFileSizeValidator | Tamaño máximo en bytes |
|
FileTypeValidator | MIME type (string o regex) | fileType: /^image/(jpeg|png|webp)$/ |
Custom validator | Lo que tú quieras | Dimensiones, 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 magic bytes Los primeros bytes de un archivo que identifican su formato real. JPEG empieza con FF D8 FF, PNG con 89 50 4E 47 (que es .PNG en ASCII). Verificar los magic bytes es más seguro que confiar en el mimetype del cliente. :
// 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:
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.
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.jpgy 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 💡 StreamableFile Clase de NestJS que envuelve un ReadStream o Buffer y lo devuelve como respuesta HTTP con los headers correctos (Content-Type, Content-Disposition, Content-Length). Usa streaming para no cargar el archivo completo en memoria. Más info → . 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:
@Res({ passthrough: true }): Accede al response para poner headers pero deja que NestJS controle el envío. Sinpassthrough: true, NestJS no envía elStreamableFiley tú eres responsable de llamar aresponse.end().inlinevsattachment:inlinemuestra el archivo en el navegador (imágenes, PDFs).attachmentfuerza la descarga.Cache-Control: immutable: Como los archivos tienen nombre UUID, nunca cambian. El navegador los cachea para siempre.
¿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
| Interceptor | Decorador | Uso |
|---|---|---|
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:
Parsea multipart/form-data. FileInterceptor para un archivo, FilesInterceptor para varios, FileFieldsInterceptor para campos diferentes.
Validación tipada de archivos: MaxFileSizeValidator, FileTypeValidator, custom validators para magic bytes.
Devuelve archivos como stream sin cargar en memoria. Funciona con todo el pipeline de NestJS (interceptors, filters).
Servicio centralizado para upload, download y delete. UUID para nombres, path traversal prevention.
Magic bytes, límites en Multer, path traversal prevention, no servir uploads como estáticos. Múltiples capas.
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?