Upload S3 com Presigned URL e Processamento com Lambda no Node com TypeScript

Aprenda upload S3 com presigned URL passo a passo, usando Lambda e React. Implemente fluxo seguro, binário correto e processamento pós-upload.


Fluxo de Upload com S3

Este guia ensina como implementar upload de arquivos usando S3 com presigned URLs, como o cliente deve fazer o upload corretamente, e como processar o arquivo após o upload.


Visão Geral do Fluxo

O upload segue um fluxo em 3 etapas:

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  1. Cliente pede URL                                        │
│     ─────────────────                                       │
│     Cliente → API → S3 (gera presigned URL)                 │
│                  ← URL retornada ao cliente                 │
│                                                             │
│  2. Cliente faz upload                                      │
│     ────────────────────                                    │
│     Cliente → S3 (upload direto usando a URL)               │
│                                                             │
│  3. S3 notifica Lambda                                      │
│     ─────────────────────                                   │
│     S3 → Lambda (evento ObjectCreated)                      │
│          └─→ Processa arquivo (thumbnail, validação, etc)   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Por que esse fluxo?

  • Segurança: Cliente não precisa de credenciais AWS
  • Performance: Upload vai direto pro S3, não passa pelo seu servidor
  • Custo: Sua Lambda não gasta tempo/memória com upload

Etapa 1: Gerando Presigned URL

O que é Presigned URL?

É uma URL temporária que dá permissão para fazer uma ação específica no S3 (upload ou download) sem precisar de credenciais.

URL normal:
https://bucket.s3.amazonaws.com/arquivo.jpg
→ Acesso negado (precisa de credenciais)

Presigned URL:
https://bucket.s3.amazonaws.com/arquivo.jpg?X-Amz-Signature=abc123...
→ Funciona! (assinatura válida por tempo limitado)

Gerando URL de Upload

// src/infra/services/s3-storage.service.ts

import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

export class S3StorageService {
  private readonly s3 = new S3Client({});
  private readonly bucketName = process.env.MEDIA_BUCKET_NAME;

  async generateUploadUrl(params: {
    key: string;
    contentType: string;
    expiresIn?: number;
  }): Promise<{ url: string; expiresIn: number }> {
    const expiresIn = params.expiresIn ?? 300; // 5 minutos

    const command = new PutObjectCommand({
      Bucket: this.bucketName,
      Key: params.key,
      ContentType: params.contentType
    });

    const url = await getSignedUrl(this.s3, command, { expiresIn });

    return { url, expiresIn };
  }
}

Handler de Geração de URL

// src/infra/http/handlers/generate-upload-url.ts

export async function handler(event: APIGatewayProxyEventV2) {
  // 1. Validar input
  const { fileName, fileSize, contentType } = JSON.parse(event.body);

  // 2. Gerar identificadores
  const userId = event.requestContext.authorizer.lambda.sub;
  const fileId = crypto.randomUUID();
  const extension = fileName.split(".").pop();
  const key = `media/${userId}/${fileId}.${extension}`;

  // 3. Salvar metadados no banco
  const media = Media.create({
    ownerId: new UniqueEntityId(userId),
    fileName,
    fileSize,
    contentType,
    s3Key: key,
    status: "uploading"
  });
  await mediaRepository.save(media);

  // 4. Gerar presigned URL
  const { url, expiresIn } = await storageService.generateUploadUrl({
    key,
    contentType
  });

  // 5. Retornar URL para o cliente
  return {
    statusCode: 201,
    body: JSON.stringify({
      uploadUrl: url,
      fileId: fileId,
      expiresIn
    })
  };
}

Etapa 2: Upload pelo Cliente

IMPORTANTE: Como fazer o upload corretamente

O cliente deve enviar o arquivo como binário direto, NÃO como form-data.

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│   ERRADO: multipart/form-data                               │
│   ──────────────────────────                                │
│   O S3 vai salvar o "envelope" do form, não o arquivo       │
│                                                             │
│   Conteúdo salvo:                                           │
│   ------WebKitFormBoundary                                  │
│   Content-Disposition: form-data; name="file"               │
│   ... dados do arquivo ...                                  │
│   ------WebKitFormBoundary--                                │
│                                                             │
│   CORRETO: binary                                           │
│   ───────────────────                                       │
│   O S3 salva exatamente os bytes do arquivo                 │
│                                                             │
│   Conteúdo salvo:                                           │
│   [bytes puros da imagem]                                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Exemplo: JavaScript/React

