Datei-Uploads mit Bootstrap 5 implementieren
Erstellen Sie mit den Bootstrap-Stilen für Dateieingaben, Buttons, Meldungen und Fortschritt ein Uploadformular für einzelne Dateien. Dieses Beispiel akzeptiert eine JPEG-, PNG- oder PDF-Datei bis 5 MiB, verhindert ein zweites Absenden während eines laufenden Uploads und wartet auf die Serverantwort, bevor es die Annahme meldet.
Bootstrap liefert die Darstellung. JavaScript übernimmt Auswahl und Übertragung, und ein kleiner
Node.js-Empfänger prüft die Anfrage. Sie erstellen drei Dateien: public/index.html, public/upload.js
und server.ts. Der Empfänger meldet die Dateigröße und die SHA-256-Prüfsumme und verwirft
anschließend die Datei; er speichert keine Uploads.
Eine Bootstrap-5-Umgebung einrichten
Verwenden Sie Node.js 26 und einen aktuellen Browser. Dieses Beispiel wurde mit Node.js 26.8.1 und 26.5.0 sowie Chromium 152 unter Linux getestet. Es nutzt das CSS von Bootstrap 5.3.8 aus der offiziellen Schnellstartanleitung einschließlich des passenden Integritäts-Hashwerts. Diese Komponenten benötigen das JavaScript-Bundle von Bootstrap nicht. Zum Laden des Stylesheets ist Internetzugang erforderlich; der Server benötigt keine Drittanbieterpakete.
Erstellen Sie in einem Bash-kompatiblen Terminal ein neues Projekt:
mkdir bootstrap-upload &&
cd bootstrap-upload &&
printf '%s\n' '{"type":"module"}' > package.json &&
mkdir public
Die generierte Datei package.json aktiviert ES-Module für server.ts. Die Befehlskette
stoppt, wenn das Verzeichnis bereits existiert oder der Verzeichniswechsel fehlschlägt. Wählen Sie
in diesem Fall einen anderen Verzeichnisnamen; überschreiben Sie kein bestehendes Projekt.
Erstellen Sie die drei folgenden Dateien in diesem neuen Projekt, bevor Sie den Server starten.
Ein einfaches Uploadformular erstellen
Speichern Sie dieses vollständige Dokument als 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>
Die sichtbare, beschriftete Bootstrap-Dateieingabe
behält die per Tastatur bedienbare Dateiauswahl des Browsers bei. Drag-and-drop bietet eine
zusätzliche Möglichkeit, eine Datei auszuwählen. Die Spalte füllt einen kleinen Bildschirm aus und
wird auf größeren Bildschirmen schmaler; d-grid d-sm-flex lässt den Button „Upload“ auf
Smartphones die volle Breite einnehmen.
Ein roter Rahmen allein erklärt keinen Fehler. Das Skript kombiniert .is-invalid mit aria-invalid
und einer sichtbaren Meldung, auf die aria-describedby verweist. Das Formular nutzt novalidate,
damit diese Meldungen das Absenden einheitlich begleiten. Die
Validierungsdokumentation von Bootstrap
warnt davor, sich für Barrierefreiheit allein auf die benutzerdefinierten Validierungsstile und
Tooltips zu verlassen.
Drag-and-drop, Validierung und Fortschritt ergänzen
Speichern Sie das gesamte Skript als public/upload.js. Auswahl und Absenden nutzen dieselben
Prüfungen. Eine ungültige Auswahl leert das Eingabefeld, sodass die erneute Auswahl derselben Datei
eine neue Prüfung auslöst. Während einer Anfrage ist die Feldgruppe deaktiviert, und die
Ereignishandler weisen weiteres Absenden und Ablegen von Dateien zurück. Nach einer fehlgeschlagenen
Anfrage bleibt die ausgewählte Datei für einen erneuten Versuch verfügbar.
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
liefert den Übertragungsfortschritt. 100 % bedeutet, dass der Anfrageinhalt gesendet wurde, nicht,
dass der Server die Datei angenommen hat. Der beschriftete
Bootstrap-Fortschrittsbalken
macht den Zahlenwert zugänglich, während der separate Statustext diesen Unterschied erklärt.
Kleine lokale Uploads können direkt auf 100 % springen.
Überlassen Sie den Multipart-Header Content-Type dem Browser: Er enthält die Begrenzung,
die zum Parsen von FormData erforderlich ist.
Der Anfrageinhalt wird vor dem Deaktivieren der Steuerelemente erfasst, da deaktivierte Felder
nicht berücksichtigt werden. Ein
Timeout von 30 Sekunden
gibt das Formular wieder frei, wenn keine Antwort eintrifft. Ein Timeout oder Verbindungsfehler
belegt nicht, ob ein Server die Anfrage bereits verarbeitet hat. Bei dieser Demo, die Dateien nur
verwirft, ist ein erneuter Versuch sicher; ein Speicherdienst bräuchte eigene Regeln für Retries.
Datei-Uploads serverseitig verarbeiten
Speichern Sie dies als server.ts neben public/. Der Server liefert nur die beiden
öffentlichen Dateien aus, begrenzt die gepufferte Anfrage und parst Multipart-Daten mit den
integrierten Web-APIs von Node. Die zusätzlichen 64 KiB bieten
Platz für Multipart-Header; für die Datei selbst gilt weiterhin das Limit von 5 MiB. Mit der
Prüfsumme können Sie die empfangenen Bytes unabhängig mit der Originaldatei vergleichen.
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}`)
}
})
Starten Sie den Server im Verzeichnis bootstrap-upload mit:
node server.ts
Öffnen Sie die ausgegebene URL, die einen verfügbaren Port verwendet. Öffnen Sie index.html
nicht direkt: Formular und Empfänger müssen denselben Ursprung des Servers nutzen. Stoppen Sie den
Server mit Ctrl+C. Ein erneuter Upload derselben Datei ersetzt nichts auf dem Datenträger, da
keine hochgeladene Datei aufbewahrt wird.
Das Formular ausprobieren
Wählen Sie per Tastatur eine kleine PDF-Datei aus und aktivieren Sie dann „Upload“. Das Ergebnis sollte „Accepted“ anzeigen sowie die empfangene Byteanzahl und SHA-256, gefolgt von „The demo did not save the file.“ Sie können auch eine Datei innerhalb der umrandeten Gruppe ablegen. Ändern Sie die Fenstergröße, um das untereinander angeordnete mobile Layout zu prüfen.
Testen Sie eine leere Datei, eine Textdatei, zwei gleichzeitig abgelegte Dateien und eine Datei
über 5 MiB. Jeder Fall sollte eine sichtbare Erklärung auslösen, ohne eine Anfrage zu senden.
Auch eine leere oder unerwartete MIME-Angabe wird abgewiesen, selbst wenn der Dateiname auf
.pdf endet. Während des Uploads bleiben die Dateiauswahl und der Button „Upload“
deaktiviert. Aktivieren Sie nach einem Fehler „Upload“, um dieselbe Auswahl erneut hochzuladen.
Wählen Sie nach einem Erfolg erneut eine Datei aus, um einen weiteren Upload zu starten.
Mit den Entwicklertools des Browsers können Sie die Verbindung drosseln, um den Fortschritt besser zu beobachten. Das Stoppen des Empfängers führt zu einem Netzwerkfehler; ein Empfänger, der nie antwortet, führt zur Timeout-Meldung. In keinem Fall sollte eine Annahme angezeigt werden. Falls die Seite ungestaltet aussieht, prüfen Sie die Stylesheet-Anfrage und den Integritätsfehler in der Browserkonsole.
Sicherheitsaspekte
Das Attribut accept und die JavaScript-Prüfungen helfen bei der Dateiauswahl. Sie bilden
keine Sicherheitsgrenze. Der Browserwert
File.type
und der MIME-Typ einer Multipart-Datei beschreiben Metadaten, die der Client liefert; der Empfänger
prüft nicht, ob die Bytes tatsächlich ein Bild oder eine PDF-Datei sind. Seine Prüfsumme gibt an,
was angekommen ist, nicht, ob der Inhalt sicher ist.
Dieser Empfänger bindet sich an die Loopback-Schnittstelle und puffert Anfragen für lokale Tests im Arbeitsspeicher. Setzen Sie ihn nicht als Uploaddienst ein. Eine Anwendung, die Dateien aufbewahrt, benötigt authentifizierten und autorisierten Zugriff, Limits für Anfragen und Parallelität, Inhaltsprüfungen und bewusst festgelegte Regeln für Retries. Bewahren Sie hochgeladene Dateien außerhalb öffentlicher Verzeichnisse für statische Inhalte auf und führen Sie alle erforderlichen Scans durch, bevor Sie die Dateien verfügbar machen. Diese Entscheidungen zur Speicherung ändern nichts daran, dass das Bootstrap-Formular zwischen dem Senden eines Anfrageinhalts und dem Empfang der serverseitigen Annahmebestätigung unterscheidet.
