Uploads and downloads
A form sends a file when it says enctype="multipart/form-data":
<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
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.
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.pngand sent asimage/pngcan be anything. Look at how it starts, asisPngdoes, 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.
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:
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.
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:
/// 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" });
}
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.