Implementando uploads de arquivos com Bootstrap 5
Use os estilos de input de arquivo, botão, alerta e progresso do Bootstrap para criar um formulário de upload de um único arquivo. Este exemplo aceita um JPEG, PNG ou PDF de até 5 MiB, impede um segundo envio enquanto um upload está pendente e aguarda a resposta do servidor antes de informar que o arquivo foi aceito.
O Bootstrap cuida da apresentação. O JavaScript cuida da seleção e da transferência, e um pequeno
receptor em Node.js verifica a requisição. Você vai criar três arquivos: public/index.html, public/upload.js
e server.ts. O receptor informa o tamanho e o checksum SHA-256 do arquivo e depois o descarta;
ele não salva os uploads.
Configurando um ambiente Bootstrap 5
Use o Node.js 26 e um navegador atual. Este exemplo foi testado com o Node.js 26.8.1 e 26.5.0, e com o Chromium 152 no Linux. Ele usa o CSS do Bootstrap 5.3.8 do guia oficial de início rápido, incluindo o hash de integridade correspondente. Estes componentes não precisam do bundle JavaScript do Bootstrap. É necessário ter acesso à internet para carregar a folha de estilo; o servidor não precisa de pacotes de terceiros.
Em um terminal compatível com Bash, crie um projeto novo:
mkdir bootstrap-upload &&
cd bootstrap-upload &&
printf '%s\n' '{"type":"module"}' > package.json &&
mkdir public
O package.json gerado habilita módulos ES para server.ts. A sequência de comandos para se o diretório
já existir ou se a navegação falhar. Nesse caso, escolha outro nome de
diretório; não sobrescreva um projeto existente. Crie os três arquivos abaixo neste novo
projeto antes de iniciar o servidor.
Criando um formulário simples de upload de arquivos
Salve este documento completo como public/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Bootstrap file upload</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB"
crossorigin="anonymous"
/>
<script src="/upload.js" defer></script>
</head>
<body>
<main class="container py-4">
<div class="row justify-content-center">
<section class="col-12 col-md-8 col-lg-6" aria-labelledby="title">
<h1 id="title" class="h3">Upload a file</h1>
<p>This local demo checks your upload and discards it.</p>
<form id="uploadForm" aria-label="File upload" action="/upload"
method="post" enctype="multipart/form-data" novalidate>
<fieldset id="controls">
<legend class="visually-hidden">Select and upload one file</legend>
<div id="dropZone" class="border rounded p-3 mb-3">
<label for="formFile" class="form-label">Choose a file</label>
<input id="formFile" name="file" class="form-control" type="file"
accept="image/jpeg,image/png,application/pdf" required
aria-describedby="fileHelp errorMessage" />
<p id="fileHelp" class="form-text mb-0">
Drop one file here or use the file picker. JPEG, PNG, or PDF;
nonempty files up to 5 MiB (5,242,880 bytes).
</p>
</div>
<div class="d-grid d-sm-flex mb-3">
<button class="btn btn-primary" type="submit">Upload</button>
</div>
</fieldset>
<div id="errorMessage" class="alert alert-danger" role="alert" hidden></div>
<div id="progress" class="progress mb-2" role="progressbar"
aria-label="Upload transfer" aria-valuemin="0" aria-valuemax="100"
aria-valuenow="0" hidden>
<div id="progressFill" class="progress-bar"></div>
</div>
<p id="status" class="text-break" role="status" aria-atomic="true">
No file selected.
</p>
</form>
</section>
</div>
</main>
</body>
</html>
O input de arquivo do Bootstrap, visível e com rótulo,
mantém o seletor do navegador operável pelo teclado. Soltar um arquivo é uma forma adicional de
selecioná-lo. A coluna ocupa toda a largura em telas pequenas e fica mais estreita em telas maiores;
d-grid d-sm-flex faz o botão Upload ocupar toda a largura em celulares.
Uma borda vermelha, por si só, não explica um erro. O script combina .is-invalid com aria-invalid
e um alerta visível referenciado por aria-describedby. O formulário usa novalidate para que essas
mensagens tratem o envio de forma consistente. A
documentação de validação do Bootstrap
alerta contra depender apenas dos seus estilos de validação personalizados e tooltips para garantir
a acessibilidade.
Adicione arrastar e soltar, validação e progresso
Salve o script inteiro como public/upload.js. A seleção e o envio compartilham as mesmas verificações.
Uma seleção inválida limpa o input, para que escolher o mesmo arquivo novamente dispare uma nova
verificação. Durante uma requisição, o fieldset fica desabilitado e os handlers de eventos rejeitam
novos envios e arquivos soltos. O arquivo selecionado continua disponível para uma nova tentativa
depois de uma requisição com falha.
const form = document.getElementById('uploadForm')
const controls = document.getElementById('controls')
const input = document.getElementById('formFile')
const dropZone = document.getElementById('dropZone')
const errorMessage = document.getElementById('errorMessage')
const status = document.getElementById('status')
const progress = document.getElementById('progress')
const progressFill = document.getElementById('progressFill')
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
const maxSize = 5 * 1024 * 1024
let busy = false
let activeFiles = null
function clearFeedback() {
errorMessage.hidden = true
errorMessage.textContent = ''
input.classList.remove('is-invalid')
input.removeAttribute('aria-invalid')
status.textContent = ''
progress.hidden = true
}
function showError(message, invalid = false) {
errorMessage.textContent = message
errorMessage.hidden = false
input.classList.toggle('is-invalid', invalid)
if (invalid) input.setAttribute('aria-invalid', 'true')
}
function validateSelection() {
clearFeedback()
const file = input.files[0]
let message = ''
if (input.files.length !== 1) message = 'Please select exactly one file.'
else if (!allowedTypes.includes(file.type)) message = 'Choose a JPEG, PNG, or PDF file.'
else if (file.size === 0 || file.size > maxSize) {
message = 'Choose a nonempty file no larger than 5 MiB.'
}
if (message) {
input.value = ''
showError(message, true)
return false
}
status.textContent = `Selected: ${file.name}`
return true
}
input.addEventListener('change', () => {
if (busy) {
input.files = activeFiles
return
}
validateSelection()
})
dropZone.addEventListener('dragover', (event) => {
event.preventDefault()
if (!busy) dropZone.classList.add('border-primary', 'bg-body-tertiary')
})
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('border-primary', 'bg-body-tertiary')
})
dropZone.addEventListener('drop', (event) => {
event.preventDefault()
dropZone.classList.remove('border-primary', 'bg-body-tertiary')
if (busy) return
const files = event.dataTransfer?.files
if (!files || files.length !== 1) {
clearFeedback()
input.value = ''
showError('Please drop exactly one file.', true)
return
}
input.files = files
validateSelection()
})
function setProgress(percent) {
progress.setAttribute('aria-valuenow', String(percent))
progressFill.style.width = `${percent}%`
}
form.addEventListener('submit', (event) => {
event.preventDefault()
if (busy) return
if (!validateSelection()) {
input.focus()
return
}
// Disabled controls are omitted from FormData, so capture the body first.
const body = new FormData(form)
const file = input.files[0]
activeFiles = input.files
busy = true
controls.disabled = true
progress.hidden = false
setProgress(0)
status.textContent = 'Uploading…'
const xhr = new XMLHttpRequest()
function finish(message, failed) {
busy = false
controls.disabled = false
activeFiles = null
if (failed) {
progress.hidden = true
status.textContent = ''
showError(message)
return
}
setProgress(100)
status.textContent = message
form.reset()
}
xhr.upload.addEventListener('progress', (event) => {
if (!event.lengthComputable || event.total === 0) {
progress.removeAttribute('aria-valuenow')
status.textContent = 'Uploading; transfer size unknown…'
return
}
const percent = Math.floor((event.loaded / event.total) * 100)
setProgress(percent)
status.textContent = `${percent}% transferred. Waiting for the server…`
})
xhr.upload.addEventListener('load', () => {
setProgress(100)
status.textContent = '100% transferred. Waiting for the server…'
})
xhr.addEventListener('load', () => {
const reply = xhr.response
if (xhr.status !== 200) {
const message = xhr.status === 413
? 'The server rejected the upload size.'
: `The server rejected the upload (HTTP ${xhr.status}).`
finish(message, true)
return
}
if (!reply || reply.bytes !== file.size || typeof reply.sha256 !== 'string'
|| !/^[a-f0-9]{64}$/.test(reply.sha256)) {
finish('The server returned an unexpected receipt. Acceptance is unconfirmed.', true)
return
}
finish(`Accepted: ${file.name} (${reply.bytes.toLocaleString()} bytes). `
+ `SHA-256: ${reply.sha256}. The demo did not save the file.`, false)
})
xhr.addEventListener('error', () => {
finish('Network error. Acceptance is unconfirmed; you can retry.', true)
})
xhr.addEventListener('timeout', () => {
finish('No response within 30 seconds. Acceptance is unconfirmed; you can retry.', true)
})
xhr.open('POST', form.action)
xhr.responseType = 'json'
xhr.timeout = 30_000
xhr.send(body)
})
XMLHttpRequest.upload
fornece o progresso da transferência. Chegar a 100% significa que o corpo da requisição foi enviado,
não que o servidor aceitou o arquivo. A barra de progresso do Bootstrap, com rótulo,
expõe o valor numérico, enquanto o texto de status separado explica essa diferença. Uploads locais
pequenos podem pular direto para 100%.
Deixe o cabeçalho multipart Content-Type por conta do navegador: ele inclui o boundary necessário
para interpretar FormData.
O corpo é capturado antes de os controles serem desabilitados, porque campos desabilitados são
excluídos. Um tempo limite de 30 segundos
libera o formulário se nenhuma resposta chegar. Um tempo limite esgotado ou uma falha de conexão não
comprova se o servidor já processou a requisição. Esta demonstração, que apenas descarta os arquivos,
pode ser repetida com segurança; um serviço de armazenamento precisaria da sua própria política de
novas tentativas.
Tratando uploads de arquivos no lado do servidor
Salve isto como server.ts ao lado de public/. Ele serve apenas os dois arquivos públicos, limita
a requisição armazenada em buffer e interpreta os dados multipart com as
APIs web nativas do Node. Os 64 KiB extras deixam espaço para os
cabeçalhos multipart; o arquivo em si continua limitado a 5 MiB. O checksum permite comparar de forma
independente os bytes recebidos com o arquivo original.
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
const maxSize = 5 * 1024 * 1024
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
const publicFiles = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
['/upload.js', ['upload.js', 'text/javascript; charset=utf-8']],
])
const server = createServer(async (req, res) => {
function reply(status: number, data: object): void {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(data))
}
try {
const asset = publicFiles.get(req.url ?? '')
if (req.method === 'GET' && asset) {
const bytes = await readFile(new URL(`./public/${asset[0]}`, import.meta.url))
res.writeHead(200, { 'Content-Type': asset[1] })
res.end(bytes)
return
}
if (req.method !== 'POST' || req.url !== '/upload') {
reply(404, { error: 'Not found.' })
return
}
const chunks: Buffer[] = []
let size = 0
for await (const chunk of req) {
size += chunk.length
if (size > maxSize + 64 * 1024) {
reply(413, { error: 'Request too large.' })
return
}
chunks.push(chunk)
}
const body = new Response(Buffer.concat(chunks), {
headers: { 'Content-Type': req.headers['content-type'] ?? '' },
})
const data = await body.formData().catch(() => null)
const file = data?.get('file')
if (!data || [...data].length !== 1 || !(file instanceof File)) {
reply(400, { error: 'Send exactly one file field named file.' })
return
}
if (!allowedTypes.includes(file.type)) {
reply(415, { error: 'Unsupported declared file type.' })
return
}
if (file.size === 0 || file.size > maxSize) {
reply(413, { error: 'File must be nonempty and at most 5 MiB.' })
return
}
const sha256 = createHash('sha256')
.update(Buffer.from(await file.arrayBuffer()))
.digest('hex')
reply(200, { bytes: file.size, sha256 })
} catch {
reply(500, { error: 'Unable to handle the request.' })
}
})
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}`)
}
})
De dentro de bootstrap-upload, inicie-o com:
node server.ts
Abra a URL exibida, que usa uma porta disponível. Não abra index.html diretamente: o formulário e o
receptor precisam compartilhar a origem do servidor. Pare o servidor com Ctrl+C. Fazer upload do
mesmo arquivo novamente não substitui nada no disco, porque nenhum arquivo enviado é mantido.
Experimente o formulário
Selecione um PDF pequeno usando o teclado e depois acione Upload. O resultado deve dizer “Accepted” e mostrar a contagem de bytes recebidos e o SHA-256, seguidos de “The demo did not save the file.” Você também pode soltar um arquivo dentro do grupo com borda. Redimensione a janela para conferir o layout empilhado para dispositivos móveis.
Teste um arquivo vazio, um arquivo de texto, dois arquivos soltos de uma vez e um arquivo maior que
5 MiB. Cada caso deve exibir uma explicação visível sem enviar nenhuma requisição. Um rótulo MIME
vazio ou inesperado também é rejeitado, mesmo que o nome do arquivo termine em .pdf. Durante o
upload, o seletor e o botão Upload continuam desabilitados. Depois de uma falha, acione Upload para
tentar novamente com a mesma seleção. Depois de um sucesso, selecione um arquivo outra vez para
iniciar outro upload.
As ferramentas de desenvolvedor do navegador podem limitar a velocidade da conexão para facilitar a observação do progresso. Parar o receptor gera um erro de rede; um receptor que nunca responde gera a mensagem de tempo limite. Nenhum dos dois casos deve exibir a aceitação. Se a página aparecer sem estilos, verifique a requisição da folha de estilo e o erro de integridade no console do navegador.
Considerações de segurança
O atributo accept e as verificações em JavaScript ajudam as pessoas a escolher um arquivo. Eles
não são uma barreira de segurança. O File.type de um navegador
e o tipo MIME de um arquivo multipart descrevem metadados fornecidos pelo cliente; o receptor não
inspeciona se os bytes são realmente uma imagem ou um PDF. O checksum informa o que chegou, não se o
conteúdo é seguro.
Este receptor escuta apenas na interface de loopback e armazena as requisições em buffer na memória para testes locais. Não o implante como serviço de upload. Uma aplicação que mantém arquivos precisa de acesso autenticado e autorizado, limites de requisições e de concorrência, inspeção de conteúdo e uma política deliberada de novas tentativas. Mantenha os arquivos enviados fora de diretórios estáticos públicos e aplique qualquer verificação de malware necessária antes de disponibilizá-los. Essas decisões de armazenamento não mudam a distinção que o formulário Bootstrap faz entre enviar um corpo e receber a aceitação do servidor.
