Implementar la subida de archivos con Bootstrap 5
Usa los estilos de Bootstrap para campos de archivo, botones, alertas y progreso para crear un formulario de subida de un solo archivo. Este ejemplo acepta un JPEG, PNG o PDF de hasta 5 MiB, impide un segundo envío mientras hay una subida pendiente y espera la respuesta del servidor antes de informar de la aceptación.
Bootstrap aporta la presentación. JavaScript gestiona la selección y la transferencia, y un pequeño
receptor en Node.js comprueba la solicitud. Crearás tres archivos: public/index.html, public/upload.js
y server.ts. El receptor informa del tamaño del archivo y su suma de comprobación
SHA-256, y después lo descarta; no guarda los archivos subidos.
Configura un entorno de Bootstrap 5
Usa Node.js 26 y un navegador actual. Este ejemplo se probó con Node.js 26.8.1 y 26.5.0, y Chromium 152 en Linux. El ejemplo usa el CSS de Bootstrap 5.3.8 de su guía oficial de inicio rápido, incluido el hash de integridad correspondiente. Estos componentes no necesitan el paquete JavaScript de Bootstrap. Se requiere acceso a Internet para cargar la hoja de estilos; el servidor no necesita paquetes de terceros.
En una terminal compatible con Bash, crea un proyecto nuevo:
mkdir bootstrap-upload &&
cd bootstrap-upload &&
printf '%s\n' '{"type":"module"}' > package.json &&
mkdir public
El archivo package.json generado habilita los módulos ES para server.ts.
La cadena se detiene si el directorio ya existe o si falla la navegación. En ese caso, elige otro
nombre de directorio; no sobrescribas un proyecto existente. Crea los tres archivos siguientes en
este proyecto nuevo antes de iniciar el servidor.
Crea un formulario sencillo de subida de archivos
Guarda 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>
El campo de archivo de Bootstrap, visible y etiquetado,
conserva el selector del navegador que se maneja con el teclado. Arrastrar y soltar es una forma
adicional de seleccionar un archivo. La columna ocupa todo el ancho en pantallas pequeñas y se
estrecha en las grandes; d-grid d-sm-flex hace que el botón Upload ocupe todo el ancho
en los teléfonos.
Un borde rojo por sí solo no explica un error. El script combina .is-invalid con aria-invalid
y una alerta visible referenciada mediante aria-describedby. El formulario usa
novalidate para que estos mensajes gestionen el envío de forma coherente. La
documentación de validación de Bootstrap
advierte que no se debe depender únicamente de sus estilos de validación personalizados y mensajes
emergentes para garantizar la accesibilidad.
Añade arrastrar y soltar, validación y progreso
Guarda el script completo como public/upload.js. La selección y el envío comparten las
mismas comprobaciones. Una selección no válida vacía el campo para que volver a elegir el mismo
archivo active una nueva comprobación. Durante una solicitud, el grupo de campos se deshabilita y
los manejadores de eventos rechazan nuevos envíos y archivos soltados. El archivo seleccionado
sigue disponible para reintentar tras una solicitud fallida.
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
proporciona el progreso de la transferencia. Llegar al 100 % significa que se envió el cuerpo de
la solicitud, no que el servidor haya aceptado el archivo. La
barra de progreso de Bootstrap, etiquetada,
expone el valor numérico, mientras que un texto de estado independiente explica esa diferencia.
Las subidas locales pequeñas pueden saltar directamente al 100 %.
Deja que el navegador establezca el encabezado Content-Type de multipart: incluye
el delimitador necesario para analizar
FormData. El cuerpo se captura
antes de deshabilitar los controles porque los campos deshabilitados quedan excluidos. Un
tiempo de espera de 30 segundos
libera el formulario si no llega ninguna respuesta. Un tiempo de espera agotado o un fallo de
conexión no permiten determinar si un servidor ya procesó la solicitud. Es seguro reintentar con
esta demostración, que solo descarta los archivos; un servicio de almacenamiento necesitaría su
propia política de reintentos.
Gestiona las subidas de archivos del lado del servidor
Guarda esto como server.ts junto a public/. Solo sirve los
dos archivos públicos, limita la solicitud almacenada en el búfer y analiza los datos multipart
con las API web integradas de Node. Los 64 KiB adicionales
dejan espacio para los encabezados multipart; el archivo en sí sigue teniendo un límite de 5 MiB.
La suma de comprobación te permite comparar de forma independiente los bytes recibidos con el
archivo 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}`)
}
})
Desde dentro de bootstrap-upload, inícialo con:
node server.ts
Abre la URL que se muestra, que usa un puerto disponible. No abras index.html
directamente: el formulario y el receptor deben compartir el origen del servidor. Detén el servidor
con Ctrl+C. Volver a subir el mismo archivo no reemplaza nada en el disco porque no se conserva
ningún archivo subido.
Prueba el formulario
Selecciona un PDF pequeño con el teclado y luego activa Upload. El resultado debería decir «Accepted» y mostrar la cantidad de bytes recibidos y el SHA-256, seguido de «The demo did not save the file». También puedes soltar un archivo dentro del grupo delimitado por el borde. Cambia el tamaño de la ventana para comprobar la disposición apilada para móviles.
Prueba con un archivo vacío, un archivo de texto, dos archivos soltados y un archivo de más de
5 MiB. Cada caso debería generar una explicación visible sin enviar una solicitud. También se
rechaza una etiqueta MIME vacía o inesperada, aunque el nombre del archivo termine en
.pdf. Durante la subida, el selector y el botón Upload permanecen
deshabilitados. Después de un fallo, activa Upload para reintentar con la misma selección. Tras
una subida correcta, selecciona de nuevo un archivo para iniciar otra subida.
Las herramientas de desarrollo del navegador permiten limitar la velocidad de la conexión para observar el progreso con más facilidad. Detener el receptor produce un error de red; un receptor que nunca responde produce el mensaje de tiempo de espera agotado. Ninguno de los dos casos debería mostrar la aceptación. Si la página aparece sin estilos, comprueba la solicitud de la hoja de estilos y el error de integridad en la consola del navegador.
Consideraciones de seguridad
El atributo accept y las comprobaciones de JavaScript ayudan a los usuarios
a elegir un archivo. No constituyen una barrera de seguridad. La propiedad
File.type del navegador
y el tipo MIME de un archivo multipart describen metadatos proporcionados por el cliente; el
receptor no inspecciona si los bytes son realmente una imagen o un PDF. Su suma de comprobación
informa de lo que llegó, no de si el contenido es seguro.
Este receptor escucha en la interfaz de loopback y almacena las solicitudes en memoria para las pruebas locales. No lo despliegues como servicio de subida. Una aplicación que conserva archivos necesita acceso autenticado y autorizado, límites de solicitudes y concurrencia, inspección del contenido y una política de reintentos definida de forma deliberada. Mantén los archivos subidos fuera de los directorios estáticos públicos y aplica los análisis necesarios antes de ponerlos a disposición. Esas decisiones de almacenamiento no cambian la distinción del formulario de Bootstrap entre enviar un cuerpo y recibir la aceptación del servidor.
