Implémenter des téléversements de fichiers avec Bootstrap 5
Utilisez les styles Bootstrap pour les champs de fichier, les boutons, les alertes et les barres de progression afin de créer un formulaire de téléversement d’un seul fichier. Cet exemple accepte un fichier JPEG, PNG ou PDF jusqu’à 5 MiB, empêche une seconde soumission tant qu’un téléversement est en cours, et attend la réponse du serveur avant de signaler l’acceptation.
Bootstrap assure la présentation. JavaScript gère la sélection et le transfert, et un petit récepteur
Node.js vérifie la requête. Vous allez créer trois fichiers : public/index.html, public/upload.js
et server.ts. Le récepteur indique la taille du fichier et sa somme de contrôle SHA-256, puis
le jette ; il n’enregistre pas les téléversements.
Configurer un environnement Bootstrap 5
Utilisez Node.js 26 et un navigateur récent. Cet exemple a été testé avec Node.js 26.8.1 et 26.5.0, ainsi qu’avec Chromium 152 sous Linux. Il utilise le CSS de Bootstrap 5.3.8 issu de son guide de démarrage rapide officiel, avec le hash d’intégrité correspondant. Ces composants n’ont pas besoin du bundle JavaScript de Bootstrap. Un accès à Internet est nécessaire pour charger la feuille de style ; le serveur n’a besoin d’aucun paquet tiers.
Dans un terminal compatible Bash, créez un nouveau projet :
mkdir bootstrap-upload &&
cd bootstrap-upload &&
printf '%s\n' '{"type":"module"}' > package.json &&
mkdir public
Le fichier package.json généré active les modules ES pour server.ts. La chaîne de commandes
s’arrête si le répertoire existe déjà ou si le changement de répertoire échoue. Dans ce cas,
choisissez un autre nom de répertoire ; n’écrasez pas un projet existant. Créez les trois fichiers
ci-dessous dans ce nouveau projet avant de démarrer le serveur.
Créer un formulaire simple de téléversement de fichiers
Enregistrez ce document complet sous 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>
Le champ de fichier Bootstrap visible et doté d’un libellé conserve
le sélecteur du navigateur, utilisable au clavier. Le glisser-déposer est une manière supplémentaire
de sélectionner un fichier. La colonne occupe toute la largeur d’un petit écran et se rétrécit sur
les écrans plus grands ; d-grid d-sm-flex fait occuper toute la largeur au bouton Upload sur les
téléphones.
Une bordure rouge seule n’explique pas une erreur. Le script associe .is-invalid à aria-invalid
et à une alerte visible référencée par aria-describedby. Le formulaire utilise novalidate afin
que ces messages gèrent la soumission de manière cohérente. La
documentation de validation de Bootstrap déconseille de s’appuyer
uniquement sur ses styles de validation personnalisés et ses infobulles pour l’accessibilité.
Ajouter le glisser-déposer, la validation et la progression
Enregistrez l’intégralité du script sous public/upload.js. La sélection et la soumission partagent les
mêmes vérifications. Une sélection invalide vide le champ, de sorte que choisir à nouveau le même
fichier déclenche une nouvelle vérification. Pendant une requête, le fieldset est désactivé et les
gestionnaires d’événements rejettent toute nouvelle soumission et tout nouveau dépôt. Le fichier
sélectionné reste disponible pour une nouvelle tentative après l’échec d’une requête.
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
fournit la progression du transfert. Atteindre 100 % signifie que le corps de la requête a été
envoyé, et non que le serveur a accepté le fichier. La
barre de progression Bootstrap dotée d’un libellé expose la valeur
numérique, tandis que le texte d’état distinct explique cette différence. Les petits téléversements
locaux peuvent passer directement à 100 %.
Laissez le navigateur définir l’en-tête multipart Content-Type : il inclut la délimitation
(boundary) nécessaire pour analyser FormData.
Le corps est capturé avant la désactivation des contrôles, car les champs désactivés sont exclus. Un
délai d’expiration de 30 secondes libère le formulaire si aucune
réponse n’arrive. Un délai dépassé ou un échec de connexion ne permet pas de savoir si un serveur a
déjà traité la requête. Cette démo, qui se contente de jeter les fichiers, peut être relancée sans
risque ; un service de stockage aurait besoin de sa propre politique de nouvelle tentative.
Gérer les téléversements de fichiers côté serveur
Enregistrez ceci sous server.ts à côté de public/. Il ne sert que les deux fichiers
publics, plafonne la taille de la requête mise en mémoire tampon et analyse les données multipart
avec les API web intégrées de Node. Les 64 KiB supplémentaires
laissent de la place pour les en-têtes multipart ; le fichier lui-même reste limité à 5 MiB. La
somme de contrôle vous permet de comparer indépendamment les octets reçus avec le fichier d’origine.
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}`)
}
})
Depuis bootstrap-upload, démarrez-le avec :
node server.ts
Ouvrez l’URL affichée, qui utilise un port disponible. N’ouvrez pas index.html directement : le
formulaire et le récepteur doivent partager l’origine du serveur. Arrêtez le serveur avec Ctrl+C.
Téléverser à nouveau le même fichier ne remplace rien sur le disque, car aucun fichier téléversé
n’est conservé.
Tester le formulaire
Sélectionnez un petit PDF au clavier, puis activez Upload. Le résultat doit indiquer « Accepted » et afficher le nombre d’octets reçus ainsi que le SHA-256, suivis de « The demo did not save the file. ». Vous pouvez aussi déposer un fichier dans le groupe encadré. Redimensionnez la fenêtre pour vérifier la disposition mobile empilée.
Essayez un fichier vide, un fichier texte, deux fichiers déposés et un fichier de plus de 5 MiB.
Chacun doit produire une explication visible sans envoyer de requête. Un libellé MIME vide ou
inattendu est également rejeté, même si le nom du fichier se termine par .pdf. Pendant le
téléversement, le sélecteur et le bouton Upload restent désactivés. Après un échec, activez Upload
pour réessayer avec la même sélection. Après une réussite, sélectionnez de nouveau un fichier pour
lancer un autre téléversement.
Les outils de développement du navigateur peuvent brider la connexion pour faciliter l’observation de la progression. Arrêter le récepteur produit une erreur réseau ; un récepteur qui ne répond jamais produit le message de délai dépassé. Aucun de ces deux cas ne doit afficher d’acceptation. Si la page semble dépourvue de styles, vérifiez la requête de la feuille de style et l’erreur d’intégrité dans la console du navigateur.
Considérations de sécurité
L’attribut accept et les vérifications JavaScript aident les utilisateurs à choisir un
fichier. Ils ne constituent pas une frontière de sécurité. Le
File.type d’un navigateur et le type MIME d’un
fichier multipart décrivent des métadonnées fournies par le client ; le récepteur ne vérifie pas si
les octets sont réellement une image ou un PDF. Sa somme de contrôle indique ce qui est arrivé, et
non si le contenu est sûr.
Ce récepteur écoute sur l’interface loopback et met les requêtes en mémoire tampon pour des tests locaux. Ne le déployez pas comme service de téléversement. Une application qui conserve des fichiers a besoin d’un accès authentifié et autorisé, de limites de requêtes et de concurrence, d’une inspection du contenu et d’une politique de nouvelle tentative réfléchie. Conservez les fichiers téléversés en dehors des répertoires statiques publics, et appliquez toute analyse de sécurité requise avant de les rendre disponibles. Ces choix de stockage ne changent rien à la distinction, dans le formulaire Bootstrap, entre l’envoi d’un corps de requête et la réception de l’acceptation par le serveur.
