Pular para o conteúdo
← Artigos

05 de outubro de 2026·6 min de leitura

O bucket é o banco: um app de 430 linhas que eu não precisei manter

O media-viewer existe para eu assistir e baixar no celular os vídeos que meus agentes geram. Ele tem um arquivo, cinco dependências, nenhum banco e nenhum JavaScript no cliente. Este post explica cada decisão e o que eu abri mão para tomá-la.

No post anterior contei como meus agentes gravam a interface que construíram e me devolvem um link. Esse link abre o media-viewer, a última etapa do pipeline. Lá ele ganhou um parágrafo. Aqui ele ganha o post inteiro, porque é o código mais curto do kit e o que tem mais decisões por linha.

O problema era pequeno. Eu renderizava vídeos de marketing num devbox na Railway e queria assistir no celular, longe do notebook, e salvar no rolo da câmera para postar. Servidor na rede local não resolve, porque eu não estou na rede local. Mandar o arquivo por mensageiro também não, porque o vídeo é comprimido de novo e a qualidade cai.

O media-viewer aberto no celular, com o projeto launch, um vídeo tocando inline e o botão Download
O viewer público, numa viewport de iPhone 14. É a tela inteira do produto.

O resultado é um server.js de pouco mais de 400 linhas e cinco dependências: Express, multer, cookie e os dois pacotes do SDK S3. Não tem banco, não tem build e não tem JavaScript no navegador. A versão pessoal roda desde agosto e teve três commits.

O bucket é o banco

A primeira vontade é criar uma tabela de projetos. Eu não criei. No media-viewer, um projeto é o primeiro segmento da chave do objeto: jure/1712345-reel.mp4 pertence ao projeto jure. Para listar os projetos, uso ListObjectsV2 com Delimiter: "/", e o S3 devolve as "pastas" em CommonPrefixes.

Isso elimina uma categoria inteira de bug. Com uma tabela, ela e o bucket podem divergir: um projeto existe na tabela e está vazio no bucket, ou um arquivo existe sem registro. Como só existe uma fonte, não tem nada para sincronizar. Criar um projeto é fazer upload do primeiro arquivo nele, e apagar um projeto é apagar os arquivos.

A regra que ficou escrita no repositório: antes de adicionar um banco, estenda o padrão de prefixo. Se um dia eu precisar de subpastas, uso um segundo segmento na chave.

O vídeo nunca passa pelo app

O servidor não faz streaming de nada. Para cada arquivo, ele gera uma URL pré-assinada do bucket, válida por uma hora, e coloca essa URL direto no src do <video>. O navegador conversa com o storage, que sabe lidar com range requests, cache e arquivos grandes. O processo Node só monta HTML.

Tem um detalhe que não é óbvio. Cada arquivo precisa de duas URLs assinadas. A primeira é para tocar inline. A segunda define ResponseContentDisposition: attachment, para que o botão Download salve o arquivo em vez de abrir o player. A disposição faz parte da assinatura, então uma URL não serve para os dois usos. Com uma URL só, o botão Download no iPhone abre o vídeo em tela cheia e não salva nada. E salvar no celular é justamente o que permite postar no Instagram depois.

Sessão sem estado

Não há contas de usuário. Existe uma senha compartilhada. Quando ela está certa, o servidor grava um cookie httpOnly com um valor e a assinatura HMAC desse valor, que vale por 30 dias. Para validar a sessão, ele recalcula a assinatura e compara em tempo constante.

Não existe session store. O estado inteiro está na assinatura, então redeploy, restart e escala horizontal não derrubam ninguém. O custo é claro: não dá para revogar uma sessão específica. Para derrubar todas, eu troco o SESSION_SECRET. Para uma ferramenta com um usuário só, que sou eu, essa troca vale a pena.

Agentes não fazem login. O POST /upload aceita também Authorization: Bearer <UPLOAD_TOKEN> e responde em JSON, com o link do projeto. É esse link que o agente me devolve:

