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.
Meu objetivo era claro: construir uma interface web que qualquer operador pudesse abrir no celular, selecionar o bloco, tirar fotos ou gravar vídeos, e ter tudo organizado automaticamente no S3 — rápido, seguro e sem servidor permanente.

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

Diagrama de Arquitetura
Fluxo completo: navegador do operador → CloudFront → API Gateway → Lambda → serviços AWS
┌─────────────────────────────────────────────────────────────────┐
│                      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) │
      └────────┘ └────────┘ └────────┘ └────────┘
Ponto-chave: os arquivos de mídia nunca passam pela Lambda. O navegador recebe uma Presigned URL e faz o 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"
  }
}
Por que SDK v3 e não v2?

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
Atenção ao DeletionPolicy: Retain na tabela DynamoDB. Sem isso, se o stack do CloudFormation for deletado (por exemplo, durante um teste), todos os dados de auditoria são apagados permanentemente. O Retain garante que a tabela sobrevive mesmo se o stack for removido.
Por que PAY_PER_REQUEST no DynamoDB? O modo on-demand cobra apenas pelas leituras e escritas realizadas, sem necessidade de provisionar capacidade. Para workloads imprevisíveis como esta (muitos uploads durante o dia, zero à noite), é significativamente mais barato do que provisionar RCU/WCU fixos.

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 });
}
Sobre o Router Manual

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.

Por que fire-and-forget na auditoria? O 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:

  1. Presign: o frontend pede à Lambda uma Presigned URL para o arquivo específico
  2. PUT: o navegador faz o upload diretamente para o S3, sem passar pela Lambda
  3. 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!";
}
Por que sequencial e não paralelo? Em dispositivos móveis com conexão limitada (que é exatamente o cenário da serraria), disparar 10 uploads simultâneos satura a rede e todos ficam lentos. Upload sequencial garante que cada arquivo completa rapidamente, dando feedback visual ao operador. Se a conexão for boa, a diferença de tempo é imperceptível para 5-10 fotos.

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,
    });
  }
}
Por que gerar thumbnails no cliente e não no servidor? Três motivos:
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, DeleteObject apenas no bucket específico. ListBucket no ARN do bucket (sem /*).
  • DynamoDB: apenas PutItem, GetItem, Query, Scan na tabela de auditoria e seus índices.
  • Cognito: apenas operações Admin* no User Pool específico.
  • SSM: apenas GetParameter em parâmetros com prefixo /galeria/*.
Erro comum: ListBucket ARN. A ação 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
Sem CORS no S3, o upload falha silenciosamente. O navegador faz o 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:

  1. 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.
  2. 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                  │
└─────────────────────────────────────────────────────────┘
A história do bug do .mp4: durante os testes, vídeos .mp4 retornavam um erro 403 no CloudFront. Depois de horas debugando, descobri que o behavior /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.
OAI (Origin Access Identity) garante que o bucket S3 só pode ser acessado via CloudFront. Ninguém consegue acessar as imagens diretamente pela URL do S3 — apenas pelo domínio do CloudFront. Isso evita que URLs de imagens "vazem" e sejam acessadas sem autenticação.

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:

  1. sam build cria um diretório .aws-sam/ com o código e as dependências empacotadas
  2. sam deploy faz upload do pacote para um bucket S3 do CloudFormation
  3. O CloudFormation cria/atualiza os recursos na seguinte ordem: DynamoDB → Cognito → IAM Roles → Lambda → API Gateway
  4. 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
───────────────────────────────────────────────────────────
Deploys subsequentes: depois do primeiro --guided, o SAM salva as configurações em samconfig.toml. Nos próximos deploys, basta rodar sam build && sam deploy sem perguntas.
Criar o primeiro usuário admin: como o Cognito está configurado para 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
NUNCA commite o 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 main para produção e dev para desenvolvimento. Deploy automático (CI/CD) apenas a partir de main.
  • 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.
Sintoma: a página HTML aparece como texto codificado em Base64 no navegador. A Lambda retorna o HTML corretamente (verificado no CloudWatch), mas o API Gateway "ajuda" fazendo encoding. A solução é contraintuitiva: marcar */* 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:

Checklist de Validação:
☑ 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

2-3s Upload de foto (5 MB)
~$5 Custo mensal
95% Adesão dos operadores
0 Servidores permanentes
Antes (File Gateway)
~$150/mês
Depois (Serverless)
~$5/mês

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
O que eu aprendi com esse projeto: serverless não é apenas sobre economia (embora de $150/mês para $5/mês seja difícil de ignorar). É sobre construir sistemas que escalam para zero quando ninguém está usando e respondem em milissegundos quando precisam. O File Gateway era a solução "enterprise" para o problema. A combinação Lambda + S3 + Presigned URLs é a solução certa.