Eigener HTML-Dateiupload-Button mit Drag-and-drop
Gestalten Sie den Button des nativen Dateieingabefelds mit ::file-selector-button, damit
Tastaturbedienung und Anzeige der ausgewählten Datei erhalten bleiben. Wir erstellen ein
Multipart-Formular, das ohne JavaScript funktioniert, und ergänzen dann eine Ablagefläche, die eine
Datei in dasselbe Eingabefeld übernimmt. Ein lokaler Empfänger meldet, was angekommen ist.
Demo-Dateien erstellen
Verwenden Sie eine aktuelle Patch-Version von Node.js, mindestens 24.15.0, aus einer
gepflegten Versionsreihe, einen Browser und eine POSIX-Shell
wie Bash unter Linux, macOS oder WSL. Dieses Beispiel nutzt die integrierten APIs von Node und
benötigt weder eine Paketinstallation noch eine Kompilierung. Der Empfänger verwendet
.mts, damit Node ihn unabhängig von package.json
eines übergeordneten Projekts als ES-Modul ausführt. Siehe dazu die
TypeScript-Modulregeln von Node.
Die Anleitung wurde unter Linux mit Node.js 24.15.0, 26.5.0 und 26.8.1 sowie Chromium 145 und 152 getestet.
Fügen Sie in einem Verzeichnis für Ihre Experimente Folgendes ein:
(
mkdir native-upload &&
cd native-upload &&
touch index.html styles.css enhance.js server.mts
)
Durch die Klammern bleibt Ihre Shell im ursprünglichen Verzeichnis. Existiert das Verzeichnis
native-upload bereits, schlägt dieser Befehl fehl, bevor er dessen Inhalt verändert.
Beheben Sie in diesem Fall den Fehler oder wählen Sie einen neuen Verzeichnisnamen, bevor Sie
fortfahren. Speichern Sie die folgenden Beispiele in den Dateien unter
native-upload. Lassen Sie enhance.js bis zum optionalen
JavaScript-Schritt leer.
Das native Dateieingabefeld beibehalten
Speichern Sie diese vollständige Seite als native-upload/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Native file upload</title>
<link rel="stylesheet" href="styles.css" />
<script src="enhance.js" defer></script>
</head>
<body>
<main>
<h1>Upload a file</h1>
<form id="upload-form" aria-label="File upload" action="/upload"
method="post" enctype="multipart/form-data">
<section id="drop-area" aria-label="File selection">
<label for="file-input">Choose a file</label>
<p id="file-help">One file, up to 1 MiB. Nothing is saved.</p>
<input id="file-input" name="file" type="file" required
aria-describedby="file-help" />
<p id="drop-hint" hidden>Or drop one file here, then choose Upload.</p>
</section>
<p id="selection-status" role="status" aria-atomic="true"></p>
<button type="submit">Upload</button>
</form>
</main>
</body>
</html>
Es gibt ein Dateieingabefeld innerhalb des Formulars, das es absendet. Sein
name="file" ist das Multipart-Feld, das der Empfänger erwartet;
id="file-input" verknüpft die Beschriftung. required verhindert,
dass der Browser das Formular ohne ausgewählte Datei absendet. Eine bewusst ausgewählte leere Datei
ist weiterhin eine Datei und hier zulässig.
Lassen Sie das Eingabefeld sichtbar. Wenn Sie es mit display: none ausblenden,
entfällt es aus der Tastaturnavigation. Eine Beschriftung, die wie ein Button gestaltet ist, erhält
dadurch nicht dessen Tastaturverhalten. Eine ausführlichere Erklärung der Formularattribute finden
Sie in unserer
Anleitung für HTML-Dateiupload-Formulare.
Button und Ablagefläche gestalten
Speichern Sie dies als native-upload/styles.css:
:root { color-scheme: light dark; }
:root.dark { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0;
font: 1rem/1.5 system-ui, sans-serif;
background: Canvas;
color: CanvasText;
}
main { max-width: 36rem; margin: 2rem auto; padding: 1.25rem; }
label { font-weight: 600; }
input[type='file'] { display: block; width: 100%; font: inherit; }
input[type='file']::file-selector-button,
button {
font: inherit;
padding: 0.6rem 0.9rem;
border: 1px solid ButtonText;
border-radius: 0.4rem;
background: ButtonFace;
color: ButtonText;
cursor: pointer;
}
input[type='file']::file-selector-button { margin-inline-end: 0.75rem; }
input:focus-visible,
button:focus-visible { outline: 3px solid Highlight; outline-offset: 4px; }
#drop-area { border: 2px dashed GrayText; padding: 1rem; }
#drop-area.dragover { border-color: Highlight; border-style: solid; }
#selection-status { overflow-wrap: anywhere; }
Das Pseudoelement ::file-selector-button
gestaltet den Button im nativen Eingabefeld. Legen Sie seine Schriftart explizit fest, da es die
Schriftart des Eingabefelds nicht unbedingt erbt. Das umgebende Steuerelement zeigt weiterhin den
Dateinamen an und öffnet die native Dateiauswahl. Diese Systemfarben folgen dem hellen oder dunklen
Farbschema des Browsers. Die Klasse dark auf
<html> wählt explizit das dunkle Schema. Die Fokusumrandung gehört zum
eigentlichen Eingabefeld, nicht zu seiner Beschriftung. Der gestrichelt umrandete Bereich ist eine
alternative Auswahlfläche und benötigt daher keinen weiteren Tabstopp.
Formular ausliefern und seine Bytes empfangen
Speichern Sie dies als native-upload/server.mts. Der Empfänger liefert die drei Browserdateien
aus und akzeptiert genau einen Multipart-Teil namens file mit bis zu 1 MiB.
Ein separates Limit von 2 MiB begrenzt vor dem Multipart-Parsing den gepufferten Anfragekörper,
einschließlich Multipart-Headern und etwaigen nachfolgenden Daten. Parsing und Hash-Berechnung
benötigen zusätzlichen Speicher. Dies ist eine kleine lokale Demo, kein Limit für den gesamten
Serverspeicher.
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { parseArgs } from 'node:util'
const { values } = parseArgs({ options: { port: { type: 'string', default: '0' } } })
const port = Number(values.port)
if (!/^\d+$/.test(values.port) || !Number.isInteger(port) || port > 65535) {
throw new Error('Use --port with an integer from 0 to 65535.')
}
const maxFileBytes = 1024 * 1024
const maxRequestBytes = 2 * 1024 * 1024
const assets = new Map<string, { body: Buffer; type: string }>()
for (const [route, file, type] of [
['/', 'index.html', 'text/html; charset=utf-8'],
['/styles.css', 'styles.css', 'text/css; charset=utf-8'],
['/enhance.js', 'enhance.js', 'text/javascript; charset=utf-8'],
]) {
assets.set(route, { body: await readFile(new URL(file, import.meta.url)), type })
}
let origin = ''
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\nNothing was saved. Use Back to return to the form.\n`)
}
async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
if (request.headers.host !== new URL(origin).host) {
request.resume()
reply(response, 403, 'Use the printed loopback URL.')
return
}
const asset = assets.get(request.url ?? '')
if (request.method === 'GET' && asset) {
response.writeHead(200, { 'Content-Type': asset.type })
response.end(asset.body)
return
}
if (request.method === 'GET' && request.url === '/favicon.ico') {
response.writeHead(204).end()
return
}
if (request.method !== 'POST' || request.url !== '/upload') {
request.resume()
reply(response, 404, 'Route not found.')
return
}
if (request.headers.origin !== origin) {
request.resume()
reply(response, 403, 'Submit the form served by this receiver.')
return
}
const chunks: Buffer[] = []
let bytes = 0
// Preserve the connection for readable feedback when stopping the read loop early.
for await (const chunk of request.iterator({ destroyOnReturn: false })) {
if (!Buffer.isBuffer(chunk)) throw new Error('Expected request bytes')
bytes += chunk.length
if (bytes > maxRequestBytes) {
request.resume()
reply(response, 413, 'Request exceeds 2 MiB. Choose a smaller file.')
return
}
chunks.push(chunk)
}
let data: FormData
try {
data = await new Request(origin, {
method: 'POST',
headers: { 'Content-Type': request.headers['content-type'] ?? '' },
body: Buffer.concat(chunks),
}).formData()
} catch {
reply(response, 400, 'Could not read the multipart form.')
return
}
const file = data.get('file')
if ([...data.keys()].length !== 1 || !(file instanceof File) || file.name === '') {
reply(response, 400, 'Choose exactly one file in the file field.')
return
}
if (file.size > maxFileBytes) {
reply(response, 413, 'File exceeds 1 MiB. Choose a smaller file.')
return
}
const receipt = {
name: file.name,
bytes: file.size,
sha256: createHash('sha256').update(new Uint8Array(await file.arrayBuffer())).digest('hex'),
}
reply(response, 200, `Received one file.\n${JSON.stringify(receipt, 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.')
else response.destroy()
})
})
server.requestTimeout = 30000
server.on('error', () => {
console.error('Could not start the receiver. Check that the port is available.')
process.exitCode = 1
})
server.listen(port, '127.0.0.1', () => {
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}/`)
})
Der Server puffert eine kleine Anfrage und parst sie dann mit Request.formData() von Node.
Mit der Option destroyOnReturn: false
des Streams kann er das Puffern eines zu großen Anfragekörpers stoppen und dabei eine
HTTP-413-Antwort zurückgeben. Der Rest wird verworfen. Die Hashes der Dateiinhalte werden im
Arbeitsspeicher berechnet; die Inhalte werden nie auf die Festplatte geschrieben.
Formular ohne JavaScript ausführen
Starten Sie den Empfänger aus dem Verzeichnis, das native-upload enthält:
node native-upload/server.mts
Öffnen Sie genau die URL, die im Terminal ausgegeben wird. Der Server wählt einen verfügbaren Port
auf 127.0.0.1. Wenn Sie index.html direkt öffnen, wird
/upload nicht mit ihm verbunden. Beenden Sie ihn nach Abschluss mit Strg+C.
Starten Sie ihn nach jeder Dateiänderung neu, da die Browserdateien beim Start geladen werden.
Wenn Sie einen festen Port benötigen, hängen Sie --port 8123 an denselben Befehl
an. Ist der Port belegt, schlägt der Befehl mit einem Status ungleich null fehl.
Laden Sie die Seite bei deaktiviertem JavaScript neu und navigieren Sie mit der Tabulatortaste zum
Eingabefeld mit der Beschriftung Choose a file.
Seine Umrandung sollte sichtbar sein. Drücken Sie die Leertaste, um die Dateiauswahl zu öffnen,
wählen Sie eine kleine Datei aus und navigieren Sie mit der Tabulatortaste zu
Upload. Drücken Sie dann die Eingabetaste.
Der Browser navigiert zu /upload. Dort erscheint
Received one file. zusammen mit Dateiname, Byteanzahl und
SHA-256-Hash. Diese Antwort bestätigt den Empfang; ein Dateiname in der Dateiauswahl bestätigt nur
die Auswahl. Kehren Sie mit „Zurück“ zum Formular zurück. Erneutes Absenden untersucht die Datei
nochmals, ohne sie zu speichern.
Drag-and-drop ergänzen, ohne das Absenden zu ändern
Speichern Sie diese optionale Ergänzung als native-upload/enhance.js, starten Sie den Empfänger
neu und laden Sie die Seite mit aktiviertem JavaScript erneut:
const form = document.getElementById('upload-form')
const input = document.getElementById('file-input')
const area = document.getElementById('drop-area')
const status = document.getElementById('selection-status')
const hint = document.getElementById('drop-hint')
const maxFileBytes = 1024 * 1024
function checkSelection(files) {
const file = files[0]
if (files.length !== 1 || !file) {
input.value = ''
status.textContent = 'Choose exactly one file.'
return false
}
if (file.size > maxFileBytes) {
input.value = ''
status.textContent = 'File exceeds 1 MiB. Choose a smaller file.'
return false
}
status.textContent = `Selected: ${file.name}. Choose Upload to send it.`
return true
}
input.addEventListener('change', () => checkSelection(input.files))
form.addEventListener('submit', (event) => {
if (!checkSelection(input.files)) event.preventDefault()
})
area.addEventListener('dragover', (event) => {
event.preventDefault()
area.classList.add('dragover')
})
area.addEventListener('dragleave', () => area.classList.remove('dragover'))
area.addEventListener('drop', (event) => {
event.preventDefault()
area.classList.remove('dragover')
if (!event.dataTransfer || !checkSelection(event.dataTransfer.files)) return
try {
input.files = event.dataTransfer.files
} catch {
input.value = ''
status.textContent = 'Could not select this drop. Use the file picker.'
}
})
hint.hidden = false
Lesen Sie dataTransfer.files
im Handler für drop aus. Die Zuweisung dieser
FileList an input.files ändert die Auswahl des nativen
Eingabefelds, wie im HTML-Standard festgelegt.
Wenn Sie value des Eingabefelds auf einen Dateisystempfad setzen, lässt sich
damit keine Datei auswählen.
Das Ablegen einer gewöhnlichen Datei wählt sie lediglich aus. Die Statusmeldung nennt die Datei und lautet Choose Upload to send it.. Wählen Sie Upload, um dasselbe Multipart-Formular abzusenden. Die Auswahl einer anderen Datei ersetzt die erste. Wenn Sie eine zu große Datei oder mehrere Dateien ablegen, wird die bisherige Auswahl geleert und Sie werden zu einer neuen Auswahl aufgefordert. Falls die Zuweisung der abgelegten Datei fehlschlägt, nutzen Sie die native Dateiauswahl. Ordner sind nicht Teil dieses Beispiels.
Hier gibt es keinen Übertragungsweg mit fetch() oder XHR: Gültige Formulare
werden mit und ohne JavaScript per Browsernavigation abgesendet. Wenn Sie Fortschrittsanzeigen,
Retries und eine Oberfläche wünschen, die auf der Seite bleibt, nutzen Sie unsere
Anleitung für einen eigenen JavaScript-Dateiuploader.
Ablehnung testen, bevor Sie das Formular anpassen
Versuchen Sie, das Formular ohne ausgewählte Datei abzusenden: Der Browser sollte nach einer Datei
fragen. Wenn JavaScript aktiviert ist, wird bei Auswahl einer Datei über 1 MiB die Auswahl geleert
und File exceeds 1 MiB. Choose a smaller file. angezeigt.
Deaktivieren Sie JavaScript und senden Sie diese Datei erneut ab: Der Empfänger sollte sie mit
HTTP 413 ablehnen. Kehren Sie mit „Zurück“ zum Formular zurück und wählen Sie eine kleinere Datei,
um fortzufahren. Eine ausgewählte Datei mit null Bytes wird mit bytes: 0
erfolgreich angenommen.
Der Empfänger lehnt auch fehlende, wiederholte oder anders benannte Dateifelder mit HTTP 400 ab, fehlerhafte Multipart-Anfragekörper mit HTTP 400 und Anfragen über seinem Pufferlimit von 2 MiB mit HTTP 413. HTTP 403 bedeutet, dass Host oder Ursprung der Anfrage nicht mit der ausgegebenen URL übereinstimmt. Betreiben Sie Formular und Empfänger unter dieser URL.
Dieser lokale Empfänger akzeptiert jeden Dateityp und verwirft jeden Upload. Er prüft nicht, ob
eine Datei ein Bild oder ein Dokument ist oder sicher verarbeitet werden kann. Ein
Attribut accept
kann die Dateiauswahl lenken, aber keine Inhalte validieren. Bevor Sie dieses Formular mit einem
öffentlichen Dienst verbinden, nutzen Sie dessen authentifizierten Upload-Endpunkt und dessen
serverseitige Größen- und Inhaltsprüfungen. Achten Sie darauf, dass der Feldname des Eingabefelds
mit dem vom Empfänger erwarteten Feld übereinstimmt.
