Uploads de arquivos seguros com Claude Code: FormData, validação, prévia e S3
Upload seguro em SaaS com Claude Code: File API, FormData, fetch, validação, progresso e S3.
Upload de arquivos parece simples até entrar em produção. Foto de perfil, PDF de fatura, importação CSV, contrato e anexo de chat começam com o mesmo botão: escolher arquivo. Por trás dele existem APIs do navegador, formato da requisição, validação no servidor, permissões de armazenamento, escopo público ou privado, prévia, progresso, custo e logs de auditoria.
Claude Code ajuda muito nesse tipo de implementação. Ele consegue criar o componente React, a rota API, o validador, o adaptador de storage e o README. Mas o pedido precisa ter limites. Se o prompt for apenas “implemente upload de arquivo”, o resultado pode funcionar como demo, mas salvar o nome original, confiar demais no MIME do navegador, esquecer limite de tamanho ou retornar uma URL pública cedo demais.
Este guia segue uma ordem fácil de revisar. Primeiro separamos File API, FormData e Fetch API. Depois fazemos um upload mínimo, prévia e progresso real em React, validação no servidor e a decisão de mover para S3, Cloud Storage, Azure Blob Storage ou Cloudflare R2.
Para storage, leia também Claude Code e AWS S3. Para revisão de permissões, use boas práticas de segurança com Claude Code.
Separar as responsabilidades do navegador
A primeira peça é a File API. Quando o usuário escolhe um arquivo com<input type="file">ou arrasta um arquivo para a página, o navegador fornece um objetoFile. Você pode lerfile.name, file.size, file.type e file.lastModified. Para imagens,URL.createObjectURL(file) cria uma URL temporária de prévia.
A segunda peça é FormData. Ela envia campos e arquivos comomultipart/form-data. O uso comum éformData.append("file", file). Um detalhe importante: ao usar FormData com fetch, não defina manualmente o headerContent-Type. O navegador precisa adicionar o boundary multipart; se você fixar o header, a requisição pode quebrar.
A terceira peça é fetch. Para um upload simples, isso basta:
const formData = new FormData();
formData.append("file", file);
await fetch("/api/upload", {
method: "POST",
body: formData
});
A exceção é o progresso real. Fetch é ótimo para enviar a requisição, mas eventos de progresso de upload ainda são mais diretos comXMLHttpRequest.upload.onprogress. Por isso eu costumo pedir ao Claude Code: fetch para o caminho simples, XMLHttpRequest quando o produto precisa de barra de progresso real.
Implementação mínima com HTML e fetch
Antes de React e S3, confirme o fluxo básico: escolher arquivo, validar no navegador, mostrar prévia e enviar FormData.
<form id="upload-form">
<input id="file-input" name="file" type="file" accept="image/png,image/jpeg,application/pdf" />
<button type="submit">Enviar</button>
</form>
<img id="preview" alt="" style="max-width: 240px; display: none;" />
<p id="message"></p>
<script type="module">
const MAX_BYTES = 5 * 1024 * 1024;
const allowedTypes = new Set(["image/png", "image/jpeg", "application/pdf"]);
const form = document.querySelector("#upload-form");
const input = document.querySelector("#file-input");
const preview = document.querySelector("#preview");
const message = document.querySelector("#message");
input.addEventListener("change", () => {
const file = input.files?.[0];
preview.style.display = "none";
preview.removeAttribute("src");
message.textContent = "";
if (!file) return;
if (!allowedTypes.has(file.type)) {
message.textContent = "Apenas PNG, JPEG e PDF são permitidos.";
input.value = "";
return;
}
if (file.size > MAX_BYTES) {
message.textContent = "O arquivo deve ter no máximo 5MB.";
input.value = "";
return;
}
if (file.type.startsWith("image/")) {
preview.src = URL.createObjectURL(file);
preview.style.display = "block";
preview.onload = () => URL.revokeObjectURL(preview.src);
}
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const file = input.files?.[0];
if (!file) {
message.textContent = "Escolha um arquivo primeiro.";
return;
}
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/upload", {
method: "POST",
body: formData
});
const result = await response.json();
message.textContent = response.ok ? `Salvo: ${result.name}` : result.error;
});
</script>
Validação no cliente melhora a experiência, mas não é segurança. Um cliente malicioso pode ignorar esse JavaScript. O servidor precisa validar tudo de novo.
React com prévia e progresso real
Em React, deixe os estados explícitos: arquivo selecionado, URL de prévia, progresso, erro e nome salvo. Assim o Claude Code consegue adicionar drag and drop, cancelamento ou retry depois.
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react";
const MAX_BYTES = 5 * 1024 * 1024;
const ALLOWED_TYPES = new Set(["image/png", "image/jpeg", "application/pdf"]);
type UploadResult = { ok: true; name: string; size: number; type: string };
export function FileUploadBox() {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [uploadedName, setUploadedName] = useState<string | null>(null);
const canUpload = useMemo(() => selectedFile && !error, [selectedFile, error]);
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0] ?? null;
setUploadedName(null);
setProgress(0);
setError(null);
if (previewUrl) URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
if (!file) return setSelectedFile(null);
if (!ALLOWED_TYPES.has(file.type)) {
setSelectedFile(null);
return setError("Apenas PNG, JPEG e PDF são permitidos.");
}
if (file.size > MAX_BYTES) {
setSelectedFile(null);
return setError("O arquivo deve ter no máximo 5MB.");
}
setSelectedFile(file);
if (file.type.startsWith("image/")) setPreviewUrl(URL.createObjectURL(file));
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedFile) return;
const formData = new FormData();
formData.append("file", selectedFile);
const result = await uploadWithProgress(formData, setProgress);
setUploadedName(result.name);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<input type="file" accept="image/png,image/jpeg,application/pdf" onChange={handleFileChange} />
{previewUrl && <img src={previewUrl} alt="Prévia do arquivo selecionado" width={240} />}
{selectedFile && <p>{selectedFile.name} / {Math.round(selectedFile.size / 1024)}KB</p>}
{error && <p role="alert">{error}</p>}
<progress value={progress} max={100}>{progress}%</progress>
<button type="submit" disabled={!canUpload}>Enviar</button>
{uploadedName && <p>Salvo: {uploadedName}</p>}
</form>
);
}
function uploadWithProgress(formData: FormData, onProgress: (progress: number) => void) {
return new Promise<UploadResult>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/upload");
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
});
xhr.addEventListener("load", () => {
const body = JSON.parse(xhr.responseText || "{}");
if (xhr.status >= 200 && xhr.status < 300) resolve(body);
else reject(new Error(body.error ?? "Upload failed"));
});
xhr.addEventListener("error", () => reject(new Error("Network error")));
xhr.send(formData);
});
}
Se você não mede o progresso real, mostre apenas “enviando”. Uma barra falsa pode funcionar em demo, mas quebra a confiança quando a rede está lenta.
Validação no servidor
Esta Route Handler do Next.js salva em.local-uploads na primeira versão. Em produção, troque essa escrita por S3, Cloud Storage, Azure Blob Storage ou R2.
// app/api/upload/route.ts
import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs";
const MAX_BYTES = 5 * 1024 * 1024;
const ALLOWED_TYPES = new Map([
["image/png", ".png"],
["image/jpeg", ".jpg"],
["application/pdf", ".pdf"]
]);
export async function POST(request: NextRequest) {
const formData = await request.formData();
const value = formData.get("file");
if (!(value instanceof File)) {
return NextResponse.json({ error: "Arquivo ausente." }, { status: 400 });
}
const expectedExt = ALLOWED_TYPES.get(value.type);
const originalExt = path.extname(value.name).toLowerCase();
if (!expectedExt) return NextResponse.json({ error: "Tipo MIME não permitido." }, { status: 400 });
if (value.size === 0 || value.size > MAX_BYTES) {
return NextResponse.json({ error: "O arquivo deve ter entre 1 byte e 5MB." }, { status: 400 });
}
if (expectedExt === ".jpg" && ![".jpg", ".jpeg"].includes(originalExt)) {
return NextResponse.json({ error: "Extensão JPEG inválida." }, { status: 400 });
}
if (expectedExt !== ".jpg" && originalExt !== expectedExt) {
return NextResponse.json({ error: "MIME e extensão não correspondem." }, { status: 400 });
}
const bytes = Buffer.from(await value.arrayBuffer());
const storedName = `${randomUUID()}${expectedExt === ".jpg" ? ".jpg" : expectedExt}`;
const uploadDir = path.join(process.cwd(), ".local-uploads");
await mkdir(uploadDir, { recursive: true });
await writeFile(path.join(uploadDir, storedName), bytes, { flag: "wx" });
return NextResponse.json({ ok: true, name: storedName, size: value.size, type: value.type });
}
Revise MIME, extensão, tamanho, nome de armazenamento e local de armazenamento. Para documentos sensíveis, adicione magic number, decodificação real, antivírus, autenticação, cotas e logs de auditoria.
Quando mover para S3 ou Cloud Storage
Armazenamento local serve para aprendizado e protótipo. Se o arquivo é ativo do cliente, se o app pode rodar em vários servidores, se você precisa de lifecycle rules, downloads privados ou processamento assíncrono, use object storage.
Você não precisa começar com upload direto do navegador para S3. Para arquivos pequenos, o servidor pode receber, validar e salvar no storage. Presigned URLs entram quando tamanho, tráfego ou largura de banda justificam.
Converta o upload local existente do Next.js para armazenamento S3.
Estado atual: /api/upload recebe FormData e já valida MIME, extensão e tamanho.
Restrições: não use o nome original como S3 key. Salve como uploads/yyyy/mm/dd/{uuid}.ext.
Restrições: permita apenas image/png, image/jpeg e application/pdf. Máximo 5MB.
Restrições: bucket privado. A API retorna um ID de arquivo, não uma URL pública.
Entregáveis: app/api/upload/route.ts, lib/storage/s3.ts, testes de falha e README com variáveis de ambiente.
Verificação: explique testes para arquivo grande, extensão incorreta, usuário não autenticado e upload correto.
Casos reais, falhas e prompt
Caso 1: imagem de perfil. Prévia, recorte, retry e miniaturas importam. Comece com PNG, JPEG e WebP; cuidado com SVG.
Caso 2: importação CSV. O upload é só a entrada. O principal é validar colunas, encoding, número de linhas, duplicatas, rollback e relatório de erros.
Caso 3: faturas e contratos PDF. Não retorne URL pública. Salve de forma privada, verifique usuário e organização, e emita URL assinada de curta duração.
Falhas comuns: trataracceptcomo segurança, salvar o nome original, devolver URL pública cedo demais, mostrar progresso falso e dar permissões S3 amplas.
Adicione upload de arquivos seguro a este app Next.js.
Objetivo: tela admin SaaS que envia um PNG, JPEG ou PDF por vez.
Cliente: componente React com File API; mostrar nome, tamanho, prévia, erro e estado de upload.
Transporte: POST com FormData para /api/upload. Se progresso real for necessário, use XMLHttpRequest e explique por que fetch não basta.
Servidor: em app/api/upload/route.ts, receber FormData e validar MIME, extensão, limite de 5MB e arquivo vazio.
Armazenamento: não use o nome original como nome salvo. Use UUID + extensão em .local-uploads.
Proibido: não salvar direto em public/. Não dizer que extensão é segurança completa. Não tornar bucket S3 público.
Verificação: explicar testes de tamanho excedido, extensão incorreta, arquivo ausente e upload correto.
Referências: MDN File API, FormData e Fetch API.
Nota de verificação de Masa
Nos testes, a maior melhora veio de separar “upload simples com fetch” de “upload com progresso real”. Quando pedi progresso sem explicar, às vezes a UI ficava bonita, mas não ligada ao progresso real da rede. Também foi melhor começar com storage local e só depois trocar o adaptador para S3. Assim File API, FormData, validação e prévia ficam estáveis antes de lidar com IAM, CORS e URLs assinadas.
Resumo
Upload seguro não é apenas um input. É uma fronteira entre dispositivo do usuário, servidor, storage e modelo de permissões. Claude Code implementa isso rápido quando o prompt deixa claros File API, FormData, validação no servidor, limite de tamanho, chave gerada, progresso honesto e object storage privado.
Se quiser adaptar este padrão a um repositório real, o treinamento e consultoria Claude Code pode cobrir fluxo de upload, S3, URLs assinadas, testes e checklist de revisão. O PDF gratuito e os materiais de estudo são um bom primeiro passo.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Workflow Obsidian para CLAUDE.md com Claude Code
Transforme notas de trabalho do Obsidian em notas operacionais CLAUDE.md para preservar contexto.
Claude Code Revenue CTA Routing: artigos para PDF, Gumroad e consultoria
Um fluxo com Claude Code para levar leitores ao PDF grátis, Gumroad ou consultoria conforme intenção.
Regras de handoff para equipes com Claude Code: evidências, permissões, rollback e receita
Formato prático para entregar trabalho do Claude Code com prova, permissões, rollback, PDF grátis, Gumroad e consultoria.