Skip to content

File uploads

Take a file part straight from a multipart/form-data request with poem’s Multipart extractor. #[routes] passes it through like any other handler argument, so a direct upload is the single-round-trip alternative to the presigned flow: the client posts the bytes, the server stores them.

crates/features/src/audio/http/controller.rs (abridged)
#[post("/uploads/direct")]
async fn upload_direct(&self, mut form: Multipart) -> Result<Json<PresignedUrlDto>> {
while let Some(field) = form.next_field().await.map_err(bad_request)? {
if field.name() != Some("file") {
continue;
}
let filename = field.file_name().map(str::to_owned).unwrap_or_default();
// Validate before the bytes become an object key.
UploadRequestDto { filename: filename.clone() }.validate().map_err(unprocessable)?;
let bytes = field.bytes().await.map_err(bad_request)?;
let ticket = self.svc.store_upload(&filename, bytes).await.map_err(internal)?;
return Ok(Json(ticket));
}
Err(Error::from_string("multipart body has no `file` part", StatusCode::BAD_REQUEST))
}

A multipart part’s filename is attacker-controlled — never let it become an object key unchecked. Reuse the same validated DTO the rest of the slice binds (UploadRequestDto), so one anti-traversal allowlist covers both the presigned and the direct path. A rejected name is a 422, never a stored object.

Presigned PUTDirect multipart
Bytes flowclient → storageclient → server → storage
Server memorynoneone part at a time
Round tripstwo (mint, then PUT)one
Use whenlarge media, offload the transfersmall files, one call
  • The max_body_bytes cap gates the RawBody and Json extractors — not Multipart, which poem reads independently. A direct upload is bounded only by the field buffering below, so keep the admin gate and rate limit the demo binds, or prefer the presigned path.
  • The uploaded part is buffered whole per field before it reaches storage (field.bytes()); for very large files the presigned PUT avoids proxying through the server entirely.