⚡ AutomationsAI|Portal de Cursos →

Verificando acesso...

MÓDULO 5.5

🖼️ Upload de mídia

Receber arquivos no NestJS, persistir no S3/R2, servir via URL assinada, otimizar com sharp e manter o bucket limpo de mídia órfã.

6
Tópicos
35
Minutos
Avançado
Nível
Prático
Tipo
1

📤 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

multipart/form-data

Encoding HTTP para enviar arquivos + campos juntos.

FileInterceptor

Decorator Nest que injeta o arquivo no handler.

memoryStorage

Buffer em RAM — valida antes de qualquer escrita.

ParseFilePipe

Pipeline de validadores nativos do Nest.

2

☁️ 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

SDK v3

Modular, tree-shakable, async/await nativo.

PutObjectCommand

Operação atômica de escrita no bucket.

R2 vs S3

R2 = mesma API, zero egress, region auto.

Key strategy

Prefixo + data + UUID + ext = único e legível.

3

🔗 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

Presigned URL

URL temporária com credenciais embutidas via HMAC.

expiresIn

TTL em segundos — curto p/ privado, longo p/ público.

ContentDisposition

inline exibe; attachment baixa.

Purpose

Cada uso (preview, download, share) tem TTL próprio.

4

🎨 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

sharp

Lib C nativa (libvips) — 10x mais rápida que Jimp.

WebP

25-35% menor que JPEG na mesma qualidade visual.

withoutEnlargement

Nunca aumenta imagem pequena — evita perda.

EXIF rotate

Corrige orientação de fotos de celular automaticamente.

5

✅ 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-Type do 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

Magic bytes

Primeiros bytes do arquivo identificam o tipo real.

Whitelist

Permitir só o que conhece — bloquear o resto.

Defesa em camadas

Size + mime + magic + reprocess.

file-type

Lib que sniff o buffer e retorna mime real.

6

🧹 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

Mídia órfã

Upload sem vínculo a post — vira lixo se ignorado.

Soft delete

deletedAt permite undo antes do purge físico.

@Cron

Decorator do @nestjs/schedule para tarefas agendadas.

Batch delete

take: 500 evita timeout em buckets grandes.

🎯 Resumo do Módulo

Upload via FileInterceptormemoryStorage, ParseFilePipe com size + mime, limite de 10MB.
Persistência em S3/R2 — AWS SDK v3, PutObjectCommand, key estruturada por prefixo + data + UUID.
URLs assinadasgetSignedUrl com TTL por propósito (preview, download, public).
Otimização com sharp — resize 1600px, WebP qualidade 82, rotate EXIF, thumbnail 400px.
Validação em camadas — size + mime declarado + magic bytes via file-type.
Cleanup automatizado — cron diário remove órfãos (>1d) e soft-deletes (>7d).

🏁 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.