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.

src/main.rs
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");
}
Terminal
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, and image/* matches any image.
  • write_to(&mut writer) accepts any AsyncWrite, so the same checks work when you stream straight to an object store instead of a local file.
  • bytes() and text() 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.