📤 Multer + multipart/form-data
O NestJS recebe upload via FileInterceptor, que por baixo usa o Multer para parsear multipart/form-data. O segredo é nunca aceitar o arquivo em disco — fique sempre em memoryStorage para conseguir validar antes de gravar em qualquer lugar.
Combine MaxFileSizeValidator e FileTypeValidator via ParseFilePipe para falhar cedo, antes do controller começar a trabalhar.
// src/media/media.controller.ts
import {
Controller, Post, UploadedFile, UseInterceptors,
ParseFilePipe, MaxFileSizeValidator, FileTypeValidator,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { MediaService } from './media.service';
@Controller('media')
export class MediaController {
constructor(private readonly media: MediaService) {}
@Post('upload')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
}),
)
async upload(
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 10 * 1024 * 1024 }),
new FileTypeValidator({ fileType: /^image\/(jpeg|png|webp)$/ }),
],
}),
)
file: Express.Multer.File,
) {
return this.media.handle(file);
}
}
💡 Dica prática
Nunca use diskStorage em produção. Arquivo em disco precisa de cleanup manual em caso de falha — e em ambientes com múltiplas réplicas, o arquivo "some" se o load balancer mandar a próxima request para outro pod.
Conceitos-chave
Encoding HTTP para enviar arquivos + campos juntos.
Decorator Nest que injeta o arquivo no handler.
Buffer em RAM — valida antes de qualquer escrita.
Pipeline de validadores nativos do Nest.
☁️ Salvar no S3 (ou R2)
Use o AWS SDK v3 com PutObjectCommand. Funciona igual no S3 da AWS e no Cloudflare R2 — basta trocar o endpoint e a região para auto. R2 é zero egress, ideal para servir mídia.
A geração da key precisa ser previsível e única. Combine prefixo lógico + UUID + extensão: posts/2026/05/<uuid>.webp. Evita colisão e facilita auditoria.
// src/media/s3.client.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { randomUUID } from 'crypto';
export const s3 = new S3Client({
region: process.env.S3_REGION ?? 'auto',
endpoint: process.env.S3_ENDPOINT, // R2: https://<acc>.r2.cloudflarestorage.com
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
},
forcePathStyle: true, // R2 e MinIO exigem
});
export function buildKey(prefix: string, ext: string): string {
const now = new Date();
const yyyy = now.getUTCFullYear();
const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
return `${prefix}/${yyyy}/${mm}/${randomUUID()}.${ext}`;
}
export async function putObject(
key: string,
body: Buffer,
contentType: string,
) {
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
Body: body,
ContentType: contentType,
CacheControl: 'public, max-age=31536000, immutable',
}));
return key;
}
⚡ Dica prática
Defina CacheControl: immutable nos uploads. Como a key contém UUID e nunca muda, o navegador e CDN podem cachear para sempre. Reduz custo de egress em 80%+.
Conceitos-chave
Modular, tree-shakable, async/await nativo.
Operação atômica de escrita no bucket.
R2 = mesma API, zero egress, region auto.
Prefixo + data + UUID + ext = único e legível.
🔗 Gerar URL pública assinada
Buckets privados são o default — você não quer expor todos os objetos. Para servir mídia ao frontend, gere URLs assinadas com getSignedUrl. A URL carrega credenciais temporárias no query string e expira automaticamente.
Use expirations curtas (15min - 1h) para mídia em painel admin e cache CDN público para imagens de posts já publicados. Cada propósito tem seu padrão.
// src/media/signed-url.ts
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { s3 } from './s3.client';
type Purpose = 'preview' | 'download' | 'public';
const EXPIRY: Record<Purpose, number> = {
preview: 60 * 15, // 15 min - admin UI
download: 60 * 5, // 5 min - link de download
public: 60 * 60 * 24 * 7, // 7 dias - imagem de post publicado
};
export async function signGet(key: string, purpose: Purpose): Promise<string> {
const command = new GetObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
ResponseContentDisposition: purpose === 'download'
? `attachment; filename="${key.split('/').pop()}"`
: 'inline',
});
return getSignedUrl(s3, command, { expiresIn: EXPIRY[purpose] });
}
// uso no controller
@Get(':id/url')
async getUrl(@Param('id') id: string): Promise<{ url: string }> {
const media = await this.media.findById(id);
return { url: await signGet(media.key, 'preview') };
}
🔒 Dica prática
Para imagens públicas (capas de post já publicado), considere um bucket público com CDN em vez de URL assinada. Cache hit é maior e o custo de assinatura por request some.
Conceitos-chave
URL temporária com credenciais embutidas via HMAC.
TTL em segundos — curto p/ privado, longo p/ público.
inline exibe; attachment baixa.
Cada uso (preview, download, share) tem TTL próprio.
🎨 Resize / optimize (sharp)
Subir o JPEG original do iPhone (4MB, 4032x3024) é desperdício. Use o sharp para redimensionar, converter para WebP e ajustar qualidade — economizando 70-90% de bytes sem perda visível.
Pipeline padrão: limite à largura máxima (ex.: 1600px), preserve aspect ratio, força WebP com qualidade 82. Para thumbnails, gere uma segunda variante 400px e suba ambas.
// src/media/image.optimizer.ts
import sharp from 'sharp';
export interface OptimizedImage {
buffer: Buffer;
width: number;
height: number;
contentType: 'image/webp';
}
export async function optimize(
input: Buffer,
opts: { maxWidth?: number; quality?: number } = {},
): Promise<OptimizedImage> {
const maxWidth = opts.maxWidth ?? 1600;
const quality = opts.quality ?? 82;
const pipeline = sharp(input, { failOn: 'error' })
.rotate() // respeita EXIF orientation
.resize({ width: maxWidth, withoutEnlargement: true })
.webp({ quality, effort: 4 });
const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
return {
buffer: data,
width: info.width,
height: info.height,
contentType: 'image/webp',
};
}
// gera thumbnail 400px para listagens
export async function thumbnail(input: Buffer): Promise<OptimizedImage> {
return optimize(input, { maxWidth: 400, quality: 75 });
}
📐 Dica prática
.rotate() sem argumento aplica a rotação EXIF e depois remove o metadado. Sem isso, fotos do celular aparecem deitadas no navegador em alguns browsers.
Conceitos-chave
Lib C nativa (libvips) — 10x mais rápida que Jimp.
25-35% menor que JPEG na mesma qualidade visual.
Nunca aumenta imagem pequena — evita perda.
Corrige orientação de fotos de celular automaticamente.
✅ Validação de mime/size
O Content-Type que o cliente envia é mentiroso — qualquer pessoa pode renomear virus.exe para foto.png e mandar header forjado. Valide os magic bytes reais do buffer com file-type.
Combine três camadas: whitelist de extensão (rápido), size limit (10MB cobre 99% dos casos legítimos) e magic byte sniff (verdade absoluta).
// src/media/validator.ts
import { BadRequestException } from '@nestjs/common';
import { fileTypeFromBuffer } from 'file-type';
const ALLOWED_MIMES = new Set([
'image/jpeg',
'image/png',
'image/webp',
]);
const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
export async function validateImage(file: Express.Multer.File): Promise<void> {
// 1. Tamanho
if (file.size > MAX_SIZE) {
throw new BadRequestException(`Arquivo maior que ${MAX_SIZE / 1024 / 1024}MB`);
}
// 2. Mime declarado (sanity check)
if (!ALLOWED_MIMES.has(file.mimetype)) {
throw new BadRequestException(`Mime ${file.mimetype} não permitido`);
}
// 3. Magic bytes (verdade)
const detected = await fileTypeFromBuffer(file.buffer);
if (!detected || !ALLOWED_MIMES.has(detected.mime)) {
throw new BadRequestException(
`Conteúdo real (${detected?.mime ?? 'desconhecido'}) não confere com tipo declarado`,
);
}
}
✓ O que FAZER
- ✓Validar magic bytes com
file-type, não confiar no header. - ✓Whitelist de extensões — nunca blacklist.
- ✓Limite global de 10MB no Multer + checagem manual.
- ✓Reprocessar com sharp — destrói qualquer payload escondido.
✗ O que NÃO fazer
- ✗Confiar só no
Content-Typedo request. - ✗Aceitar SVG sem sanitização (pode ter
<script>). - ✗Servir o arquivo do mesmo domínio do app (XSS).
- ✗Logar o buffer inteiro em erro — vai estourar APM.
Conceitos-chave
Primeiros bytes do arquivo identificam o tipo real.
Permitir só o que conhece — bloquear o resto.
Size + mime + magic + reprocess.
Lib que sniff o buffer e retorna mime real.
🧹 Cleanup de mídia órfã
Toda mídia uploadeada começa como órfã: existe no bucket mas não está vinculada a nenhum post ainda. Se o usuário abandona o rascunho, o arquivo vira lixo pago. Marque com postId: null e rode um cron job que limpa os órfãos com mais de N dias.
Quando um post é deletado, marque as mídias dele com deletedAt em vez de apagar imediato — assim você recupera em caso de erro do usuário. O cron varre e remove definitivamente após 7-30 dias.
// src/media/cleanup.cron.ts
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
import { PrismaService } from '../prisma/prisma.service';
import { s3 } from './s3.client';
const ORPHAN_DAYS = 1; // upload sem post associado
const DELETED_DAYS = 7; // post foi apagado
@Injectable()
export class MediaCleanupCron {
private readonly logger = new Logger(MediaCleanupCron.name);
constructor(private readonly prisma: PrismaService) {}
@Cron(CronExpression.EVERY_DAY_AT_3AM)
async sweep(): Promise<void> {
const now = Date.now();
const orphanCutoff = new Date(now - ORPHAN_DAYS * 86400_000);
const deletedCutoff = new Date(now - DELETED_DAYS * 86400_000);
const stale = await this.prisma.media.findMany({
where: {
OR: [
{ postId: null, createdAt: { lt: orphanCutoff } },
{ deletedAt: { lt: deletedCutoff, not: null } },
],
},
take: 500, // batch
});
this.logger.log(`Cleanup: ${stale.length} mídias`);
for (const m of stale) {
try {
await s3.send(new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: m.key,
}));
await this.prisma.media.delete({ where: { id: m.id } });
} catch (err) {
this.logger.error(`Falha ao limpar ${m.key}`, err);
}
}
}
}
💸 Dica prática
Monitore o tamanho total do bucket por prefixo. Se posts/ cresce 10MB/dia mas você só publica 2MB/dia, o cron não está pegando algum caminho de órfão — investigue antes da conta do mês doer.
Conceitos-chave
Upload sem vínculo a post — vira lixo se ignorado.
deletedAt permite undo antes do purge físico.
Decorator do @nestjs/schedule para tarefas agendadas.
take: 500 evita timeout em buckets grandes.
🎯 Resumo do Módulo
memoryStorage, ParseFilePipe com size + mime, limite de 10MB.PutObjectCommand, key estruturada por prefixo + data + UUID.getSignedUrl com TTL por propósito (preview, download, public).file-type.🏁 Fim da Trilha 5 — Do Zero
Você construiu o MVP: schema, auth, posts, agendamento e mídia. Hora de colocar no ar de verdade — Trilha 6 cobre containerização, CI/CD, observabilidade e deploy em VPS / Railway / Fly.io.