Lesson 24 / 25
File Upload
The value a file field produces, the multipart submission encoding's counterpart in the body, the accept type declaration, and where the size limit is enforced.
Contents
Validation was built on text and number fields. A file field does not fit these patterns. Its value is not a string; it carries binary content, and has its own name and its own content type.
The default encoding defined in the Form Structure lesson — joining name=value pairs with
& — cannot carry this content. On a form with a file field, the submission encoding
changes.
Three Attributes
<form action="/measurement/upload" method="post" enctype="multipart/form-data"> <p><label for="record">Raw record file</label> <input id="record" name="record" type="file" accept=".csv,text/csv" required></p> <p><button type="submit">Upload</button></p> </form>
enctype="multipart/form-data" changes the submission encoding. If it is not written, the
file field sends only the file name; the content does not go. This is the most common
reason a form silently misbehaves: the form submits, the server gets a value, but what it gets
is a string.
The accept attribute announces which types should be surfaced in the file picker. Its value
can be a list of extensions, a list of content types, or a mix of both.
The multiple attribute allows more than one file to be selected; each file joins the entry
list as a separate entry under the same name.
The Shape of the Body
The script below opens a server, sends the same two fields with two different encodings, and
prints the raw body the server receives. To make line endings visible, the \r\n sequence is
printed explicitly.
// file-upload.mjs — the two submission encodings' counterpart in the body import { createServer } from "node:http"; // The boundary string is generated randomly on every submission; to keep the // output reproducible it is replaced with a fixed placeholder before printing. const server = createServer((request, response) => { const chunks = []; request.on("data", (chunk) => chunks.push(chunk)); request.on("end", () => { const body = Buffer.concat(chunks); const contentType = request.headers["content-type"]; const boundary = contentType.match(/boundary=(.+)$/)?.[1]; const mask = (d) => (boundary ? d.split(boundary).join("BOUNDARY") : d); console.log("content-type :", mask(contentType)); console.log("body length :", body.length, "bytes"); console.log("--- raw body ---"); console.log(mask(body.toString()).replace(/\r\n/g, "\\r\\n\n")); console.log("==="); response.writeHead(204).end(); }); }); server.listen(0, "127.0.0.1", async () => { const url = "http://127.0.0.1:" + server.address().port + "/upload"; // enctype="multipart/form-data" const formData = new FormData(); formData.append("station", "north-slope"); formData.append("record", new Blob(["time,temperature\n07:00,-4.2\n"], { type: "text/csv" }), "measurement.csv"); await fetch(url, { method: "POST", body: formData }); // enctype="application/x-www-form-urlencoded" — the same two fields, without the file content await fetch(url, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams([["station", "north-slope"], ["record", "measurement.csv"]]).toString(), }); server.close(); });
content-type : multipart/form-data; boundary=BOUNDARY body length : 304 bytes --- raw body --- --BOUNDARY\r\n Content-Disposition: form-data; name="station"\r\n \r\n north-slope\r\n --BOUNDARY\r\n Content-Disposition: form-data; name="record"; filename="measurement.csv"\r\n Content-Type: text/csv\r\n \r\n time,temperature 07:00,-4.2 \r\n --BOUNDARY--\r\n === content-type : application/x-www-form-urlencoded body length : 42 bytes --- raw body --- station=north-slope&record=measurement.csv ===
BOUNDARY in the output is a placeholder: the real boundary string is generated randomly on
every submission, and the script replaces it with a fixed piece of text before printing it. The
boundary’s text is chosen by the sender; its only condition is that it must not occur anywhere
in the body. The body length here does not vary, since the generated boundary’s length is
fixed.
The body’s shape is this. A boundary string is announced in the Content-Type header. The
body is split into parts by this boundary; each part carries its own headers, an empty line,
and its content. The Content-Disposition header gives the field’s name; file fields also
carry a filename parameter. A file part additionally has a Content-Type header announcing
the file’s type. The body ends with the boundary followed by two hyphens.
Two basic differences are visible.
Content is not escaped. In multipart encoding, file content is carried as-is; no percent-encoding is applied. The boundary string does the job of separation. A binary file can be sent without being encoded.
The overhead ratio is high. For the same two fields, 304 bytes were used against 42 bytes. Each part carries its own headers, and the boundary string is repeated in every part. This cost shrinks proportionally as the file grows larger; but sending a form with no file field using multipart encoding is unnecessary overhead.
The second part of the output shows the result of the wrong encoding: the record field
carries only the file name. The server receives a value, and there is no content.
An Accept Type Is Not a Constraint
The accept attribute filters the file picker window. The filter can be removed by the user,
and submission can be made without using the document at all; the conclusion from the Built-in
Validation lesson applies here too.
Whether a file is actually of the type it declares cannot be learned from the body’s
Content-Type header — that header too is written by the sender. The server can verify the
type only by looking at the file’s content — the identifying byte sequences at the start of
file formats exist for exactly this purpose.
Trusting the extension is not enough either. The uploaded file name is not used directly on the server: a name from the user can contain a path separator, overwrite an existing file, or carry an extension the server executes. The safe behavior is for the server to generate its own name and store the original only as metadata.
The Field’s Value Cannot Be Written From the Document
A file field differs from other fields in one more way: its value cannot be given from the
document. Even if a value attribute is written, it is ignored, because otherwise a document
could silently submit any file on the user’s disk. The field’s value is formed only by the
user’s own choice.
The same reasoning also hides the file’s path. What is opened up to the document is the file’s name, size, and type — not the directory it sits in. A user’s directory structure carries information about that user, and it is not given to the document.
One consequence is that a form cannot redisplay a file left over from a previous submission. In a form returned due to a validation error, other fields’ values can be rewritten into the document; the file field stays empty and the user must select the file again. In multi-step forms, this requires the file to be uploaded at an early step, held temporarily on the server, and referred to by an id in later steps.
The Size Limit Cannot Be Declared in the Document
There is no attribute that limits file size. This can be called not a gap but a consistent design: a limit declared in the document could not have been enforced anyway.
The limit is enforced in two places. The server can reject a request by looking at the
Content-Length header before reading the whole body, and can also enforce an upper bound
while reading; this prevents a single request from filling memory. The server software’s own
configuration usually also carries a request body upper bound.
If early feedback to the user is wanted, the file size can be read in the behavior layer before submission; this is a convenience, not a constraint. The expected limit is written as text in the document — the user should not learn that the file they chose will be rejected only after the upload finishes.
In the Station Document
<fieldset> <legend>Raw record</legend> <p><label for="record">Measurement file</label> <input id="record" name="record" type="file" accept=".csv,text/csv" aria-describedby="record-help"> <span id="record-help">Comma-separated value file, 5 MB maximum.</span></p> </fieldset>
The form’s enctype attribute must be changed the moment this field is added; if forgotten,
the form appears to work and the file content never arrives.
Summary
- A form with a file field writes
enctype="multipart/form-data"; if not written, only the file name is sent and the content is lost. - A multipart body is split into parts by a boundary string; each part carries its own headers and the content is transferred without being escaped.
- This encoding’s overhead ratio is high; it is not used on forms with no file field.
acceptonly filters the file picker; type checking is done on the server by looking at the file’s content, and the uploaded name is not used directly.- The size limit cannot be declared in the document; it is enforced on the server and announced to the user as text.
Next Step
At this point the form collects data, validates it, and can accept a file. One question remains: what does the user see when submission is rejected? The browser’s own message shows only one field, and an error returned from the server arrives as a new document. The final lesson looks at error reporting and focus management, and closes the course by building the accessible version of this form.
To keep your progress and take notes, Log in
My notes
Log in to take notes.