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"));
| Formato | Primeiros bytes (hex) |
|---|---|
| PNG | 89504e47 (89 P N G) |
| JPEG | ffd8ff |
| WebP | 52494646 (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:
- Cliente enviou como form-data (veja seção "Como fazer upload corretamente")
- Arquivo corrompido
- Formato não suportado
"Access Denied" no upload
- Verifique se a presigned URL não expirou
- Verifique se o Content-Type bate com o configurado na URL
- Verifique permissões do bucket (CORS)
Lambda não é acionada
- Verifique se o trigger S3 está configurado
- Verifique se o bucket está correto
- 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
- Presigned URL dá permissão temporária para upload/download
- Cliente pede URL ao backend, faz upload direto ao S3
- Upload deve ser binário direto, NÃO form-data
- Evento S3 aciona Lambda para processar
- Lambda baixa, processa, e atualiza status no banco
- Sempre valide tipo e tamanho do arquivo
- Use paths únicos e expire URLs rapidamente