Introdução e Contexto
Eu trabalhava numa empresa do setor de mármores e granitos. O fluxo de trabalho diário envolvia fotografar e filmar blocos de pedra na serraria — cada bloco recebia um código (ex.: BL-4021) e precisava de registros visuais para controle de qualidade, vendas e rastreabilidade.
Antes dessa solução, a empresa usava o AWS Storage Gateway (File Gateway). A ideia era boa: montar um share SMB no servidor local e sincronizar automaticamente com o S3. Na prática, porém, a realidade era outra:
- Latência brutal: uploads de vídeos de 50-100 MB levavam 15 a 30 segundos pelo SMB, quando não travavam.
- Operadores desistiam: como o processo era lento e pouco intuitivo, a equipe de campo começou a mandar fotos pelo WhatsApp — sem organização, sem metadados, sem rastreabilidade.
- Custo desnecessário: o File Gateway exigia uma VM EC2 dedicada (mínimo
m5.xlarge) rodando 24/7, mesmo quando ninguém estava fazendo upload. - Sem controle de acesso granular: qualquer pessoa com acesso ao share podia apagar arquivos de qualquer bloco.
A solução final é 100% serverless: Lambda serve a interface e gera URLs de upload, S3 recebe os arquivos direto do navegador via Presigned URLs, Cognito cuida da autenticação, DynamoDB mantém um log de auditoria, e CloudFront entrega tudo com baixa latência. Custo mensal em produção: menos de $5 para ~2.000 uploads/mês.
Arquitetura
┌─────────────────────────────────────────────────────────────────┐
│ NAVEGADOR (celular/PC) │
│ │
│ 1. Login (Cognito) │
│ 2. Seleciona bloco → pede Presigned URL │
│ 3. Faz PUT direto no S3 (sem passar pela Lambda!) │
│ 4. Notifica Lambda que o upload terminou │
└──────────┬──────────────────────────────────┬───────────────────┘
│ HTTPS │ HTTPS (PUT direto)
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ CloudFront │ │ Amazon S3 │
│ (CDN + behaviors) │ │ (bucket de mídia) │
└──────────┬──────────┘ └─────────────────────┘
│ ▲
▼ │ Presigned URL
┌─────────────────────┐ │
│ API Gateway │ │
│ (HTTP API) │ │
└──────────┬──────────┘ │
│ │
▼ │
┌─────────────────────────────────────────────┴───────────────────┐
│ AWS Lambda │
│ │
│ - Serve o frontend (HTML/CSS/JS) │
│ - Gera Presigned URLs (PutObject) │
│ - CRUD de usuários (Cognito) │
│ - Registra auditoria (DynamoDB) │
│ - Lê configurações (SSM Parameter Store) │
└──────────┬──────────┬──────────┬──────────┬─────────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│DynamoDB│ │Cognito │ │ SSM │ │ SES │
│(audit) │ │(users) │ │(config)│ │(email) │
└────────┘ └────────┘ └────────┘ └────────┘
PUT diretamente no S3. Isso significa que a Lambda não precisa lidar com payloads grandes — ela só gera a URL (operação leve, ~50ms) e o navegador faz o trabalho pesado.
O que eu usei
- Conta AWS com permissões de administrador (ou pelo menos acesso a S3, Lambda, API Gateway, DynamoDB, Cognito, SSM, SES, CloudFront e IAM)
- AWS CLI v2 configurado com
aws configure - AWS SAM CLI — o framework de deploy serverless da AWS
- Node.js 20+ (runtime da Lambda)
- Git
Instalação (macOS / Linux)
Instalação — macOS / Linux (clique para expandir)
# AWS CLI
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install
# SAM CLI
brew install aws-sam-cli
# ou via pip:
pip install aws-sam-cli
# Node.js 20 (via nvm)
nvm install 20
nvm use 20
# Verificar tudo
aws --version
sam --version
node --version
Instalação (Windows)
Instalação — Windows (clique para expandir)
# AWS CLI — baixe o MSI:
# https://awscli.amazonaws.com/AWSCLIV2.msi
# SAM CLI — baixe o MSI:
# https://github.com/aws/aws-sam-cli/releases/latest
# Node.js 20 — baixe o instalador:
# https://nodejs.org/
# Verificar tudo (PowerShell ou Git Bash)
aws --version
sam --version
node --version
Passo 1: Configurando o Projeto
Criei a estrutura do projeto do zero usando o SAM CLI:
Estrutura do projeto (clique para expandir)
# Inicializar o projeto
mkdir galeria-midia && cd galeria-midia
sam init --runtime nodejs20.x --name galeria-midia --app-template hello-world
# Estrutura final que vamos construir:
# galeria-midia/
# ├── template.yaml ← SAM template (infraestrutura)
# ├── src/
# │ ├── index.js ← Lambda principal
# │ ├── relatorio.js ← Lambda de relatório diário
# │ ├── package.json
# │ └── frontend/
# │ ├── index.html ← Interface do operador
# │ ├── login.html ← Tela de login
# │ └── admin.html ← Painel de administração
# └── .gitignore
Primeiro, o package.json com as dependências do AWS SDK v3. Usei o SDK v3 porque ele é modular — importei apenas os clientes necessários, reduzindo o tamanho do pacote da Lambda:
package.json (clique para expandir)
{
"name": "galeria-midia",
"version": "1.0.0",
"description": "Galeria de mídia serverless - Pedra Sul Mineração",
"private": true,
"dependencies": {
"@aws-sdk/client-s3": "^3.500.0",
"@aws-sdk/s3-request-presigner": "^3.500.0",
"@aws-sdk/client-dynamodb": "^3.500.0",
"@aws-sdk/client-cognito-identity-provider": "^3.500.0",
"@aws-sdk/client-ssm": "^3.500.0",
"@aws-sdk/client-ses": "^3.500.0"
}
}
O AWS SDK v2 (aws-sdk) inclui todos os 300+ serviços da AWS num único pacote de ~70 MB. O SDK v3 é modular: cada serviço é um pacote separado. Para Lambda, isso significa cold starts mais rápidos e menos memória consumida.
Passo 2: Template SAM (template.yaml)
O template.yaml é o coração do projeto. Ele define toda a infraestrutura como código: a Lambda, o DynamoDB, o Cognito, as permissões IAM, as rotas da API — tudo num único arquivo. Quando eu rodava sam deploy, o SAM transformava isso em um CloudFormation stack e criava todos os recursos automaticamente.
template.yaml — Infraestrutura completa (clique para expandir)
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Galeria de Midia Serverless
# ─── Globais ────────────────────────────────────────────────────
Globals:
Api:
BinaryMediaTypes:
- "*/*"
Cors:
AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'"
AllowHeaders: "'Content-Type,Authorization'"
AllowOrigin: "'*'"
# ─── Parâmetros ─────────────────────────────────────────────────
Parameters:
DestBucket:
Type: String
Description: Nome do bucket S3 para armazenar as midias
Default: meu-bucket-de-fotos
SesFromEmail:
Type: String
Description: Email remetente para relatorios (verificado no SES)
Default: noreply@meudominio.com
SesToEmail:
Type: String
Description: Email destinatario dos relatorios
Default: admin@meudominio.com
# ─── Recursos ───────────────────────────────────────────────────
Resources:
# ── DynamoDB: Tabela de Auditoria ──
AuditTable:
Type: AWS::DynamoDB::Table
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
TableName: galeria-audit-log
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: pk
AttributeType: S
- AttributeName: sk
AttributeType: S
- AttributeName: bloco
AttributeType: S
- AttributeName: ts
AttributeType: S
KeySchema:
- AttributeName: pk
KeyType: HASH
- AttributeName: sk
KeyType: RANGE
GlobalSecondaryIndexes:
- IndexName: bloco-ts-index
KeySchema:
- AttributeName: bloco
KeyType: HASH
- AttributeName: ts
KeyType: RANGE
Projection:
ProjectionType: ALL
TimeToLiveSpecification:
AttributeName: ttl
Enabled: true
# ── Cognito: User Pool ──
GaleriaUserPool:
Type: AWS::Cognito::UserPool
Properties:
UserPoolName: galeria-user-pool
AdminCreateUserConfig:
AllowAdminCreateUserOnly: true
InviteMessageTemplate:
EmailMessage: >
Seu acesso a Galeria de Midia foi criado.
Usuario: {username} | Senha temporaria: {####}
Acesse e troque sua senha no primeiro login.
EmailSubject: "Galeria de Midia - Seu acesso foi criado"
Policies:
PasswordPolicy:
MinimumLength: 8
RequireLowercase: true
RequireNumbers: true
RequireSymbols: false
RequireUppercase: false
Schema:
- Name: email
AttributeDataType: String
Required: true
Mutable: true
- Name: custom:role
AttributeDataType: String
Mutable: true
GaleriaUserPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
ClientName: galeria-web-client
UserPoolId: !Ref GaleriaUserPool
ExplicitAuthFlows:
- ALLOW_USER_PASSWORD_AUTH
- ALLOW_REFRESH_TOKEN_AUTH
GenerateSecret: false
# ── Lambda: Função Principal ──
GaleriaFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: galeria-midia-api
Handler: index.handler
Runtime: nodejs20.x
CodeUri: src/
MemorySize: 512
Timeout: 30
Environment:
Variables:
DEST_BUCKET: !Ref DestBucket
AUDIT_TABLE: !Ref AuditTable
USER_POOL_ID: !Ref GaleriaUserPool
USER_POOL_CLIENT_ID: !Ref GaleriaUserPoolClient
SES_FROM_EMAIL: !Ref SesFromEmail
SES_TO_EMAIL: !Ref SesToEmail
Policies:
# S3: ler, escrever e listar objetos
- Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- s3:PutObject
- s3:GetObject
- s3:DeleteObject
Resource: !Sub "arn:aws:s3:::${DestBucket}/*"
- Effect: Allow
Action:
- s3:ListBucket
Resource: !Sub "arn:aws:s3:::${DestBucket}"
# DynamoDB: leitura e escrita na tabela de auditoria
- Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- dynamodb:PutItem
- dynamodb:GetItem
- dynamodb:Query
- dynamodb:Scan
Resource:
- !GetAtt AuditTable.Arn
- !Sub "${AuditTable.Arn}/index/*"
# Cognito: gerenciar usuários
- Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- cognito-idp:AdminCreateUser
- cognito-idp:AdminDeleteUser
- cognito-idp:AdminGetUser
- cognito-idp:AdminInitiateAuth
- cognito-idp:AdminRespondToAuthChallenge
- cognito-idp:ListUsers
Resource: !GetAtt GaleriaUserPool.Arn
# SSM: ler parâmetros de configuração
- Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- ssm:GetParameter
- ssm:GetParameters
Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/galeria/*"
# SES: enviar relatórios por email
- Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- ses:SendEmail
- ses:SendRawEmail
Resource: "*"
Events:
# Rotas da API
RootGet:
Type: Api
Properties:
Path: /
Method: get
LoginPage:
Type: Api
Properties:
Path: /login
Method: get
AdminPage:
Type: Api
Properties:
Path: /admin
Method: get
ApiLogin:
Type: Api
Properties:
Path: /api/login
Method: post
ApiPresign:
Type: Api
Properties:
Path: /api/presign
Method: post
ApiNotify:
Type: Api
Properties:
Path: /api/notify
Method: post
ApiList:
Type: Api
Properties:
Path: /api/list
Method: get
ApiDelete:
Type: Api
Properties:
Path: /api/delete
Method: post
ApiUsers:
Type: Api
Properties:
Path: /api/users
Method: get
ApiCreateUser:
Type: Api
Properties:
Path: /api/users
Method: post
ApiDeleteUser:
Type: Api
Properties:
Path: /api/users/delete
Method: post
ApiAudit:
Type: Api
Properties:
Path: /api/audit
Method: get
CorsOptions:
Type: Api
Properties:
Path: /{proxy+}
Method: options
# ── Lambda: Relatório Diário ──
RelatorioFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: galeria-relatorio-diario
Handler: relatorio.handler
Runtime: nodejs20.x
CodeUri: src/
MemorySize: 256
Timeout: 60
Environment:
Variables:
DEST_BUCKET: !Ref DestBucket
AUDIT_TABLE: !Ref AuditTable
SES_FROM_EMAIL: !Ref SesFromEmail
SES_TO_EMAIL: !Ref SesToEmail
Policies:
- Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- dynamodb:Query
- dynamodb:Scan
Resource:
- !GetAtt AuditTable.Arn
- !Sub "${AuditTable.Arn}/index/*"
- Effect: Allow
Action:
- ses:SendEmail
Resource: "*"
Events:
DailySchedule:
Type: Schedule
Properties:
Schedule: cron(0 21 * * ? *)
Description: Relatorio diario as 18h BRT (21h UTC)
Enabled: true
# ─── Outputs ────────────────────────────────────────────────────
Outputs:
ApiEndpoint:
Description: URL da API Gateway
Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
UserPoolId:
Description: ID do Cognito User Pool
Value: !Ref GaleriaUserPool
UserPoolClientId:
Description: ID do Cognito User Pool Client
Value: !Ref GaleriaUserPoolClient
AuditTableName:
Description: Nome da tabela DynamoDB
Value: !Ref AuditTable
Retain garante que a tabela sobrevive mesmo se o stack for removido.
Pontos importantes do template:
- BinaryMediaTypes:
"*/*"— instrui o API Gateway a tratar qualquer Content-Type como binário. Sem isso, respostas HTML são corrompidas com encoding base64. - AllowAdminCreateUserOnly: true — ninguém pode se auto-registrar no Cognito. Apenas um admin pode criar contas, garantindo controle total de acesso.
- GSI (bloco-ts-index) — permite consultar todos os eventos de um bloco ordenados por timestamp, essencial para auditoria.
- TTL na tabela — registros antigos podem expirar automaticamente (útil para logs que não precisam ser mantidos indefinidamente).
- Lambda com 512 MB — mesmo que a função seja leve, o Node.js no Lambda tem performance proporcional à memória alocada. 512 MB dá um bom equilíbrio custo/performance.
Passo 3: Lambda Principal (index.js)
A Lambda principal é o "cérebro" da aplicação. Ela serve as páginas HTML, autentica usuários via Cognito, gera Presigned URLs para upload, lista arquivos, gerencia usuários e registra tudo no DynamoDB.
Construí o código completo assim:
index.js — Lambda principal completa (clique para expandir)
// ─── Imports do AWS SDK v3 ────────────────────────────────────
const { S3Client, PutObjectCommand, DeleteObjectCommand,
ListObjectsV2Command } = require("@aws-sdk/client-s3");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
const { DynamoDBClient, PutItemCommand, QueryCommand,
ScanCommand } = require("@aws-sdk/client-dynamodb");
const { CognitoIdentityProviderClient, AdminCreateUserCommand,
AdminDeleteUserCommand, AdminInitiateAuthCommand,
AdminRespondToAuthChallengeCommand,
ListUsersCommand } = require("@aws-sdk/client-cognito-identity-provider");
const { SSMClient, GetParameterCommand } = require("@aws-sdk/client-ssm");
const fs = require("fs");
const path = require("path");
// ─── Clientes (inicializados fora do handler para reutilizar entre invocações)
const s3 = new S3Client({});
const ddb = new DynamoDBClient({});
const cognito = new CognitoIdentityProviderClient({});
const ssm = new SSMClient({});
// ─── Variáveis de Ambiente ────────────────────────────────────
const BUCKET = process.env.DEST_BUCKET;
const TABLE = process.env.AUDIT_TABLE;
const POOL_ID = process.env.USER_POOL_ID;
const CLIENT_ID = process.env.USER_POOL_CLIENT_ID;
// ─── Cache de configurações do SSM ───────────────────────────
const ssmCache = {};
const SSM_TTL = 5 * 60 * 1000; // 5 minutos
async function getConfig(paramName) {
const now = Date.now();
if (ssmCache[paramName] && (now - ssmCache[paramName].ts) < SSM_TTL) {
return ssmCache[paramName].value;
}
try {
const resp = await ssm.send(new GetParameterCommand({
Name: `/galeria/${paramName}`,
WithDecryption: true,
}));
ssmCache[paramName] = { value: resp.Parameter.Value, ts: now };
return resp.Parameter.Value;
} catch (err) {
console.warn(`SSM param /galeria/${paramName} nao encontrado:`, err.message);
return null;
}
}
// ─── Cache de arquivos HTML do frontend ──────────────────────
const htmlCache = {};
function loadHtml(fileName) {
if (htmlCache[fileName]) return htmlCache[fileName];
const filePath = path.join(__dirname, "frontend", fileName);
const content = fs.readFileSync(filePath, "utf-8");
htmlCache[fileName] = content;
return content;
}
// ─── Helpers de Resposta ─────────────────────────────────────
function jsonResponse(statusCode, body) {
return {
statusCode,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type,Authorization",
},
body: JSON.stringify(body),
};
}
function htmlResponse(html) {
return {
statusCode: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
"Access-Control-Allow-Origin": "*",
},
body: html,
isBase64Encoded: false,
};
}
function cors204() {
return {
statusCode: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type,Authorization",
},
body: "",
};
}
// ─── Auditoria (fire-and-forget) ─────────────────────────────
async function logAudit(action, user, bloco, details = {}) {
try {
const now = new Date().toISOString();
await ddb.send(new PutItemCommand({
TableName: TABLE,
Item: {
pk: { S: `AUDIT#${now.slice(0, 10)}` },
sk: { S: `${now}#${Math.random().toString(36).slice(2, 8)}` },
action: { S: action },
user: { S: user || "anonymous" },
bloco: { S: bloco || "N/A" },
ts: { S: now },
details: { S: JSON.stringify(details) },
ttl: { N: String(Math.floor(Date.now() / 1000) + 90 * 86400) }, // 90 dias
},
}));
} catch (err) {
// Fire-and-forget: não queremos que falha de auditoria quebre o upload
console.error("Erro ao registrar auditoria:", err.message);
}
}
// ─── Handler Principal (Router Manual) ───────────────────────
exports.handler = async (event) => {
const method = event.httpMethod;
const routePath = event.path;
console.log(`${method} ${routePath}`);
// CORS preflight
if (method === "OPTIONS") {
return cors204();
}
try {
// ── Rotas de Páginas HTML ──
if (method === "GET" && routePath === "/") {
return htmlResponse(loadHtml("index.html"));
}
if (method === "GET" && routePath === "/login") {
return htmlResponse(loadHtml("login.html"));
}
if (method === "GET" && routePath === "/admin") {
return htmlResponse(loadHtml("admin.html"));
}
// ── API: Login ──
if (method === "POST" && routePath === "/api/login") {
return await handleLogin(JSON.parse(event.body));
}
// ── API: Presigned URL ──
if (method === "POST" && routePath === "/api/presign") {
return await handlePresign(JSON.parse(event.body));
}
// ── API: Notificação pós-upload ──
if (method === "POST" && routePath === "/api/notify") {
return await handleNotify(JSON.parse(event.body));
}
// ── API: Listar arquivos de um bloco ──
if (method === "GET" && routePath === "/api/list") {
const bloco = event.queryStringParameters?.bloco;
return await handleList(bloco);
}
// ── API: Deletar arquivo ──
if (method === "POST" && routePath === "/api/delete") {
return await handleDelete(JSON.parse(event.body));
}
// ── API: Listar usuários ──
if (method === "GET" && routePath === "/api/users") {
return await handleListUsers();
}
// ── API: Criar usuário ──
if (method === "POST" && routePath === "/api/users") {
return await handleCreateUser(JSON.parse(event.body));
}
// ── API: Deletar usuário ──
if (method === "POST" && routePath === "/api/users/delete") {
return await handleDeleteUser(JSON.parse(event.body));
}
// ── API: Consultar auditoria ──
if (method === "GET" && routePath === "/api/audit") {
const bloco = event.queryStringParameters?.bloco;
const date = event.queryStringParameters?.date;
return await handleAudit(bloco, date);
}
return jsonResponse(404, { error: "Rota nao encontrada" });
} catch (err) {
console.error("Erro no handler:", err);
return jsonResponse(500, { error: "Erro interno do servidor" });
}
};
// ─── Handlers de Rota ────────────────────────────────────────
async function handleLogin({ username, password }) {
try {
const authResp = await cognito.send(new AdminInitiateAuthCommand({
UserPoolId: POOL_ID,
ClientId: CLIENT_ID,
AuthFlow: "ADMIN_USER_PASSWORD_AUTH",
AuthParameters: {
USERNAME: username,
PASSWORD: password,
},
}));
// Primeiro login: Cognito exige troca de senha
if (authResp.ChallengeName === "NEW_PASSWORD_REQUIRED") {
return jsonResponse(200, {
challenge: "NEW_PASSWORD_REQUIRED",
session: authResp.Session,
message: "Troque sua senha temporaria",
});
}
const token = authResp.AuthenticationResult.IdToken;
await logAudit("LOGIN", username, null);
return jsonResponse(200, {
token,
message: "Login realizado com sucesso",
});
} catch (err) {
console.error("Erro no login:", err.message);
return jsonResponse(401, { error: "Credenciais invalidas" });
}
}
async function handlePresign({ bloco, fileName, contentType, tipo }) {
if (!bloco || !fileName) {
return jsonResponse(400, { error: "bloco e fileName sao obrigatorios" });
}
// Montar a key no S3: blocos/{codigo}/{tipo}/{arquivo}
const prefix = tipo === "thumb" ? "thumbs" : "originais";
const key = `blocos/${bloco}/${prefix}/${fileName}`;
// Presigned URL com expiração curta
// Fotos: 5 min | Vídeos: 15 min (arquivos maiores)
const isVideo = contentType?.startsWith("video/");
const expiresIn = isVideo ? 900 : 300;
const command = new PutObjectCommand({
Bucket: BUCKET,
Key: key,
ContentType: contentType || "application/octet-stream",
});
const presignedUrl = await getSignedUrl(s3, command, { expiresIn });
return jsonResponse(200, {
url: presignedUrl,
key,
expiresIn,
});
}
async function handleNotify({ bloco, key, user, action = "UPLOAD" }) {
await logAudit(action, user, bloco, { key });
return jsonResponse(200, { message: "Registrado" });
}
async function handleList(bloco) {
if (!bloco) {
return jsonResponse(400, { error: "Parametro bloco e obrigatorio" });
}
const prefix = `blocos/${bloco}/originais/`;
const thumbPrefix = `blocos/${bloco}/thumbs/`;
// Listar originais e thumbnails em paralelo
const [origResp, thumbResp] = await Promise.all([
s3.send(new ListObjectsV2Command({ Bucket: BUCKET, Prefix: prefix })),
s3.send(new ListObjectsV2Command({ Bucket: BUCKET, Prefix: thumbPrefix })),
]);
const originais = (origResp.Contents || []).map((obj) => ({
key: obj.Key,
size: obj.Size,
lastModified: obj.LastModified,
fileName: obj.Key.split("/").pop(),
}));
const thumbs = (thumbResp.Contents || []).map((obj) => ({
key: obj.Key,
fileName: obj.Key.split("/").pop(),
}));
return jsonResponse(200, { bloco, originais, thumbs });
}
async function handleDelete({ key, user, bloco }) {
if (!key) {
return jsonResponse(400, { error: "Parametro key e obrigatorio" });
}
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key }));
await logAudit("DELETE", user, bloco, { key });
return jsonResponse(200, { message: "Arquivo deletado" });
}
async function handleListUsers() {
const resp = await cognito.send(new ListUsersCommand({
UserPoolId: POOL_ID,
Limit: 60,
}));
const users = (resp.Users || []).map((u) => ({
username: u.Username,
status: u.UserStatus,
enabled: u.Enabled,
created: u.UserCreateDate,
email: u.Attributes?.find((a) => a.Name === "email")?.Value,
role: u.Attributes?.find((a) => a.Name === "custom:role")?.Value || "operador",
}));
return jsonResponse(200, { users });
}
async function handleCreateUser({ username, email, role = "operador" }) {
if (!username || !email) {
return jsonResponse(400, { error: "username e email sao obrigatorios" });
}
await cognito.send(new AdminCreateUserCommand({
UserPoolId: POOL_ID,
Username: username,
UserAttributes: [
{ Name: "email", Value: email },
{ Name: "email_verified", Value: "true" },
{ Name: "custom:role", Value: role },
],
DesiredDelivery: "EMAIL",
}));
await logAudit("CREATE_USER", "admin", null, { username, email, role });
return jsonResponse(201, { message: `Usuario ${username} criado com sucesso` });
}
async function handleDeleteUser({ username }) {
if (!username) {
return jsonResponse(400, { error: "username e obrigatorio" });
}
await cognito.send(new AdminDeleteUserCommand({
UserPoolId: POOL_ID,
Username: username,
}));
await logAudit("DELETE_USER", "admin", null, { username });
return jsonResponse(200, { message: `Usuario ${username} removido` });
}
async function handleAudit(bloco, date) {
if (bloco) {
// Consultar por bloco usando o GSI
const resp = await ddb.send(new QueryCommand({
TableName: TABLE,
IndexName: "bloco-ts-index",
KeyConditionExpression: "bloco = :b",
ExpressionAttributeValues: { ":b": { S: bloco } },
ScanIndexForward: false,
Limit: 100,
}));
return jsonResponse(200, { items: resp.Items });
}
if (date) {
// Consultar por data na partition key
const resp = await ddb.send(new QueryCommand({
TableName: TABLE,
KeyConditionExpression: "pk = :pk",
ExpressionAttributeValues: { ":pk": { S: `AUDIT#${date}` } },
ScanIndexForward: false,
Limit: 100,
}));
return jsonResponse(200, { items: resp.Items });
}
// Sem filtros: scan limitado (últimos registros)
const resp = await ddb.send(new ScanCommand({
TableName: TABLE,
Limit: 50,
}));
return jsonResponse(200, { items: resp.Items });
}
Em vez de usar um framework como Express (que adicionaria peso desnecessário à Lambda), usei um simples if/else no event.path e event.httpMethod. Para ~12 rotas, essa abordagem é mais rápida, mais leve e mais fácil de debugar do que qualquer framework.
logAudit tem um try/catch que engole erros silenciosamente (só loga no CloudWatch). Isso é intencional: se o DynamoDB estiver com problemas, eu não queria que isso impedisse o operador de fazer upload. A auditoria é importante, mas a operação do negócio vem primeiro.
Passo 4: Frontend Embutido na Lambda
Uma decisão arquitetural que tomei: o frontend (HTML/CSS/JS) é servido pela própria Lambda em vez de ficar em um bucket S3 separado. A função usa fs.readFileSync com cache em memória para servir os arquivos.
index.js — Cache de HTML no frontend (clique para expandir)
// Este trecho já está no index.js acima, mas vale destaque:
const htmlCache = {};
function loadHtml(fileName) {
if (htmlCache[fileName]) return htmlCache[fileName];
const filePath = path.join(__dirname, "frontend", fileName);
const content = fs.readFileSync(filePath, "utf-8");
htmlCache[fileName] = content;
return content;
}
// Na rota GET /:
if (method === "GET" && routePath === "/") {
return htmlResponse(loadHtml("index.html"));
}
Trade-offs dessa abordagem
| Aspecto | Frontend na Lambda | Frontend no S3 |
|---|---|---|
| Deploy | Um sam deploy atualiza tudo |
Precisa de aws s3 sync separado |
| Latência | ~50ms a mais no cold start | Direto do S3/CloudFront |
| Simplicidade | Um único artefato, zero config extra | Precisa configurar bucket, policy, OAI |
| Cache | Em memória na Lambda (entre invocações) | Nativo do CloudFront |
| Custo | Invocação Lambda por page view | Só custo de S3 GET |
Para um painel interno com poucos usuários (5-20 acessos simultâneos), a abordagem Lambda é perfeitamente adequada e drasticamente mais simples de manter. Se o tráfego crescer significativamente, migrar o frontend para S3 + CloudFront é uma refatoração trivial.
Passo 5: Upload Direto ao S3
Este é o coração da performance da solução que construí. O fluxo de upload acontece em 3 etapas:
- Presign: o frontend pede à Lambda uma Presigned URL para o arquivo específico
- PUT: o navegador faz o upload diretamente para o S3, sem passar pela Lambda
- Notify: o frontend avisa a Lambda que o upload terminou (para registro de auditoria)
┌──────────┐ 1. POST /api/presign ┌──────────┐
│ │ ──────────────────────────► │ │
│ │ { url, key, expiresIn } │ Lambda │
│ Browser │ ◄────────────────────────── │ │
│ │ └──────────┘
│ │ 2. PUT (arquivo binário)
│ │ ──────────────────────────► ┌──────────┐
│ │ 200 OK │ S3 │
│ │ ◄────────────────────────── └──────────┘
│ │
│ │ 3. POST /api/notify ┌──────────┐
│ │ ──────────────────────────► │ Lambda │
│ │ ◄────────────────────────── │(DynamoDB)│
└──────────┘ { message: "Registrado" } └──────────┘
O código do frontend para realizar o upload com essa cadeia sequencial:
Frontend — upload com Presigned URL (clique para expandir)
// ─── Upload com Presigned URL (frontend) ─────────────────────
async function uploadFile(file, bloco, user) {
const statusEl = document.getElementById("upload-status");
try {
// Etapa 1: Obter Presigned URL
statusEl.textContent = "Gerando URL de upload...";
const presignResp = await fetch("/api/presign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bloco: bloco,
fileName: file.name,
contentType: file.type,
tipo: "original",
}),
});
if (!presignResp.ok) throw new Error("Erro ao gerar URL");
const { url, key } = await presignResp.json();
// Etapa 2: Upload direto ao S3
statusEl.textContent = `Enviando ${file.name}...`;
const uploadResp = await fetch(url, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
if (!uploadResp.ok) throw new Error("Erro no upload para S3");
// Etapa 3: Notificar a Lambda (auditoria)
statusEl.textContent = "Registrando...";
await fetch("/api/notify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bloco: bloco,
key: key,
user: user,
action: "UPLOAD",
}),
});
statusEl.textContent = `${file.name} enviado com sucesso!`;
} catch (err) {
console.error("Erro no upload:", err);
statusEl.textContent = `Erro: ${err.message}`;
}
}
// ─── Upload de múltiplos arquivos (sequencial) ───────────────
async function uploadMultiple(files, bloco, user) {
for (let i = 0; i < files.length; i++) {
const progress = `[${i + 1}/${files.length}]`;
document.getElementById("upload-progress").textContent = progress;
await uploadFile(files[i], bloco, user);
}
document.getElementById("upload-progress").textContent = "Todos os arquivos enviados!";
}
Passo 6: Thumbnails no Cliente
Para exibir uma galeria rápida sem carregar fotos de 5-10 MB cada, optei por gerar thumbnails no próprio navegador usando a Canvas API. Isso acontece antes do upload:
Frontend — geração de thumbnail + upload (clique para expandir)
// ─── Geração de Thumbnail no Cliente ─────────────────────────
function generateThumbnail(file, maxWidth = 300) {
return new Promise((resolve, reject) => {
// Só gera thumb para imagens, não para vídeos
if (!file.type.startsWith("image/")) {
resolve(null);
return;
}
const img = new Image();
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
img.onload = () => {
// Calcular dimensões mantendo proporção
const ratio = maxWidth / img.width;
canvas.width = maxWidth;
canvas.height = img.height * ratio;
// Desenhar a imagem redimensionada
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// Converter para Blob (JPEG com qualidade 70%)
canvas.toBlob(
(blob) => {
if (blob) {
resolve(new File([blob], `thumb_${file.name}`, {
type: "image/jpeg",
}));
} else {
reject(new Error("Falha ao gerar thumbnail"));
}
},
"image/jpeg",
0.7
);
};
img.onerror = () => reject(new Error("Falha ao carregar imagem"));
img.src = URL.createObjectURL(file);
});
}
// ─── Upload completo: original + thumbnail ───────────────────
async function uploadWithThumbnail(file, bloco, user) {
// 1. Gerar thumbnail (se for imagem)
const thumb = await generateThumbnail(file);
// 2. Upload do original
await uploadFile(file, bloco, user);
// 3. Upload do thumbnail (se existir)
if (thumb) {
const presignResp = await fetch("/api/presign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bloco: bloco,
fileName: thumb.name,
contentType: "image/jpeg",
tipo: "thumb",
}),
});
const { url } = await presignResp.json();
await fetch(url, {
method: "PUT",
headers: { "Content-Type": "image/jpeg" },
body: thumb,
});
}
}
1. Custo zero: a CPU do celular do operador faz o trabalho, não a Lambda.
2. Sem dependências nativas: bibliotecas de manipulação de imagem no Lambda (Sharp, ImageMagick) exigem layers com binários compilados para Amazon Linux. A Canvas API do navegador funciona em qualquer dispositivo.
3. Velocidade: o thumbnail é gerado em milissegundos, antes mesmo do upload começar.
Passo 7: Segurança
Segurança em produção não é opcional. Cobri todas as camadas:
7.1 Autenticação com Cognito
O Amazon Cognito é o serviço de identidade da AWS. Ele gerencia usuários, senhas, tokens JWT e fluxos de autenticação — sem que eu precisasse construir nada disso do zero.
Configurei o User Pool com AllowAdminCreateUserOnly: true, o que significa que ninguém pode se auto-registrar. Apenas um administrador (via painel ou API) pode criar novas contas. Quando um usuário é criado, ele recebe um email com uma senha temporária e é obrigado a trocar no primeiro login.
7.2 SSM Parameter Store para Segredos
Credenciais e configurações sensíveis nunca ficam no código ou em variáveis de ambiente em texto puro. Usei o AWS Systems Manager Parameter Store para armazená-las de forma segura e criptografada:
SSM Parameter Store — criação de parâmetros (clique para expandir)
# Criar parâmetros no SSM (executar uma vez)
aws ssm put-parameter \
--name "/galeria/api-key" \
--type "SecureString" \
--value "seu-segredo-aqui" \
--description "Chave de API para integracao externa"
aws ssm put-parameter \
--name "/galeria/allowed-origins" \
--type "String" \
--value "https://galeria.meudominio.com" \
--description "Origens permitidas para CORS"
aws ssm put-parameter \
--name "/galeria/max-file-size" \
--type "String" \
--value "104857600" \
--description "Tamanho maximo de arquivo em bytes (100MB)"
Na Lambda, o cache do SSM evita chamadas repetidas (o código já foi mostrado no Passo 3):
index.js — Cache do SSM com TTL (clique para expandir)
// Cache com TTL de 5 minutos
// Se o parâmetro mudar no SSM, a Lambda pega o novo valor
// no máximo 5 minutos depois — sem precisar de redeploy.
const ssmCache = {};
const SSM_TTL = 5 * 60 * 1000;
async function getConfig(paramName) {
const now = Date.now();
if (ssmCache[paramName] && (now - ssmCache[paramName].ts) < SSM_TTL) {
return ssmCache[paramName].value;
}
const resp = await ssm.send(new GetParameterCommand({
Name: `/galeria/${paramName}`,
WithDecryption: true, // descriptografa SecureString automaticamente
}));
ssmCache[paramName] = { value: resp.Parameter.Value, ts: now };
return resp.Parameter.Value;
}
7.3 IAM Least-Privilege
Cada policy no template.yaml dá à Lambda apenas as permissões mínimas necessárias:
- S3:
PutObject,GetObject,DeleteObjectapenas no bucket específico.ListBucketno ARN do bucket (sem/*). - DynamoDB: apenas
PutItem,GetItem,Query,Scanna tabela de auditoria e seus índices. - Cognito: apenas operações
Admin*no User Pool específico. - SSM: apenas
GetParameterem parâmetros com prefixo/galeria/*.
s3:ListBucket se aplica ao bucket (arn:aws:s3:::meu-bucket), não aos objetos (arn:aws:s3:::meu-bucket/*). Se colocar /* no final, o ListBucket simplesmente não funciona e retorna Access Denied — sem nenhuma mensagem clara.
7.4 CORS no Bucket S3
Para que o navegador possa fazer PUT direto no S3 via Presigned URL, o bucket precisa de uma configuração CORS:
cors.json — configuração CORS do bucket S3 (clique para expandir)
{
"CORSRules": [
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["PUT", "GET"],
"AllowedOrigins": ["https://galeria.meudominio.com"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
}
# Aplicar configuração CORS ao bucket
aws s3api put-bucket-cors \
--bucket meu-bucket-de-fotos \
--cors-configuration file://cors.json
PUT, o S3 rejeita por CORS, e o erro aparece no console do browser como "blocked by CORS policy". A Presigned URL está correta, a permissão IAM está correta, mas sem CORS no bucket, nada funciona.
7.5 Expiração de Presigned URLs
Defini tempos de expiração agressivos:
- Fotos: 5 minutos (
expiresIn: 300) — mais que suficiente para uma foto de 5-10 MB - Vídeos: 15 minutos (
expiresIn: 900) — necessário para vídeos de 50-100 MB em conexões lentas
Depois que a URL expira, ela se torna completamente inválida. Mesmo que alguém intercepte a URL, ela não serve para nada após o timeout.
Passo 8: CloudFront
O Amazon CloudFront é a CDN (Content Delivery Network) da AWS. Ele distribui o conteúdo por servidores espalhados pelo mundo (edge locations), reduzindo a latência para o usuário final. No meu caso, ele serviu duas funções:
- Cache de imagens: quando um operador acessa a galeria, as thumbnails são servidas do edge mais próximo em vez de ir até o S3 na região da AWS.
- Ponto de entrada único: tanto a API (Lambda) quanto as imagens (S3) ficam no mesmo domínio, evitando problemas de CORS entre subdomínios.
Behaviors e Prioridade
O CloudFront usa behaviors para decidir de onde buscar o conteúdo baseado no caminho da URL. A ordem de prioridade importa:
┌─────────────────────────────────────────────────────────┐
│ CloudFront Distribution │
│ │
│ Behavior 1 (prioridade 0): │
│ Path: /api/* │
│ Origin: API Gateway │
│ Cache: desabilitado (CachingDisabled) │
│ → Todas as chamadas de API vão para a Lambda │
│ │
│ Behavior 2 (prioridade 1): │
│ Path: /blocos/* │
│ Origin: S3 Bucket (OAI) │
│ Cache: 24h (CachingOptimized) │
│ → Imagens e vídeos servidos direto do S3 │
│ │
│ Behavior 3 (default, prioridade 2): │
│ Path: * (tudo que não bateu acima) │
│ Origin: API Gateway │
│ Cache: desabilitado │
│ → Páginas HTML servidas pela Lambda │
└─────────────────────────────────────────────────────────┘
/blocos/* estava configurado com Compress: true (gzip). O CloudFront tentava comprimir o .mp4 (que já é comprimido), corrompia o cabeçalho e o S3 retornava Access Denied porque o Content-Type não batia. A solução: desabilitar compressão para o behavior de mídia, ou usar uma whitelist de Content-Types comprimíveis.
Passo 9: Deploy com SAM
Com toda a infraestrutura definida no template.yaml e o código pronto, o deploy foi surpreendentemente simples:
Comandos de build e deploy (clique para expandir)
# Instalar dependências
cd src && npm install && cd ..
# Build (empacota a Lambda)
sam build
# Deploy guiado (primeira vez)
sam deploy --guided
O sam deploy --guided faz uma série de perguntas interativas:
sam deploy --guided — perguntas interativas (clique para expandir)
Configuring SAM deploy
======================
Setting default arguments for 'sam deploy'
=========================================
Stack Name [galeria-midia]: # Nome do CloudFormation stack
AWS Region [us-east-1]: sa-east-1 # São Paulo (mais perto)
Parameter DestBucket [meu-bucket-de-fotos]: # Nome do seu bucket
Parameter SesFromEmail [noreply@meudominio.com]:
Parameter SesToEmail [admin@meudominio.com]:
Confirm changes before deploy [Y/n]: y
Allow SAM CLI IAM role creation [Y/n]: y # SAM cria os roles IAM
Disable rollback [y/N]: n
Save arguments to configuration file [Y/n]: y # Salva em samconfig.toml
SAM configuration file [samconfig.toml]:
SAM configuration environment [default]:
O que acontece durante o deploy:
sam buildcria um diretório.aws-sam/com o código e as dependências empacotadassam deployfaz upload do pacote para um bucket S3 do CloudFormation- O CloudFormation cria/atualiza os recursos na seguinte ordem: DynamoDB → Cognito → IAM Roles → Lambda → API Gateway
- Após ~2-3 minutos, o stack está pronto e os Outputs mostram a URL da API
Output do deploy (clique para expandir)
# Output do deploy:
CloudFormation outputs from deployed stack
───────────────────────────────────────────────────────────
Key Value
───────────────────────────────────────────────────────────
ApiEndpoint https://abc123xyz.execute-api.sa-east-1.amazonaws.com/Prod/
UserPoolId sa-east-1_AbCdEfGhI
UserPoolClientId 1a2b3c4d5e6f7g8h9i0j
AuditTableName galeria-audit-log
───────────────────────────────────────────────────────────
--guided, o SAM salva as configurações em samconfig.toml. Nos próximos deploys, basta rodar sam build && sam deploy sem perguntas.
AllowAdminCreateUserOnly, eu precisei criar o primeiro usuário pelo CLI:
Cognito — criar primeiro admin (clique para expandir)
aws cognito-idp admin-create-user \
--user-pool-id sa-east-1_ExEmPlO \
--username admin \
--user-attributes Name=email,Value=admin@exemplo.empresa.com.br Name=email_verified,Value=true Name=custom:role,Value=admin \
--temporary-password "SenhaTemp123"
Passo 10: GitHub e Boas Práticas
Ao versionar o projeto, é crítico não vazar credenciais ou artefatos desnecessários:
.gitignore (clique para expandir)
# .gitignore
node_modules/
.aws-sam/
samconfig.toml # contém nome do bucket e região
.env
*.zip
# Arquivos do OS
.DS_Store
Thumbs.db
samconfig.toml! Ele contém o nome do bucket S3 do CloudFormation, a região e os valores dos parâmetros (incluindo nomes de buckets reais). Use SSM Parameter Store para qualquer configuração sensível e mantenha o samconfig.toml no .gitignore.
Boas práticas para o repositório:
- Branches: use
mainpara produção edevpara desenvolvimento. Deploy automático (CI/CD) apenas a partir demain. - Commits semânticos:
feat: add thumbnail generation,fix: CORS headers on presign,chore: update SDK versions. - Sem segredos no código: use variáveis de ambiente (referenciando SSM) para qualquer configuração que mude entre ambientes.
- README: documente como rodar localmente (
sam local start-api) e como fazer deploy.
Problemas Reais e Soluções
Estes são problemas que encontrei em produção — não em tutoriais. Cada um custou horas de debugging:
Problema 1: BinaryMediaTypes no API Gateway
template.yaml — BinaryMediaTypes fix (clique para expandir)
# SEM isso no Globals:
Globals:
Api:
BinaryMediaTypes:
- "*/*"
# O API Gateway converte a resposta HTML da Lambda para Base64,
# e o navegador exibe lixo em vez da página.
# Com "*/*", ele trata tudo como binário e a resposta chega intacta.
*/* como binary media type e setar isBase64Encoded: false na resposta da Lambda.
Problema 2: ListBucket com ARN errado
IAM — ListBucket ARN correto vs errado (clique para expandir)
# ERRADO (não funciona):
- Effect: Allow
Action:
- s3:ListBucket
Resource: !Sub "arn:aws:s3:::${DestBucket}/*"
# ← O /* é para OBJETOS, não para o BUCKET
# CORRETO:
- Effect: Allow
Action:
- s3:ListBucket
Resource: !Sub "arn:aws:s3:::${DestBucket}"
# ← Sem /* — é uma operação no bucket, não nos objetos
Problema 3: Rotas não declaradas no SAM
template.yaml — rotas faltando no Events (clique para expandir)
# Se a Lambda tem um handler para /api/audit
# mas o template.yaml não declara o Event:
#
# ApiAudit:
# Type: Api
# Properties:
# Path: /api/audit
# Method: get
#
# Resultado: a rota funciona com `sam local start-api`
# mas retorna 403 {"message":"Missing Authentication Token"}
# depois do deploy.
#
# O API Gateway só conhece as rotas declaradas no template.
Problema 4: CloudFront Behavior Conflicts
CloudFront — conflito de behaviors (clique para expandir)
# Se existem dois behaviors:
# /blocos/* → S3
# /* → API Gateway (default)
#
# E adiciona um terceiro:
# /blocos/thumbs/* → S3 (com cache diferente)
#
# O CloudFront usa a rota MAIS ESPECÍFICA que bater.
# /blocos/thumbs/foto.jpg bate em /blocos/thumbs/* (prioridade 2)
# /blocos/originais/foto.jpg bate em /blocos/* (prioridade 1)
#
# Se a prioridade estiver errada, o behavior errado responde
# e o resultado é 403 ou conteúdo cacheado quando não devia.
Problema 5: HTML com divs mal aninhadas
HTML — problema de divs mal aninhadas (clique para expandir)
# Quando o frontend é servido pela Lambda como string:
# Um </div> faltando ou sobrando pode não causar erro visual
# no Chrome (que é tolerante), mas quebra completamente
# no Safari mobile (que é mais rigoroso).
#
# Solução: use um validador HTML antes do deploy.
# https://validator.w3.org/ ou extensão do VS Code.
Checklist Final para Produção
Antes de liberar para os operadores, validei cada item:
☑ Bucket S3 criado com versionamento habilitado
☑ CORS configurado no bucket (PUT e GET)
☑ Cognito User Pool criado e primeiro admin cadastrado
☑ SSM Parameters criados (
/galeria/*)
☑ SES: emails de remetente e destinatário verificados
☑ Lambda deploy com
sam build && sam deploy
☑ Testar login (credenciais válidas e inválidas)
☑ Testar upload de foto (celular e desktop)
☑ Testar upload de vídeo (até 100 MB)
☑ Verificar que thumbnails aparecem na galeria
☑ Verificar registros no DynamoDB (auditoria)
☑ CloudFront behaviors na ordem correta
☑ Testar com rede lenta (Chrome DevTools → Network → Slow 3G)
☑
.gitignore inclui samconfig.toml e node_modules
☑ Nenhuma credencial hardcoded no código
☑ Relatório diário (SES) funcionando
Conclusão e Resultados
Antes vs Depois
| Métrica | File Gateway (antes) | Serverless (depois) |
|---|---|---|
| Upload de foto (5 MB) | 15-30 segundos | 2-3 segundos |
| Upload de vídeo (50 MB) | 2-5 minutos (quando não travava) | 15-30 segundos |
| Custo mensal | ~$150 (EC2 m5.xlarge 24/7) | ~$5 (Lambda + S3 + DynamoDB) |
| Adesão dos operadores | ~30% (maioria usava WhatsApp) | ~95% |
| Organização | Pasta rede com nomes aleatórios | S3 organizado por bloco + auditoria |
| Controle de acesso | Share SMB aberto | Cognito com roles por usuário |