Formulário de upload de arquivos em HTML: tutorial para devs
Um upload de arquivos em HTML precisa de um campo de arquivo com nome dentro de um formulário com
method="post" e enctype="multipart/form-data", além de um servidor que trate o action do formulário. Este passo a passo conecta
essas peças: você vai enviar um ou vários arquivos sem JavaScript no navegador e ver o receptor
informar os nomes, a contagem de bytes e os hashes SHA-256 de cada um.
Configurando um formulário básico de upload de arquivos em HTML
Você precisa do Node.js 24 ou posterior, de um navegador e de um shell POSIX, como o Bash no Linux, macOS ou WSL. O receptor usa as APIs nativas do Node, então não há pacotes para instalar. O Node consegue executar este TypeScript diretamente usando remoção de tipos. Os exemplos foram testados no Linux com Node.js 24.15.0, 26.5.0 e 26.8.1, e com Chromium 145 e 152.
A partir de um diretório onde você guarda experimentos, execute:
mkdir html-upload-demo &&
cd html-upload-demo &&
touch index.html server.ts
Este comando se recusa a usar um diretório existente. Se ele falhar, pare antes de seguir os passos restantes; escolha um novo nome de diretório ou resolva o erro. Cole os próximos dois exemplos nos arquivos recém-criados. O servidor apenas inspeciona os uploads na memória. Ele não cria nem sobrescreve arquivos enviados, e enviar o mesmo arquivo de novo simplesmente gera outro recibo.
Salve esta página completa como index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HTML file upload demo</title>
</head>
<body>
<main>
<h1>Upload files</h1>
<form action="/upload" method="post" enctype="multipart/form-data">
<p>
<label for="file-upload">Choose files (required)</label>
<input
type="file"
id="file-upload"
name="files"
multiple
required
aria-describedby="file-help"
/>
</p>
<p id="file-help">Choose up to three files, at most 1 MiB each. Nothing is saved.</p>
<button type="submit">Upload files</button>
</form>
</main>
</body>
</html>
Cada atributo do formulário tem uma função distinta:
| Atributo | O que faz aqui |
|---|---|
action="/upload" | Envia o formulário para a rota /upload no servidor que entrega esta página. |
method="post" | Envia os dados do formulário no corpo da requisição HTTP. |
enctype="multipart/form-data" | Codifica o conteúdo dos arquivos como partes separadas nesse corpo. |
name="files" | Nomeia cada parte enviada para que o receptor possa recuperá-la. |
id="file-upload" | Conecta o campo ao seu rótulo; não define o nome do campo enviado. |
multiple | Permite selecionar mais de um arquivo no seletor. |
required | Faz o navegador pedir uma seleção antes de enviar. |
O navegador monta o boundary multipart e os cabeçalhos da requisição para você. O
guia de envio de dados de formulário
do MDN explica a codificação. Um campo sem name é omitido dos dados enviados, mesmo que tenha um
id e mostre um nome de arquivo selecionado; veja as
regras de entradas de formulário do padrão HTML.
Adicione um receptor multipart local
Salve isto como server.ts. Ele entrega a página e aceita até três arquivos de 1 MiB cada. Um limite
separado de 4 MiB restringe o corpo da requisição armazenado em buffer, incluindo os cabeçalhos
multipart, antes da análise. Esses são limites pequenos para demonstração; o buffer e a análise
também exigem memória além do tamanho bruto do corpo.
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
const MAX_FILES = 3
const MAX_FILE_BYTES = 1024 * 1024
const MAX_REQUEST_BYTES = 4 * 1024 * 1024
const page = await readFile(new URL('./index.html', import.meta.url))
function reply(response: ServerResponse, status: number, message: string): void {
response.writeHead(status, {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
})
response.end(`${message}\n\nUse Back to choose files again. Nothing was saved.\n`)
}
async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
if (request.method === 'GET' && request.url === '/') {
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
response.end(page)
return
}
if (request.method === 'GET' && request.url === '/favicon.ico') {
response.writeHead(204).end()
return
}
if (request.method !== 'POST' || request.url !== '/upload') {
reply(response, 404, 'Route not found. Open the URL printed in the terminal.')
return
}
const contentType = request.headers['content-type'] ?? ''
if (!contentType.toLowerCase().startsWith('multipart/form-data;')) {
request.resume()
reply(response, 415, 'Expected multipart/form-data. Check the form enctype.')
return
}
const chunks: Buffer[] = []
let bytes = 0
// Keep the connection alive long enough to return readable size-limit feedback.
for await (const chunk of request.iterator({ destroyOnReturn: false })) {
if (!Buffer.isBuffer(chunk)) throw new Error('Expected request bytes')
bytes += chunk.length
if (bytes > MAX_REQUEST_BYTES) {
request.resume()
reply(response, 413, 'Request exceeds 4 MiB. Choose smaller files.')
return
}
chunks.push(chunk)
}
let form: FormData
try {
form = await new Request('http://localhost/upload', {
method: 'POST',
headers: { 'Content-Type': contentType },
body: Buffer.concat(chunks),
}).formData()
} catch {
reply(response, 400, 'Could not read the multipart form. Check its encoding.')
return
}
const files = form.getAll('files')
if (files.length === 0) {
reply(response, 400, 'Choose at least one file in the files field.')
return
}
if (files.length > MAX_FILES) {
reply(response, 400, 'Choose at most three files.')
return
}
const receipts = []
for (const file of files) {
if (!(file instanceof File) || file.name === '') {
reply(response, 400, 'Choose at least one file in the files field.')
return
}
if (file.size > MAX_FILE_BYTES) {
reply(response, 413, 'Each file must be at most 1 MiB. Choose smaller files.')
return
}
receipts.push({
field: 'files',
name: file.name,
bytes: file.size,
sha256: createHash('sha256').update(Buffer.from(await file.arrayBuffer())).digest('hex'),
})
}
reply(response, 200, `Received ${files.length} file(s).\n${JSON.stringify(receipts, null, 2)}`)
}
const server = createServer((request, response) => {
void handle(request, response).catch(() => {
console.error('Upload handler failed.')
if (!response.headersSent) reply(response, 500, 'Could not process the upload. Try again.')
else response.destroy()
})
})
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (address && typeof address !== 'string') {
console.log(`Open http://127.0.0.1:${address.port}/`)
}
})
A API Request nativa
analisa o corpo coletado com
formData().
Construir esse objeto não envia outra requisição de rede. O receptor usa
getAll('files')
para coletar todas as partes com esse nome. get('files') retornaria apenas a primeira.
A opção destroyOnReturn: false
do stream permite que o receptor saia do loop de leitura sem destruir a conexão quando o corpo é
grande demais. Ele descarta o restante da entrada e retorna uma resposta HTTP 413.
Lidando com uploads de um ou vários arquivos
No mesmo diretório html-upload-demo, inicie o receptor:
node server.ts
Abra a URL exata exibida no terminal. O servidor escolhe uma porta livre e escuta apenas em
127.0.0.1. Abrir index.html diretamente como arquivo local não vai conectar a ação /upload dele a
este receptor. Pare o servidor com Ctrl+C ao terminar; reinicie-o depois de editar qualquer um dos
arquivos.
Desative o JavaScript no navegador, recarregue a página e escolha um arquivo pequeno. Clique em
Upload files. O navegador vai para /upload e exibe Received 1 file(s)., seguido de um recibo com
field, name, bytes e sha256. Essa resposta confirma que o servidor aceitou e inspecionou
os bytes. O nome do arquivo aparecer no seletor confirma apenas a seleção.
Use o botão Voltar, escolha dois arquivos juntos e envie novamente. Você deve ver
Received 2 file(s). e dois recibos. Teste nomes de arquivo com espaços ou caracteres não ASCII.
Para comparar um recibo com o seu arquivo local, execute isto a partir do diretório da demonstração,
substituindo o caminho depois de -- pelo caminho do seu arquivo:
node --input-type=module -e 'import { readFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const bytes = await readFile(process.argv[1]); console.log(bytes.length, createHash("sha256").update(bytes).digest("hex"))' -- '/path/to/your file.txt'
A contagem de bytes e o hash devem coincidir. Este comando apenas lê o arquivo. Um arquivo vazio selecionado deliberadamente é válido e informa zero bytes; um seletor vazio é um caso diferente.
multiple muda quantos arquivos o usuário pode selecionar, não o nome do campo. Cada arquivo selecionado
é enviado sob files. O HTML não exige a convenção de colchetes files[]; este receptor espera a
chave literal files. Se outro back-end esperar files[], os dois lados precisam usar exatamente esse
nome. Para um seletor que permite apenas um arquivo, omita multiple. Isso muda apenas o controle do
navegador; aplique também uma regra de um único arquivo no servidor se a sua aplicação exigir.
Adicionando validação no lado do cliente
Sem nenhuma seleção, Upload files aciona o aviso de campo obrigatório do navegador e mantém você no formulário. O receptor também rejeita um upload vazio com HTTP 400, porque clientes podem contornar a validação do navegador. O HTML não tem um atributo de tamanho de arquivo que imponha o limite de 1 MiB, então seleções grandes demais chegam ao receptor e recebem uma página de erro.
Esta demonstração aceita qualquer tipo de arquivo e apenas inspeciona seus bytes. Para orientar um
seletor de imagens ou documentos, você pode adicionar accept=".jpg,.jpeg,.png,.pdf" ao campo. Como explica a
documentação do input de arquivo
do MDN, accept é uma dica para o seletor, não uma validação de conteúdo. Ele não adiciona
verificações de tipo a este receptor. Um nome de arquivo, uma extensão ou um tipo MIME informado não
conseguem comprovar que um arquivo é seguro.
Teste estes casos de falha antes de adaptar o exemplo:
| Envio | Resultado esperado |
|---|---|
| Nenhum arquivo selecionado | O navegador pede um arquivo; uma requisição vazia direta recebe HTTP 400. |
| Quatro arquivos pequenos | HTTP 400: “Choose at most three files.” |
| Um arquivo acima de 1 MiB | HTTP 413 com aviso sobre o limite de tamanho. |
| Requisição total acima de 4 MiB | HTTP 413 antes da análise multipart. |
Um campo de arquivo chamado file ou files[] | HTTP 400 porque o receptor não encontra files. |
Depois de um erro, use o botão Voltar e escolha uma seleção válida. Uma resposta de sucesso vale para o envio inteiro. Nada é salvo, seja de uma requisição bem-sucedida ou rejeitada.
Personalizando o botão de upload de arquivos
Você pode estilizar o botão nativo mantendo o rótulo, a exibição do arquivo selecionado e o
comportamento do teclado. Em index.html, adicione isto dentro de <head> e depois reinicie o servidor:
<style>
input[type='file']::file-selector-button {
font: inherit;
padding: 0.5rem 0.75rem;
margin-inline-end: 0.75rem;
cursor: pointer;
}
</style>
O pseudoelemento ::file-selector-button
seleciona o botão dentro do campo. Use Tab para chegar ao seletor com rótulo e pressione Espaço para
abri-lo; depois use Tab até Upload files e pressione Enter para enviar. Mantenha o campo visível
e o indicador de foco intacto. O name="files" dele e a posição dentro do formulário continuam conectando a
seleção ao receptor.
Considerações de segurança
Mantenha este receptor local. Ele aceita qualquer conteúdo, usa memória a cada requisição e não oferece login, armazenamento durável nem verificação de malware. Os nomes de arquivo são exibidos como JSON em uma resposta de texto simples e nunca são usados como caminhos no sistema de arquivos. O hash confirma quais bytes chegaram; ele não valida o formato nem a segurança deles.
Ao adicionar armazenamento a uma aplicação autenticada, defina autorização, limites de requisição, validação de conteúdo e proteção contra CSRF na fronteira do servidor. O guia de upload seguro com AJAX, à parte, trata dessa tarefa focada em segurança.
Adicione progresso ou arrastar e soltar quando necessário
Para uma interface que permanece na página e mostra o progresso do upload, continue com o uploader personalizado em JavaScript (English). Ele cobre arrastar e soltar, novas tentativas e progresso com um receptor compatível. Se a sua aplicação já usa Bootstrap, veja o tutorial de upload de arquivos com Bootstrap (English) para essa abordagem de estilo.
