HTML-Datei-Upload-Formular erstellen: Tutorial für Entwickler
Ein HTML-Datei-Upload braucht ein benanntes Dateieingabefeld in einem Formular mit
method="post" und enctype="multipart/form-data" sowie einen Server, der
action des Formulars verarbeitet. Diese Anleitung verbindet die Bausteine:
Sie senden eine oder mehrere Dateien ohne JavaScript im Browser und sehen, wie der Empfänger
Dateinamen, Byte-Anzahlen und SHA-256-Hashes ausgibt.
Ein einfaches Datei-Upload-Formular in HTML einrichten
Sie benötigen Node.js 24 oder neuer, einen Browser und eine POSIX-Shell wie Bash unter Linux, macOS oder WSL. Der Empfänger nutzt die integrierten APIs von Node, Sie müssen also keine Pakete installieren. Node kann diesen TypeScript-Code durch Type Stripping direkt ausführen. Die Beispiele wurden unter Linux mit Node.js 24.15.0, 26.5.0 und 26.8.1 sowie Chromium 145 und 152 getestet.
Führen Sie Folgendes in einem Verzeichnis aus, in dem Sie Ihre Experimente ablegen:
mkdir html-upload-demo &&
cd html-upload-demo &&
touch index.html server.ts
Ein bereits vorhandenes Verzeichnis wird dabei abgelehnt. Schlägt der Befehl fehl, fahren Sie nicht mit den weiteren Schritten fort. Wählen Sie einen neuen Verzeichnisnamen oder beheben Sie den Fehler. Fügen Sie die nächsten beiden Beispiele in die gerade erstellten Dateien ein. Der Server untersucht Uploads nur im Arbeitsspeicher. Er erstellt oder überschreibt keine hochgeladenen Dateien. Wenn Sie dieselbe Datei erneut senden, erzeugt er lediglich einen weiteren Empfangsbeleg.
Speichern Sie diese vollständige Seite als index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HTML file upload demo</title>
</head>
<body>
<main>
<h1>Upload files</h1>
<form action="/upload" method="post" enctype="multipart/form-data">
<p>
<label for="file-upload">Choose files (required)</label>
<input
type="file"
id="file-upload"
name="files"
multiple
required
aria-describedby="file-help"
/>
</p>
<p id="file-help">Choose up to three files, at most 1 MiB each. Nothing is saved.</p>
<button type="submit">Upload files</button>
</form>
</main>
</body>
</html>
Jedes Formularattribut hat eine eigene Aufgabe:
| Attribut | Funktion in diesem Beispiel |
|---|---|
action="/upload" | Sendet die Übermittlung an die Route /upload auf dem Server, der diese Seite ausliefert. |
method="post" | Sendet die Formulardaten im Body der HTTP-Anfrage. |
enctype="multipart/form-data" | Codiert Dateiinhalte als separate Teile in diesem Body. |
name="files" | Benennt jeden hochgeladenen Teil, damit der Empfänger ihn abrufen kann. |
id="file-upload" | Verknüpft das Eingabefeld mit seiner Beschriftung; es benennt nicht das übermittelte Feld. |
multiple | Erlaubt die Auswahl von mehr als einer Datei im Auswahldialog. |
required | Veranlasst den Browser, vor dem Absenden eine Auswahl anzufordern. |
Der Browser erstellt die Multipart-Grenze und die Anfrage-Header für Sie. Die
Anleitung zum Senden von Formulardaten von MDN erklärt die Codierung.
Ein Eingabefeld ohne name wird in den übermittelten Daten ausgelassen,
selbst wenn es ein id hat und einen ausgewählten Dateinamen anzeigt.
Siehe dazu die Regeln für Formulareinträge im HTML-Standard.
Einen lokalen Multipart-Empfänger hinzufügen
Speichern Sie dies als server.ts. Der Code liefert die Seite aus und akzeptiert
bis zu drei Dateien mit jeweils 1 MiB. Ein separates Limit von 4 MiB begrenzt den gepufferten
Anfrage-Body einschließlich der Multipart-Header vor dem Parsen. Diese niedrigen Limits dienen
nur der Demonstration. Puffern und Parsen benötigen zusätzlich zur reinen Body-Größe weiteren
Arbeitsspeicher.
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
const MAX_FILES = 3
const MAX_FILE_BYTES = 1024 * 1024
const MAX_REQUEST_BYTES = 4 * 1024 * 1024
const page = await readFile(new URL('./index.html', import.meta.url))
function reply(response: ServerResponse, status: number, message: string): void {
response.writeHead(status, {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
})
response.end(`${message}\n\nUse Back to choose files again. Nothing was saved.\n`)
}
async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
if (request.method === 'GET' && request.url === '/') {
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
response.end(page)
return
}
if (request.method === 'GET' && request.url === '/favicon.ico') {
response.writeHead(204).end()
return
}
if (request.method !== 'POST' || request.url !== '/upload') {
reply(response, 404, 'Route not found. Open the URL printed in the terminal.')
return
}
const contentType = request.headers['content-type'] ?? ''
if (!contentType.toLowerCase().startsWith('multipart/form-data;')) {
request.resume()
reply(response, 415, 'Expected multipart/form-data. Check the form enctype.')
return
}
const chunks: Buffer[] = []
let bytes = 0
// Keep the connection alive long enough to return readable size-limit feedback.
for await (const chunk of request.iterator({ destroyOnReturn: false })) {
if (!Buffer.isBuffer(chunk)) throw new Error('Expected request bytes')
bytes += chunk.length
if (bytes > MAX_REQUEST_BYTES) {
request.resume()
reply(response, 413, 'Request exceeds 4 MiB. Choose smaller files.')
return
}
chunks.push(chunk)
}
let form: FormData
try {
form = await new Request('http://localhost/upload', {
method: 'POST',
headers: { 'Content-Type': contentType },
body: Buffer.concat(chunks),
}).formData()
} catch {
reply(response, 400, 'Could not read the multipart form. Check its encoding.')
return
}
const files = form.getAll('files')
if (files.length === 0) {
reply(response, 400, 'Choose at least one file in the files field.')
return
}
if (files.length > MAX_FILES) {
reply(response, 400, 'Choose at most three files.')
return
}
const receipts = []
for (const file of files) {
if (!(file instanceof File) || file.name === '') {
reply(response, 400, 'Choose at least one file in the files field.')
return
}
if (file.size > MAX_FILE_BYTES) {
reply(response, 413, 'Each file must be at most 1 MiB. Choose smaller files.')
return
}
receipts.push({
field: 'files',
name: file.name,
bytes: file.size,
sha256: createHash('sha256').update(Buffer.from(await file.arrayBuffer())).digest('hex'),
})
}
reply(response, 200, `Received ${files.length} file(s).\n${JSON.stringify(receipts, null, 2)}`)
}
const server = createServer((request, response) => {
void handle(request, response).catch(() => {
console.error('Upload handler failed.')
if (!response.headersSent) reply(response, 500, 'Could not process the upload. Try again.')
else response.destroy()
})
})
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}/`)
}
})
Die integrierte Request-API
parst den gesammelten Body mit
formData().
Beim Erstellen dieses Objekts wird keine weitere Netzwerkanfrage gesendet. Der Empfänger nutzt
getAll('files'),
um jeden Teil mit diesem Namen zu erfassen. get('files') würde nur den ersten
zurückgeben.
Mit der Option destroyOnReturn: false
des Streams kann der Empfänger die Leseschleife verlassen, ohne die Verbindung zu zerstören,
wenn der Body zu groß ist. Er verwirft die restliche Eingabe und gibt eine HTTP-413-Antwort zurück.
Uploads einzelner und mehrerer Dateien verarbeiten
Starten Sie den Empfänger im selben Verzeichnis html-upload-demo:
node server.ts
Öffnen Sie genau die URL, die im Terminal ausgegeben wird. Der Server wählt einen freien Port und
lauscht nur auf 127.0.0.1. Wenn Sie index.html direkt als
lokale Datei öffnen, wird die Aktion /upload nicht mit diesem Empfänger
verbunden. Beenden Sie den Server mit Strg+C, wenn Sie fertig sind. Starten Sie ihn nach jeder
Änderung an einer der beiden Dateien neu.
Deaktivieren Sie JavaScript im Browser, laden Sie die Seite neu und wählen Sie eine kleine Datei.
Klicken Sie auf Upload files. Der Browser navigiert zu /upload und zeigt
Received 1 file(s). an, gefolgt von einem Empfangsbeleg mit field,
name, bytes und sha256.
Diese Antwort bestätigt, dass der Server die Bytes angenommen und untersucht hat. Ein Dateiname
im Auswahldialog bestätigt nur die Auswahl.
Gehen Sie mit „Zurück“ zur vorherigen Seite, wählen Sie zwei Dateien gleichzeitig und senden Sie
das Formular erneut. Sie sollten Received 2 file(s). und zwei Empfangsbelege sehen. Testen Sie
Dateinamen mit Leerzeichen oder Nicht-ASCII-Zeichen. Um einen Empfangsbeleg mit Ihrer lokalen Datei
zu vergleichen, führen Sie Folgendes im Demo-Verzeichnis aus. Ersetzen Sie dabei den Pfad nach
-- durch den Pfad Ihrer Datei:
node --input-type=module -e 'import { readFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const bytes = await readFile(process.argv[1]); console.log(bytes.length, createHash("sha256").update(bytes).digest("hex"))' -- '/path/to/your file.txt'
Sowohl die Byte-Anzahl als auch der Hash sollten übereinstimmen. Dieser Befehl liest die Datei nur. Eine bewusst ausgewählte leere Datei ist gültig und ergibt null Bytes. Ein leerer Auswahldialog ist ein anderer Fall.
multiple ändert, wie viele Dateien der Nutzer auswählen kann, nicht den
Feldnamen. Jede ausgewählte Datei wird unter files gesendet. HTML verlangt
nicht die Klammerkonvention files[]; dieser Empfänger erwartet den wörtlichen
Schlüssel files. Wenn ein anderes Backend files[]
erwartet, müssen beide Seiten genau diesen Namen verwenden.
Für einen Auswahldialog, der nur eine Datei zulässt, lassen Sie multiple weg.
Das ändert nur das Bedienelement im Browser. Erzwingen Sie die Beschränkung auf eine Datei auch
auf dem Server, falls Ihre Anwendung dies erfordert.
Clientseitige Validierung hinzufügen
Ohne Auswahl löst Upload files den Pflichtfeldhinweis des Browsers aus, und Sie bleiben im Formular. Der Empfänger lehnt einen leeren Upload ebenfalls mit HTTP 400 ab, da Clients die Browservalidierung umgehen können. HTML hat kein Dateigrößenattribut, das das Limit von 1 MiB erzwingt. Zu große ausgewählte Dateien erreichen deshalb den Empfänger und führen zu einer Fehlerseite.
Diese Demo akzeptiert jeden Dateityp und untersucht nur die Bytes. Um die Auswahl von Bildern oder
Dokumenten zu lenken, können Sie dem Eingabefeld accept=".jpg,.jpeg,.png,.pdf" hinzufügen.
Wie die Dokumentation zur Dateieingabe von MDN erklärt,
ist accept ein Hinweis für den Auswahldialog, keine Inhaltsvalidierung.
Dadurch erhält dieser Empfänger keine Typprüfungen. Weder Dateiname noch Erweiterung noch
mitgelieferter MIME-Typ können belegen, dass eine Datei sicher ist.
Testen Sie diese Fehlerfälle, bevor Sie das Beispiel anpassen:
| Übermittlung | Erwartetes Ergebnis |
|---|---|
| Keine Datei ausgewählt | Der Browser fordert eine Datei an; eine direkte leere Anfrage erhält HTTP 400. |
| Vier kleine Dateien | HTTP 400: „Choose at most three files.“ |
| Eine Datei über 1 MiB | HTTP 413 mit Hinweis zum Größenlimit. |
| Gesamte Anfrage über 4 MiB | HTTP 413 vor dem Multipart-Parsen. |
Ein Dateifeld namens file oder files[] | HTTP 400, weil der Empfänger files nicht finden kann. |
Gehen Sie nach einem Fehler mit „Zurück“ zur vorherigen Seite und treffen Sie eine gültige Auswahl. Eine erfolgreiche Antwort gilt für die gesamte Übermittlung. Weder bei einer erfolgreichen noch bei einer abgelehnten Anfrage wird etwas gespeichert.
Die Datei-Upload-Schaltfläche anpassen
Sie können die native Schaltfläche gestalten und dabei ihre Beschriftung, die Anzeige ausgewählter
Dateien und das Tastaturverhalten beibehalten. Fügen Sie Folgendes in der Datei
index.html innerhalb von <head> ein und starten Sie dann
den Server neu:
<style>
input[type='file']::file-selector-button {
font: inherit;
padding: 0.5rem 0.75rem;
margin-inline-end: 0.75rem;
cursor: pointer;
}
</style>
Das Pseudoelement ::file-selector-button
spricht die Schaltfläche innerhalb des Eingabefelds an. Navigieren Sie mit der Tabulatortaste zum
beschrifteten Auswahldialog und drücken Sie die Leertaste, um ihn zu öffnen. Navigieren Sie dann
mit der Tabulatortaste zu Upload files und drücken Sie die Eingabetaste zum Absenden. Lassen
Sie das Eingabefeld sichtbar und seine Fokusanzeige intakt. Sein Attribut
name="files" und seine Position im Formular verbinden die Auswahl weiterhin
mit dem Empfänger.
Sicherheitsaspekte
Betreiben Sie diesen Empfänger nur lokal. Er akzeptiert beliebige Inhalte, belegt Arbeitsspeicher pro Anfrage und bietet weder Anmeldung noch dauerhafte Speicherung oder Malware-Scans. Dateinamen werden als JSON in einer Klartextantwort angezeigt und nie als Dateisystempfade verwendet. Hashing bestätigt, welche Bytes angekommen sind. Es validiert weder deren Format noch deren Sicherheit.
Wenn Sie einer Anwendung mit Authentifizierung eine Speicherfunktion hinzufügen, legen Sie an der Servergrenze Autorisierung, Anfragelimits, Inhaltsvalidierung und CSRF-Schutz fest. Die separate Anleitung für sichere AJAX-Uploads behandelt diese sicherheitsorientierte Aufgabe.
Bei Bedarf Fortschrittsanzeige oder Drag-and-drop hinzufügen
Für eine Oberfläche, die auf der Seite bleibt und den Upload-Fortschritt anzeigt, machen Sie mit dem individuellen JavaScript-Dateiuploader weiter. Die Anleitung behandelt Drag-and-drop, Retries und Fortschrittsanzeige mit einem kompatiblen Empfänger. Wenn Ihre Anwendung bereits Bootstrap nutzt, finden Sie im Tutorial für Datei-Uploads mit Bootstrap den passenden Gestaltungsansatz.
