Formulaire HTML d’envoi de fichiers : tutoriel pour développeurs
Un téléversement de fichiers en HTML nécessite un champ de fichier nommé dans un formulaire avec method="post" et
enctype="multipart/form-data", ainsi qu’un serveur qui traite l’attribut action du formulaire. Ce tutoriel
relie ces éléments : vous enverrez un ou plusieurs fichiers sans JavaScript côté navigateur et verrez le
récepteur indiquer leurs noms, leurs tailles en octets et leurs hachages SHA-256.
Mettre en place un formulaire de téléversement simple en HTML
Vous avez besoin de Node.js 24 ou version ultérieure, d’un navigateur et d’un shell POSIX tel que Bash sous Linux, macOS ou WSL. Le récepteur utilise les API intégrées de Node : il n’y a donc aucun paquet à installer. Node peut exécuter ce TypeScript directement grâce au retrait des types. Les exemples ont été testés sous Linux avec Node.js 24.15.0, 26.5.0 et 26.8.1, ainsi qu’avec Chromium 145 et 152.
Depuis un répertoire où vous conservez vos expériences, exécutez :
mkdir html-upload-demo &&
cd html-upload-demo &&
touch index.html server.ts
Cette commande refuse un répertoire existant. En cas d’échec, arrêtez-vous avant de suivre les étapes restantes ; choisissez un nouveau nom de répertoire ou corrigez l’erreur. Collez les deux exemples suivants dans les fichiers qui viennent d’être créés. Le serveur inspecte les téléversements uniquement en mémoire. Il ne crée ni n’écrase aucun fichier téléversé, et soumettre à nouveau le même fichier produit simplement un autre reçu.
Enregistrez cette page complète sous 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>
Chaque attribut du formulaire a un rôle distinct :
| Attribut | Rôle ici |
|---|---|
action="/upload" | Envoie la soumission vers la route /upload du serveur qui sert cette page. |
method="post" | Envoie les données du formulaire dans le corps de la requête HTTP. |
enctype="multipart/form-data" | Encode le contenu des fichiers sous forme de parties distinctes dans ce corps. |
name="files" | Nomme chaque partie téléversée pour que le récepteur puisse la récupérer. |
id="file-upload" | Relie le champ à son libellé ; ne nomme pas le champ soumis. |
multiple | Permet de sélectionner plus d’un fichier dans le sélecteur. |
required | Oblige le navigateur à demander une sélection avant la soumission. |
Le navigateur construit pour vous la délimitation multipart et les en-têtes de la requête. Le
guide d’envoi de données de formulaire
de MDN explique cet encodage. Un champ sans name est omis des données soumises, même s’il possède
un id et affiche un nom de fichier sélectionné ; consultez les
règles d’entrée de formulaire du standard HTML.
Ajouter un récepteur multipart local
Enregistrez ceci sous server.ts. Ce fichier sert la page et accepte jusqu’à trois fichiers de 1 MiB chacun. Une
limite distincte de 4 MiB plafonne le corps de requête mis en mémoire tampon, en-têtes multipart compris, avant
l’analyse. Ce sont de petites limites de démonstration ; la mise en mémoire tampon et l’analyse nécessitent aussi
de la mémoire au-delà de la taille brute du corps.
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}/`)
}
})
L’API Request intégrée
analyse le corps collecté avec
formData().
La construction de cet objet n’envoie pas d’autre requête réseau. Le récepteur utilise
getAll('files')
pour collecter chaque partie portant ce nom. get('files') ne renverrait que la première.
L’option destroyOnReturn: false
du flux permet au récepteur de quitter la boucle de lecture sans détruire la connexion lorsque le
corps est trop volumineux. Il ignore alors le reste de l’entrée et renvoie une réponse HTTP 413.
Gérer le téléversement d’un ou de plusieurs fichiers
Dans le même répertoire html-upload-demo, démarrez le récepteur :
node server.ts
Ouvrez l’URL exacte affichée dans le terminal. Le serveur choisit un port libre et écoute uniquement sur
127.0.0.1. Ouvrir index.html directement comme fichier local ne reliera pas son action /upload à
ce récepteur. Arrêtez le serveur avec Ctrl+C lorsque vous avez terminé ; redémarrez-le après avoir modifié l’un
ou l’autre fichier.
Désactivez JavaScript dans le navigateur, rechargez la page et choisissez un petit fichier. Cliquez sur Upload files.
Le navigateur accède à /upload et affiche Received 1 file(s)., suivi d’un reçu comportant
field, name, bytes et sha256. Cette réponse confirme que le serveur a accepté et inspecté
les octets. Un nom de fichier affiché dans le sélecteur confirme seulement la sélection.
Revenez à la page précédente, choisissez deux fichiers à la fois et soumettez à nouveau. Vous devriez voir
Received 2 file(s). et deux reçus. Essayez des noms de fichiers contenant des espaces ou des caractères non
ASCII. Pour comparer un reçu avec votre fichier local, exécutez ceci depuis le répertoire de démonstration, en
remplaçant le chemin après -- par celui de votre fichier :
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'
La taille en octets et le hachage doivent tous deux correspondre. Cette commande lit seulement le fichier. Un fichier vide sélectionné délibérément est valide et indique zéro octet ; un sélecteur vide est un cas différent.
multiple modifie le nombre de fichiers que l’utilisateur peut sélectionner, pas le nom du champ. Chaque fichier sélectionné est
envoyé sous files. HTML n’exige pas la convention à crochets files[] ; ce récepteur attend la
clé littérale files. Si un autre back-end attend files[], les deux côtés doivent utiliser exactement ce nom.
Pour un sélecteur qui n’autorise qu’un seul fichier, omettez multiple. Cela modifie uniquement le contrôle du navigateur ;
appliquez aussi une règle d’un seul fichier côté serveur si votre application l’exige.
Ajouter une validation côté client
Sans sélection, Upload files déclenche le retour du navigateur pour les champs obligatoires et vous maintient sur le formulaire. Le récepteur rejette aussi un téléversement vide avec une erreur HTTP 400, car les clients peuvent contourner la validation du navigateur. HTML ne dispose d’aucun attribut de taille de fichier qui impose la limite de 1 MiB : les sélections trop volumineuses atteignent donc le récepteur et reçoivent une page d’erreur.
Cette démo accepte tous les types de fichiers et inspecte uniquement leurs octets. Pour orienter un sélecteur
d’images ou de documents, vous pouvez ajouter accept=".jpg,.jpeg,.png,.pdf" au champ. Comme l’explique la
documentation du champ de fichier
de MDN, accept est une indication pour le sélecteur, pas une validation du contenu. Il n’ajoute aucune vérification de type à ce
récepteur. Un nom de fichier, une extension ou un type MIME fourni ne peut pas établir qu’un fichier est sûr.
Essayez ces cas d’échec avant d’adapter l’exemple :
| Soumission | Résultat attendu |
|---|---|
| Aucun fichier sélectionné | Le navigateur demande un fichier ; une requête vide directe reçoit HTTP 400. |
| Quatre petits fichiers | HTTP 400 : « Choose at most three files. » |
| Un fichier de plus de 1 MiB | HTTP 413 avec un message sur la limite de taille. |
| Requête totale de plus de 4 MiB | HTTP 413 avant l’analyse multipart. |
Un champ de fichier nommé file ou files[] | HTTP 400, car le récepteur ne trouve pas files. |
Après une erreur, revenez à la page précédente et choisissez une sélection valide. Une réponse réussie s’applique à l’ensemble de la soumission. Rien n’est enregistré, que la requête soit acceptée ou rejetée.
Personnaliser le bouton de téléversement de fichiers
Vous pouvez styliser le bouton natif tout en conservant son libellé, l’affichage du fichier sélectionné et son
comportement au clavier. Ajoutez ceci dans index.html, au sein de son <head>, puis redémarrez le serveur :
<style>
input[type='file']::file-selector-button {
font: inherit;
padding: 0.5rem 0.75rem;
margin-inline-end: 0.75rem;
cursor: pointer;
}
</style>
Le pseudo-élément ::file-selector-button
cible le bouton à l’intérieur du champ. Avec Tab, atteignez le sélecteur libellé et appuyez sur Espace pour
l’ouvrir, puis atteignez Upload files avec Tab et appuyez sur Entrée pour soumettre. Gardez le champ visible et
son indicateur de focus intact. Son name="files" et sa position dans le formulaire relient toujours la sélection au récepteur.
Considérations de sécurité
Gardez ce récepteur en local. Il accepte des contenus arbitraires, consomme de la mémoire à chaque requête et ne fournit ni connexion, ni stockage durable, ni analyse antimalware. Les noms de fichiers sont affichés en JSON dans une réponse en texte brut et ne sont jamais utilisés comme chemins du système de fichiers. Le hachage confirme quels octets sont arrivés ; il ne valide ni leur format ni leur innocuité.
Lorsque vous ajoutez du stockage à une application authentifiée, définissez l’autorisation, les limites de requêtes, la validation du contenu et la protection CSRF à la frontière du serveur. Le guide distinct sur le téléversement AJAX sécurisé couvre cette tâche axée sur la sécurité.
Ajouter une progression ou le glisser-déposer si nécessaire
Pour une interface qui reste sur la page et affiche la progression du téléversement, poursuivez avec le téléverseur JavaScript personnalisé. Il couvre le glisser-déposer, les nouvelles tentatives et la progression avec un récepteur compatible. Si votre application utilise déjà Bootstrap, consultez le tutoriel de téléversement de fichiers avec Bootstrap pour cette approche de mise en forme.