// ERRADO - NÃO FAÇA ISSO
const formData = new FormData();
formData.append("file", file);
await fetch(presignedUrl, {
  method: "PUT",
  body: formData // Isso envia como multipart!
});

// CORRETO
await fetch(presignedUrl, {
  method: "PUT",
  body: file, // File object diretamente
  headers: {
    "Content-Type": file.type // image/jpeg, image/png, etc.
  }
});

Exemplo: Componente React Completo

function UploadComponent() {
  const [file, setFile] = useState(null);
  const [uploading, setUploading] = useState(false);

  async function handleUpload() {
    if (!file) return;

    setUploading(true);

    try {
      // 1. Pedir presigned URL para o backend
      const response = await fetch("/api/uploads/presign", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${token}`
        },
        body: JSON.stringify({
          fileName: file.name,
          fileSize: file.size,
          contentType: file.type
        })
      });

      const { uploadUrl, fileId } = await response.json();

      // 2. Fazer upload direto para o S3
      await fetch(uploadUrl, {
        method: "PUT",
        body: file, // Arquivo diretamente, sem FormData!
        headers: {
          "Content-Type": file.type
        }
      });

      console.log("Upload concluído! File ID:", fileId);
    } catch (error) {
      console.error("Erro no upload:", error);
    } finally {
      setUploading(false);
    }
  }

  return (
    <div>
      <input
        type="file"
        accept="image/*"
        onChange={e => setFile(e.target.files[0])}
      />
      <button onClick={handleUpload} disabled={uploading}>
        {uploading ? "Enviando..." : "Enviar"}
      </button>
    </div>
  );
}

Exemplo: cURL

# Primeiro, pegar a presigned URL
curl -X POST https://api.exemplo.com/uploads/presign \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"fileName":"foto.jpg","fileSize":12345,"contentType":"image/jpeg"}'

# Resposta: {"uploadUrl":"https://bucket.s3...","fileId":"abc123"}

# Depois, fazer upload
curl -X PUT "https://bucket.s3.amazonaws.com/media/user/abc123.jpg?X-Amz-..." \
  -H "Content-Type: image/jpeg" \
  --data-binary @foto.jpg

Como identificar upload errado

Se você receber erro "unsupported image format" ao processar, verifique os primeiros bytes do arquivo:

// No Lambda de processamento
console.log("Primeiros bytes:", buffer.subarray(0, 16).toString("hex"));
FormatoPrimeiros bytes (hex)
PNG89504e47 (89 P N G)
JPEGffd8ff
WebP52494646 (RIFF)
Form-data (ERRADO)2d2d2d2d (----)

Se você vir 2d2d2d2d, o cliente está enviando como form-data.


Etapa 3: Processando após Upload

Configurando Trigger S3

# serverless/functions.yml

functions:
  processUpload:
    handler: src/handlers/process-upload.handler
    timeout: 30
    memorySize: 512
    layers:
      - !Ref SharpLambdaLayer
    events:
      - s3:
          bucket: ${self:provider.environment.MEDIA_BUCKET_NAME}
          event: s3:ObjectCreated:*
          existing: true # Bucket já existe

Evento S3

Quando um arquivo é criado no S3, o Lambda recebe um evento assim:

interface S3Event {
  Records: [
    {
      s3: {
        bucket: {
          name: "meu-bucket";
        };
        object: {
          key: "media/user-123/file-456.jpg";
          size: 12345;
        };
      };
    }
  ];
}

Handler de Processamento

// src/infra/http/handlers/process-upload.ts

import type { S3Event, Context } from "aws-lambda";

export async function handler(event: S3Event, context: Context) {
  const key = event.Records[0].s3.object.key;

  console.log(`Processando: ${key}`);

  // 1. Buscar metadados no banco
  const media = await mediaRepository.findByS3Key(key);

  if (!media) {
    console.error("Media não encontrada no banco");
    return;
  }

  // 2. Baixar arquivo do S3
  const originalFile = await storageService.getObject(key);

  // 3. Processar (gerar thumbnail)
  const thumbnail =
    await imageProcessingService.generateThumbnail(originalFile);

  // 4. Salvar thumbnail
  const thumbnailKey = `thumbnails/${media.ownerId}/${media.id}.jpg`;
  await storageService.putObject({
    key: thumbnailKey,
    body: thumbnail,
    contentType: "image/jpeg"
  });

  // 5. Atualizar status no banco
  media.status = "ready";
  media.thumbnail = thumbnailKey;
  await mediaRepository.save(media);

  return { statusCode: 200 };
}

Fluxo Completo Visual

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  Cliente                API                S3               │
│     │                    │                  │               │
│     │ POST /presign      │                  │               │
│     │ {fileName, size}   │                  │               │
│     │───────────────────>│                  │               │
│     │                    │                  │               │
│     │                    │ Gera presigned   │               │
│     │                    │ URL              │               │
│     │                    │                  │               │
│     │                    │ Salva metadata   │               │
│     │                    │ (status:uploading)               │
│     │                    │                  │               │
│     │ {uploadUrl, fileId}│                  │               │
│     │<───────────────────│                  │               │
│     │                    │                  │               │
│     │ PUT (binary)       │                  │               │
│     │──────────────────────────────────────>│               │
│     │                    │                  │               │
│     │ 200 OK             │                  │               │
│     │<──────────────────────────────────────│               │
│     │                    │                  │               │
│     │                    │                  │ S3 Event      │
│     │                    │                  │───────┐       │
│     │                    │                  │       │       │
│     │                    │                  │       ▼       │
│     │                    │              ┌───────────────┐   │
│     │                    │              │ processUpload │   │
│     │                    │              │    Lambda     │   │
│     │                    │              └───────────────┘   │
│     │                    │                  │               │
│     │                    │ Atualiza status  │               │
│     │                    │ (status:ready)   │               │
│     │                    │                  │               │
└─────────────────────────────────────────────────────────────┘

Gerando URL de Download

Para baixar arquivos privados, gere uma presigned URL de download:

async generateDownloadUrl(key: string): Promise<{ url: string }> {
  const command = new GetObjectCommand({
    Bucket: this.bucketName,
    Key: key
  });

  const url = await getSignedUrl(this.s3, command, { expiresIn: 3600 });

  return { url };
}

Dicas de Segurança

1. Valide o content-type

const allowedTypes = ["image/jpeg", "image/png", "image/webp"];

if (!allowedTypes.includes(contentType)) {
  throw new ValidationError("Tipo de arquivo não permitido");
}

2. Limite o tamanho

const maxSize = 10 * 1024 * 1024; // 10MB

if (fileSize > maxSize) {
  throw new ValidationError("Arquivo muito grande");
}

3. Use paths únicos

// Evita colisão e dificulta adivinhação
const key = `media/${userId}/${uuid()}.${extension}`;

4. Expire URLs rapidamente

// 5 minutos é suficiente para upload
const expiresIn = 300;

Troubleshooting

"Input buffer contains unsupported image format"

O arquivo não é uma imagem válida. Causas comuns:

  1. Cliente enviou como form-data (veja seção "Como fazer upload corretamente")
  2. Arquivo corrompido
  3. Formato não suportado

"Access Denied" no upload

  1. Verifique se a presigned URL não expirou
  2. Verifique se o Content-Type bate com o configurado na URL
  3. Verifique permissões do bucket (CORS)

Lambda não é acionada

  1. Verifique se o trigger S3 está configurado
  2. Verifique se o bucket está correto
  3. Verifique permissões (Lambda precisa de acesso ao bucket)

CORS no upload

Configure CORS no bucket:

# serverless/resources.yml

MediaBucket:
  Type: AWS::S3::Bucket
  Properties:
    CorsConfiguration:
      CorsRules:
        - AllowedHeaders:
            - "*"
          AllowedMethods:
            - GET
            - PUT
          AllowedOrigins:
            - "*" # Em produção, especifique domínios
          MaxAge: 3000

Resumo

  1. Presigned URL dá permissão temporária para upload/download
  2. Cliente pede URL ao backend, faz upload direto ao S3
  3. Upload deve ser binário direto, NÃO form-data
  4. Evento S3 aciona Lambda para processar
  5. Lambda baixa, processa, e atualiza status no banco
  6. Sempre valide tipo e tamanho do arquivo
  7. Use paths únicos e expire URLs rapidamente