Zig 0.17.0

Uploads and downloads

A form sends a file when it says enctype="multipart/form-data":

html
<form action="/photos" method="post" enctype="multipart/form-data">
    <input type="text" name="caption">
    <input type="file" name="photo" accept="image/png">
    <button type="submit">Send</button>
</form>

The code on this page is from examples/guide (source): a small server kept as tests, for what the example apps do not use.

Taking a file

zig
const max_photo_bytes = 200 * 1024;

/// Takes one image and a caption from a form with `enctype="multipart/form-data"`.
fn addPhoto(c: *spider.Ctx) !spider.Response {
    const form = try c.parseMultipart();
    const caption = form.getValue("caption") orelse "";
    const files = form.getFile("photo") orelse return c.text("Choose a photo.", .{ .status = .unprocessable_entity });
    const photo = files[0];

    // What the browser says about the file is not checked by anyone: the
    // app decides by the bytes, and never uses the name as a path.
    if (photo.size == 0) return c.text("The file is empty.", .{ .status = .unprocessable_entity });
    if (photo.size > max_photo_bytes) return c.text("The photo can have up to 200 KB.", .{ .status = .unprocessable_entity });
    if (!isPng(photo.data)) return c.text("Only PNG images.", .{ .status = .unprocessable_entity });

    // `photo.data` lives as long as the request: an app writes it to a
    // file or to storage here, under a name of its own making.
    return c.json(.{ .caption = caption, .bytes = photo.size, .sent_as = photo.filename }, .{ .status = .created });
}

fn isPng(bytes: []const u8) bool {
    return std.mem.startsWith(u8, bytes, "\x89PNG\r\n\x1a\n");
}

c.parseMultipart() reads the form. getValue(name) is a text field, getFile(name) the files of a field: a list, because an input with multiple sends several. Each file has:

  • data: its bytes. They live as long as the request, in memory.
  • size: how many.
  • filename: the name on the visitor's computer, as the browser sent it.
  • content_type: what the browser says the file is.
zig
test "upload: a photo and its caption arrive together" {
    const app = try spider.testing.start(run);
    var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator);
    defer arena_state.deinit();

    var res = try app.request(.{ .method = "POST", .target = "/photos", .headers = multipart, .body = try form(arena_state.allocator(), "team.png", "image/png", png) });
    defer res.deinit();
    try res.expectStatus(201);
    try res.expectContains("\"caption\":\"The team\"");
    try res.expectContains("\"sent_as\":\"team.png\"");
}

What not to believe

The name and the content type come from the visitor, and nobody checked them. Two rules follow:

  • Decide by the bytes. A file called team.png and sent as image/png can be anything. Look at how it starts, as isPng does, or hand it to something that really reads the format.
  • Never use the name as a path. It can be ../../etc/passwd. Store the file under a name the app makes (an id, a random string) and keep the visitor's name, if it is wanted, as text in the database.
zig
test "upload: the app decides by the bytes, whatever the browser called the file" {
    const app = try spider.testing.start(run);
    var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator);
    defer arena_state.deinit();

    // Named and typed as a PNG, and it is a script.
    var res = try app.request(.{ .method = "POST", .target = "/photos", .headers = multipart, .body = try form(arena_state.allocator(), "team.png", "image/png", "<script>alert(1)</script>") });
    defer res.deinit();
    try res.expectStatus(422);
    try res.expectContains("Only PNG images.");
}

A body that is not a multipart form makes parseMultipart fail, and the visitor gets a 400 without the handler doing anything:

zig
test "upload: a form without the file, and a body that is not a form" {
    const app = try spider.testing.start(run);

    var no_file = try app.request(.{ .method = "POST", .target = "/photos", .headers = multipart, .body = "--edge\r\nContent-Disposition: form-data; name=\"caption\"\r\n\r\nThe team\r\n--edge--\r\n" });
    defer no_file.deinit();
    try no_file.expectStatus(422);
    try no_file.expectContains("Choose a photo.");

    var not_a_form = try app.request(.{ .method = "POST", .target = "/photos", .headers = &.{"Content-Type: application/json"}, .body = "{}" });
    defer not_a_form.deinit();
    try not_a_form.expectStatus(400);
}

How large

The whole request is read into memory before the handler runs, and max_body_bytes in spider.config.zig is the most the server reads: 10 MiB unless the config says otherwise. A larger request gets a 413 and never reaches the handler.

zig
const spider = @import("spider");

pub const config = spider.Config{
    .views_dir = "./src",
    // The proxy in front of the app: its X-Forwarded-For is believed, and
    // nobody else's. The tests talk to the server from this address.
    .trusted_proxies = &.{"127.0.0.1"},
    // A payment provider posts to /hooks/... from its own site, in the
    // visitor's browser: the one place a cross-site POST is expected.
    .origin_check = .{ .exempt_paths = &.{"/hooks/"} },
    // Nothing larger than this is read: an upload over it gets a 413.
    .max_body_bytes = 1024 * 1024,
};

That limit is for every route. The limit of one form is the handler's, as max_photo_bytes above. For large files (video, backups), do not pass them through the app: let the browser send them straight to storage with a signed address.

Sending a file

c.download answers with bytes the browser saves under the name given:

zig
/// A file the browser saves instead of showing.
fn photoReport(c: *spider.Ctx) !spider.Response {
    const csv = "caption,bytes\nThe team,20480\n";
    return c.download(csv, .{ .filename = "photos.csv", .content_type = "text/csv" });
}
zig
test "download: the browser saves the file under the name the app gives" {
    const app = try spider.testing.start(run);
    var res = try app.get("/photos/report");
    defer res.deinit();
    try res.expectStatus(200);
    try res.expectHeader("Content-Type", "text/csv");
    try res.expectHeader("Content-Disposition", "attachment; filename=\"photos.csv\"");
    try res.expectHeader("X-Content-Type-Options", "nosniff");
    try std.testing.expectEqualStrings("caption,bytes\nThe team,20480\n", res.body);
}

The name may come from data: it is cleaned before it goes into the header. .disposition = .@"inline" lets the browser show the file (a PDF, an image) instead of saving it. The answer always says X-Content-Type-Options: nosniff, so a browser does not take the file for something else.

Files that are part of the app (stylesheets, scripts, images) are not sent this way: see Static files.