Cookbook / Files and pages
Safe document uploads
Cap each file, check its real type from the first bytes, and hash it while streaming to disk or any writer.
A browser's Content-Type is only a claim. For documents you will store or process, check what the bytes say. Field::limit caps one file, Field::accept checks the type from its first bytes, and Field::save streams to disk while computing a SHA-256, returning an Upload with the size, hash and type. A rejected file writes nothing.
use axeon::{json, Json, Server, ServerError};
fn main() {
let mut app = Server::new();
let dir = std::env::temp_dir();
// The body limit covers the whole request; the field limit covers one file.
app.route("/documents")
.body_limit(60 << 20)
.stream_body()
.post(move |req| {
let dir = dir.clone();
async move {
let mut form = req.multipart()?;
let Some(field) = form.next_field().await? else {
return Err(ServerError::BadRequest("no file".into()));
};
let upload = field
.limit(50 << 20) // 413 above 50 MiB
.accept(&["application/pdf", "image/*"]) // 415 for anything else
.save(dir.join("document.bin")) // or .write_to(&mut writer)
.await?;
Ok(Json(json!({
"sha256": upload.sha256_hex(),
"size": upload.size(),
"type": upload.content_type(),
})))
}
});
app.listen("127.0.0.1:3000").expect("server failed to start");
}
curl -F 'file=@./report.pdf' http://127.0.0.1:3000/documents
echo 'not a pdf' > fake.pdf
curl -i -F 'file=@./fake.pdf;type=application/pdf' http://127.0.0.1:3000/documents # 415
Notes#
- The type comes from the file's leading bytes, not from the filename or the declared
Content-Type. Detection covers PDF, common images, MP4, ZIP, gzip and 7z, andimage/*matches any image. write_to(&mut writer)accepts anyAsyncWrite, so the same checks work when you stream straight to an object store instead of a local file.bytes()andtext()on a field respect its limit too.- Pair this with a stored hash to de-duplicate uploads or verify them later. For a plain upload without these checks, see File uploads.