HTML5-Datei-Uploads per Drag-and-drop mit JavaScript umsetzen
Ein Batch-Upload braucht für jede Datei ein Ergebnis, auch wenn eine Anfrage fehlschlägt. Erstellen Sie einen JavaScript-Dateiuploader, mit dem Sie mehrere Dateien auswählen oder ablegen können. Er sendet sie nacheinander und hält den Fortschritt sowie die Serverbestätigung jeder Datei sichtbar.
Einen lokalen Batch-Dateiuploader einrichten
Sie benötigen einen Browser, eine POSIX-Shell wie Bash und Node.js 24.15 oder neuer aus der
gepflegten Versionsreihe 24.x oder Node.js 26.5 oder neuer aus der Versionsreihe 26.x. Es sind weder
Paketinstallationen noch Build-Schritte nötig. Der Server nutzt die integrierten APIs von Node für
Request, File und das Parsen von Multipart-Daten.
Behalten Sie die Erweiterung .mts bei:
Node behandelt die Datei als ES-Modul,
auch innerhalb eines CommonJS-Projekts.
Diese Anleitung wurde unter Linux mit Node.js 24.15.0, 26.5.0 und 26.8.1 sowie Chromium 145 getestet.
Dieses Beispiel prüft die Byte-Übereinstimmung und verwirft anschließend den Upload. Es akzeptiert jeden Dateityp, einschließlich leerer Dateien, mit bis zu 5 MiB pro Datei und höchstens 10 Dateien pro Auswahl. Wenn Sie nur eine Datei mit manuellen Wiederholungsversuchen benötigen, nutzen Sie die Anleitung für einen Einzeldatei-Uploader.
Erstellen Sie in dem Verzeichnis, in dem Sie Ihre Experimente aufbewahren, einen neuen Ordner:
mkdir html5-upload-demo
Falls er bereits existiert, brechen Sie ab und wählen Sie einen neuen Verzeichnisnamen.
Überschreiben Sie kein bestehendes Projekt. Speichern Sie die nächsten vier Codeblöcke als
index.html, styles.css, upload.js und
server.mts in diesem Ordner.
Ein einfaches Drag-and-drop-Formular einrichten
Speichern Sie den Code als index.html. Das native Eingabeelement bleibt sichtbar
und per Tastatur bedienbar. Das
Attribut multiple
ermöglicht es, mehrere Dateien auf einmal auszuwählen. Eine erneute Auswahl ersetzt die
Warteschlange, solange diese inaktiv ist.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Batch file uploader</title>
<link rel="stylesheet" href="styles.css" />
<script src="upload.js" defer></script>
</head>
<body>
<main>
<h1>Upload a batch of files</h1>
<form id="upload-form" aria-label="Batch upload">
<section id="drop-zone" aria-label="Drop files">
<label for="file-input">Choose files</label>
<input id="file-input" type="file" multiple aria-describedby="file-help" />
<p id="file-help">Choose or drop up to 10 files, each no larger than 5 MiB.</p>
</section>
<button id="upload-button" type="submit" disabled>Upload batch</button>
</form>
<p id="batch-status" role="status" aria-atomic="true">Choose files to begin.</p>
<ol id="queue" aria-label="File queue"></ol>
<noscript>This uploader needs JavaScript enabled.</noscript>
</main>
</body>
</html>
Die Warteschlange gestalten
Speichern Sie den Code als styles.css. Systemfarben folgen dem hellen oder
dunklen Design des Browsers. Lange Namen und Prüfsummen werden umgebrochen, statt die Seite zu
verbreitern.
:root { color-scheme: light dark; }
body {
font: 1rem/1.5 system-ui, sans-serif;
max-width: 45rem;
margin: 2rem auto;
padding: 0 1rem;
color: CanvasText;
background: Canvas;
}
#drop-zone { border: 2px dashed currentColor; padding: 1rem; }
#drop-zone.drag-over { outline: 3px solid Highlight; }
label { display: block; font-weight: bold; }
input { max-width: 100%; }
button, input::file-selector-button { font: inherit; padding: 0.5rem; }
button { margin-top: 1rem; }
:focus-visible { outline: 3px solid Highlight; outline-offset: 3px; }
#queue { padding-left: 1.5rem; }
#queue li { margin-block: 1rem; overflow-wrap: anywhere; }
progress { display: block; width: 100%; }
Drag-and-drop-Interaktionen implementieren
Speichern Sie den Code als upload.js. Jede Zeile der Warteschlange zeigt vor
dem Senden den Dateinamen und die Größe in Bytes. Dieses Beispiel decodiert keine Dateien für eine
Bildvorschau. Prüfungen bei der Auswahl geben sofort Rückmeldung. Der Empfänger setzt seine
Grenzwerte für Dateien und Anfragekörper unabhängig davon durch.
Wir verwenden XMLHttpRequest.upload
für den Fortschritt beim Senden des Anfragekörpers. Jeder Balken gehört zu einer Datei und schließt
den Multipart-Overhead ein. Ein Balken bei 100 % bedeutet, dass der Anfragekörper gesendet wurde;
die Zeile wartet weiterhin auf die Antwort. Nur HTTP 200 mit der erwarteten Byte-Anzahl und
SHA-256-Prüfsumme führt zu einem akzeptierten Ergebnis.
Die Warteschlange ist für den gesamten Batch gesperrt: Deaktivieren Sie die Dateiauswahl und die Senden-Schaltfläche, ignorieren Sie abgelegte Dateien und weitere Sendeversuche und fahren Sie nach jedem einzelnen Fehler fort. Für jede Anfrage gilt eine Frist von 30 Sekunden, einschließlich der Wartezeit auf eine Antwort. Ergebnisse bleiben bis zur nächsten Auswahl sichtbar. Das erneute Senden derselben abgeschlossenen Warteschlange ist deaktiviert.
const form = document.getElementById('upload-form')
const input = document.getElementById('file-input')
const zone = document.getElementById('drop-zone')
const button = document.getElementById('upload-button')
const status = document.getElementById('batch-status')
const list = document.getElementById('queue')
const maxSize = 5 * 1024 * 1024
const number = new Intl.NumberFormat('en-US')
let queue = []
let busy = false
let started = false
function updateControls() {
input.disabled = busy
button.disabled = busy || started || queue.length === 0
}
function choose(files) {
if (busy || files.length === 0) return
queue = []
started = false
list.replaceChildren()
const oversized = files.find((file) => file.size > maxSize)
if (files.length > 10) {
status.textContent = 'Choose at most 10 files.'
} else if (oversized) {
status.textContent = `${oversized.name}: exceeds the 5 MiB limit. Select the batch again.`
} else {
queue = files.map((file, index) => {
const row = document.createElement('li')
const name = document.createElement('strong')
name.textContent = `${file.name} (${number.format(file.size)} bytes)`
const label = document.createElement('label')
label.htmlFor = `progress-${index}`
label.textContent = `Request body sent: ${file.name}`
const progress = document.createElement('progress')
progress.id = label.htmlFor
progress.setAttribute('aria-label', label.textContent)
progress.max = 100
progress.value = 0
const message = document.createElement('p')
message.textContent = 'Queued.'
row.append(name, label, progress, message)
list.append(row)
return { file, progress, message }
})
status.textContent = `${queue.length} files ready. Choose Upload batch to start.`
}
updateControls()
}
input.addEventListener('change', () => {
choose(Array.from(input.files))
// Keep our File objects; allow the same selection to fire change next time.
input.value = ''
})
zone.addEventListener('dragover', (event) => {
event.preventDefault()
if (!busy) zone.classList.add('drag-over')
})
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'))
zone.addEventListener('drop', (event) => {
event.preventDefault()
zone.classList.remove('drag-over')
choose(Array.from(event.dataTransfer.files))
})
function send(item, sha256) {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) item.progress.value = (event.loaded / event.total) * 100
else item.progress.removeAttribute('value')
})
xhr.upload.addEventListener('load', () => {
item.progress.value = 100
item.message.textContent = 'Body sent. Waiting for server acknowledgment…'
})
xhr.addEventListener('load', () => {
const receipt = xhr.response
if (xhr.status === 200 && receipt?.bytes === item.file.size && receipt?.sha256 === sha256) {
item.message.textContent = `Accepted. ${number.format(receipt.bytes)} bytes; SHA-256 ${receipt.sha256}.`
resolve(true)
} else {
item.message.textContent = xhr.status === 200
? 'Failed: invalid server receipt.'
: `Failed: server returned HTTP ${xhr.status}.`
resolve(false)
}
})
xhr.addEventListener('error', () => {
item.message.textContent = 'Failed: network error. Server acceptance is unknown.'
resolve(false)
})
xhr.addEventListener('timeout', () => {
item.message.textContent = 'Failed: request timed out. Server acceptance is unknown.'
resolve(false)
})
xhr.open('POST', '/upload')
xhr.responseType = 'json'
xhr.timeout = 30000
const body = new FormData()
body.append('file', item.file)
body.append('sha256', sha256)
xhr.send(body)
})
}
async function uploadBatch() {
if (busy || started || queue.length === 0) return
busy = true
started = true
updateControls()
let accepted = 0
let failed = 0
for (const item of queue) {
status.textContent = `Uploading ${item.file.name}…`
item.message.textContent = 'Preparing checksum…'
try {
const digest = await crypto.subtle.digest('SHA-256', await item.file.arrayBuffer())
const sha256 = Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, '0'),
).join('')
item.message.textContent = 'Sending request body…'
if (await send(item, sha256)) accepted += 1
else failed += 1
} catch {
item.message.textContent = 'Failed: could not prepare or send this file.'
failed += 1
}
}
status.textContent = `Batch finished: ${accepted} accepted, ${failed} failed. Select files for a new batch.`
busy = false
updateControls()
}
form.addEventListener('submit', (event) => {
event.preventDefault()
void uploadBatch()
})
Die Prüfsummenberechnung nutzt
crypto.subtle.digest()
und liest jeweils eine Datei in den Arbeitsspeicher. Rufen Sie die Seite über die ausgegebene
Loopback-URL auf, damit Web Crypto verfügbar ist. Lassen Sie Content-Type
unausgefüllt: Der Browser liefert die
Multipart-Boundary
für FormData.
Einen Empfänger hinzufügen, der jede Datei bestätigt
Speichern Sie den Code als server.mts. Er liefert nur die drei Browser-Assets
aus und akzeptiert pro Anfrage ein Feld namens file sowie ein Feld namens
sha256. Er liest vor dem Parsen den vollständigen Anfragekörper und erlaubt
16 KiB Multipart-Overhead zusätzlich zum Dateilimit von 5 MiB. Eine Anfrage, die dieses Limit für
den Anfragekörper überschreitet, erhält HTTP 413. Die Prüfsumme in einer erfolgreichen Antwort
stammt aus den Bytes, die der Empfänger tatsächlich gelesen hat.
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
const maxSize = 5 * 1024 * 1024
const maxBody = maxSize + 16 * 1024
let origin = ''
const assets = new Map<string, { body: Buffer; type: string }>()
function reply(res: ServerResponse, code: number, data: object): void {
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
res.end(JSON.stringify(data))
}
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.headers.host !== new URL(origin).host) {
reply(res, 403, { error: 'Use the printed loopback URL.' })
return
}
if (req.method === 'GET' && req.url === '/favicon.ico') {
res.writeHead(204).end()
return
}
const asset = assets.get(req.url ?? '')
if (req.method === 'GET' && asset) {
res.writeHead(200, { 'Content-Type': asset.type })
res.end(asset.body)
return
}
if (req.method !== 'POST' || req.url !== '/upload') {
reply(res, 404, { error: 'Not found.' })
return
}
if (req.headers.origin !== origin) {
reply(res, 403, { error: 'Use the uploader on this server.' })
return
}
const chunks: Buffer[] = []
let bytes = 0
// Keep the socket available for an error response when leaving iteration early.
for await (const chunk of req.iterator({ destroyOnReturn: false })) {
if (!Buffer.isBuffer(chunk)) throw new Error('Expected request bytes')
bytes += chunk.length
if (bytes > maxBody) {
reply(res, 413, { error: 'Request body exceeds the limit.' })
req.resume()
return
}
chunks.push(chunk)
}
let data: FormData
try {
data = await new Request(origin, {
method: 'POST',
headers: { 'Content-Type': req.headers['content-type'] ?? '' },
body: Buffer.concat(chunks),
}).formData()
} catch {
reply(res, 400, { error: 'Invalid multipart body.' })
return
}
const file = data.get('file')
const expected = data.get('sha256')
if ([...data.keys()].length !== 2 || !(file instanceof File) || typeof expected !== 'string') {
reply(res, 400, { error: 'Expected one file and one checksum.' })
return
}
if (file.size > maxSize) {
reply(res, 413, { error: 'File exceeds the 5 MiB limit.' })
return
}
const sha256 = createHash('sha256').update(new Uint8Array(await file.arrayBuffer())).digest('hex')
if (sha256 !== expected) {
reply(res, 422, { error: 'Checksum does not match.' })
return
}
reply(res, 200, { bytes: file.size, sha256 })
}
async function main(): Promise<void> {
const portText = process.env.PORT ?? '0'
const port = Number(portText)
if (!/^\d+$/.test(portText) || !Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error('PORT must be an integer from 0 to 65535.')
}
for (const [route, file, type] of [
['/', 'index.html', 'text/html; charset=utf-8'],
['/styles.css', 'styles.css', 'text/css'],
['/upload.js', 'upload.js', 'text/javascript'],
]) {
assets.set(route, { body: await readFile(new URL(file, import.meta.url)), type })
}
const server = createServer((req, res) => {
void handle(req, res).catch(() => {
if (!res.destroyed && !res.writableEnded) reply(res, 500, { error: 'Unable to process upload.' })
})
})
server.requestTimeout = 35000
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(port, '127.0.0.1', resolve)
})
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing TCP address')
origin = `http://127.0.0.1:${address.port}`
console.log(`Open ${origin}`)
}
main().catch((error: unknown) => {
const code = error instanceof Error && 'code' in error ? error.code : ''
console.error(code === 'EADDRINUSE'
? 'Port is already in use. Choose another PORT or leave it unset.'
: 'Could not start uploader. Check PORT and the three browser files.')
process.exitCode = 1
})
Der Server bindet sich an die Loopback-Adresse und wählt einen verfügbaren Port, wenn
PORT nicht gesetzt oder null ist. Sie können
PORT in der Serverumgebung setzen, wenn Sie einen festen Port benötigen.
Fehlende Assets, ein ungültiger Wert für PORT oder ein belegter Port
brechen den Start mit einem von null verschiedenen Exit-Status ab.
Den Batch ausführen und seine Ergebnisse lesen
Fügen Sie im übergeordneten Verzeichnis, in dem Sie html5-upload-demo erstellt haben,
Folgendes ein:
(cd html5-upload-demo && node server.mts)
Öffnen Sie die ausgegebene URL, zum Beispiel http://127.0.0.1:49152. Lassen Sie dieses
Terminal geöffnet. Strg+C stoppt den Server und bringt Sie zum übergeordneten Verzeichnis zurück.
Starten Sie nach der Bearbeitung eines Assets neu, da der Server diese Dateien beim Start lädt.
Navigieren Sie mit der Tabulatortaste zu Choose files, öffnen Sie die Dateiauswahl und wählen Sie zwei Dateien mit unterschiedlichen Größen aus. Navigieren Sie mit der Tabulatortaste zu Upload batch und aktivieren Sie die Schaltfläche. Jede Zeile sollte Accepted., ihre Byte-Anzahl und eine SHA-256-Prüfsumme anzeigen. Der abschließende Status meldet zwei akzeptierte Dateien und null fehlgeschlagene Dateien. Auf dem Server wurde nichts gespeichert.
Legen Sie für eine zweite Auswahl Dateien im umrandeten Bereich ab. Während ein Batch läuft, sind sein Eingabeelement und seine Schaltfläche deaktiviert. Das Ablegen weiterer Dateien verändert die aktuellen Zeilen nicht. Bei einer schnellen lokalen Verbindung kann ein Balken direkt auf 100 % springen. Fortschrittsereignisse sind weder eine flüssige Animation noch ein Maß für die Verarbeitung auf dem Server.
Bei einer abgelehnten Anfrage bleibt die fehlgeschlagene Zeile erhalten, und die nächste Datei in der Warteschlange wird trotzdem gesendet. HTTP 400 weist auf fehlerhafte Multipart-Daten oder falsche Felder hin, 403 auf eine Abweichung beim Ursprung oder Host, 413 auf ein Größenlimit und 422 auf eine abweichende Prüfsumme. Auch eine fehlerhafte oder inkonsistente Bestätigung mit HTTP 200 gilt als Fehlschlag. Wird der Empfänger unerreichbar oder überschreitet er die Frist, ist die Annahme unbekannt: Eine fehlende Antwort sagt nicht aus, ob ein Server seine Arbeit abgeschlossen hat. Wählen Sie für einen erneuten Versuch die fehlgeschlagenen Dateien als neuen Batch aus. Bei jedem erneuten Versuch wird ihr gesamter Inhalt nochmals gesendet. Beim Neuladen geht die Warteschlange verloren.
Entscheiden, was ein öffentlicher Dateiuploader braucht
Dieser lokale Empfänger prüft Größen und Byte-Übereinstimmung, akzeptiert beliebige Inhalte und verwirft sie. Eine Prüfsumme belegt nicht, dass eine Datei sicher oder vollständig decodierbar ist. Die Host- und Ursprungsprüfungen sind keine Benutzerauthentifizierung. Bevor Sie Uploads in einem öffentlichen Dienst aufbewahren, ergänzen Sie Autorisierung, Inhaltsvalidierung, eine Speicherrichtlinie und CSRF-Schutz, wo Cookie-basierte Sitzungen ihn benötigen. Die Upload-Empfehlungen von OWASP erläutern diese Maßnahmen. Bewahren Sie gespeicherte Uploads außerhalb des Web-Stammverzeichnisses auf und behandeln Sie eine verlorene Bestätigung, ohne doppelte Datensätze zu erzeugen, etwa mit einem serverseitig durchgesetzten Idempotenzschlüssel.
Behalten Sie beim Anpassen der Benutzeroberfläche das sichtbare Dateieingabeelement, beschriftete Fortschrittsbalken, Fokusumrandungen und die Live-Region für den Batch bei. Fehler werden als Text angezeigt und bleiben unabhängig von Farben lesbar. Ein einzelner Prozentwert für den gesamten Batch bräuchte eine festgelegte Gewichtung. Ein Durchschnitt der Prozentwerte gewichtet eine winzige Datei genauso stark wie eine große.
Für eine umfangreichere Oberfläche unterstützt
das XHRUpload-Plugin von Uppy Multipart-Uploads an einen kompatiblen
Empfänger. Für die Wiederaufnahme von Uploads benötigt das
Tus-Plugin einen tus-kompatiblen Server. Keine der beiden Optionen
macht diesen Empfänger in /upload wiederaufnahmefähig.