curl -H "Authorization: Bearer $UPLOAD_TOKEN" \
  -F project=meu-app -F "file=@reel.mp4;type=video/mp4" \
  "$MEDIA_VIEWER_URL/upload"
# {"ok":true,"project":"meu-app","key":"meu-app/1790...-reel.mp4","url":"/p/meu-app"}

Três detalhes pequenos que evitam problema grande

  • A autenticação vem antes do multer. O middleware que confere cookie ou token roda antes do parser de multipart. Uma requisição sem credencial é recusada antes de o servidor ler um byte do corpo, então ninguém consegue me fazer bufferizar 1 GB em memória sem estar autenticado.
  • O healthcheck não toca no bucket. O /healthz responde só com o uptime. Ele diz se o processo está vivo, não se o storage está. Uma credencial errada não trava o deploy na Railway sem explicação: o app sobe, e o erro aparece na página com a mensagem do S3, que é onde eu consigo ler.
  • Comparação de segredo em tempo constante. Senha, token e assinatura passam por SHA-256 e depois por timingSafeEqual. O hash iguala os tamanhos, que é a exigência do timingSafeEqual, e não revela o tamanho do segredo.

Sem JavaScript no cliente

Cada página é HTML montado em template literal no servidor. Login, upload e navegação são formulários e links. O CSS está inline no layout, e o tema escuro foi pensado para a tela do celular.

Isso não é purismo. É o motivo de o app nunca quebrar: não há bundle para atualizar, hidratação para dar errado nem dependência de front-end para receber alerta de segurança. O <video playsinline> nativo já entrega controles, tela cheia e AirPlay. Se um dia uma feature realmente precisar de interatividade, ela entra. Até agora nenhuma precisou.

Modo público

Para o lançamento do kit eu queria uma galeria aberta, sem expor o meu viewer pessoal. A solução foi uma variável: com PUBLIC_READ=true, qualquer pessoa navega, assiste e baixa. Escrever continua exigindo senha ou token, e o formulário de upload some para quem não está logado. A demo pública é uma instância separada, com um bucket separado. O README avisa: neste modo, tudo o que está no bucket fica público, então não misture revisão privada com conteúdo de demonstração.

O que eu abri mão

A simplicidade tem limites, e prefiro que eles estejam escritos:

  • O upload fica em memória. O multer bufferiza o arquivo inteiro antes do PutObject. O teto padrão é 1 GB. Para Reels de poucos megabytes é irrelevante. Para vídeo bruto de câmera, o certo seria upload direto ao bucket com URL pré-assinada de PUT.
  • A listagem não pagina. O ListObjectsV2 devolve até 1.000 chaves por chamada, e o app faz uma chamada só. Com mais de mil arquivos num projeto, os mais antigos somem da página. Eu ainda não cheguei perto.
  • Não há miniaturas. O vídeo é servido como foi enviado, com preload="metadata". Em celular, isso basta.
  • É uma senha para todos. Se eu precisar dar acesso a um cliente sem dar acesso aos outros, ou o app ganha usuários ou cada cliente ganha uma instância. Hoje eu escolheria a segunda opção.

Nenhuma dessas limitações me atrapalhou até agora. Quando alguma atrapalhar, a correção será localizada, e é por isso que eu aceitei cada uma.

Experimente

O media-viewer está dentro do agentic-media-kit, sob licença MIT. Ele funciona sozinho, sem o resto do kit: aponte para qualquer bucket compatível com S3 (Railway, R2 ou MinIO) e preencha seis variáveis de ambiente.

cd apps/media-viewer
npm install
cp .env.example .env    # COOKIE_SECURE=false para http://localhost
npm run dev

O guia de deploy na Railway está em docs/deploy-railway.md. Se você tem um problema parecido, como compartilhar arquivos grandes com você mesmo ou com um cliente sem criar conta em mais um serviço, me conte como resolveu. Talvez um bucket e 400 linhas resolvam o seu também.

  • Renan