ocre/storage/multipart.rs
1//! `multipart/form-data` bodies (RFC 7578): the whole body is read (up to a
2//! limit), then split in place, so file parts are slices of one buffer.
3
4use std::pin::Pin;
5
6use axum::{
7 body::{Bytes, HttpBody},
8 extract::{FromRequest, Request},
9 http::{HeaderMap, header},
10 response::{IntoResponse, Response},
11};
12use memchr::memmem;
13use serde::de::DeserializeOwned;
14
15use super::{Upload, essence, human_size, sanitize_filename};
16use crate::{ApiError, Error, Result};
17
18/// Extractor for a `multipart/form-data` body of at most `LIMIT` bytes.
19///
20/// It reads HTML forms with `enctype="multipart/form-data"` (file inputs)
21/// and API clients (`curl -F avatar=@me.png`). Take the text fields with
22/// [`MultipartForm::form`] or [`MultipartForm::text`] and the files with
23/// [`MultipartForm::file`]; check files with
24/// [`Validator::file`](crate::Validator::file) before storing them.
25///
26/// The whole body is read into memory, then split in place: file parts are
27/// slices of that one buffer. The rejection is a response, not an
28/// [`Error`] in the handler:
29///
30/// - 400 when the content type is not `multipart/form-data` with a
31/// `boundary`, the body cannot be read, or it is malformed;
32/// - 413 "The request is too large (maximum is 6 MB)" when `Content-Length`
33/// announces more than `LIMIT` (before anything is read) or as soon as the
34/// body passes it.
35///
36/// Errors are HTML pages for browsers (`Accept: text/html`, feature `html`)
37/// and JSON (like [`ApiError`](crate::ApiError)) otherwise.
38///
39/// Free plan: keep `LIMIT` in the tens of MB. A Worker has 128 MB, the body
40/// is held in memory while R2 gets a copy, and Cloudflare refuses request
41/// bodies over 100 MB on the Free plan before they reach the Worker.
42/// Splitting takes about 1.2 ms of CPU per 10 MB in WebAssembly (memchr's
43/// substring search, measured in V8). For a single large file, stream the
44/// raw body with [`store_body`](crate::storage::store_body) instead.
45///
46/// # Examples
47///
48/// ```no_run
49/// use axum::{extract::State, response::Redirect};
50/// use ocre::storage::{self, Multipart, Rules};
51/// use ocre::{Ctx, Result, Validator};
52/// use serde::Deserialize;
53///
54/// const IMAGE: Rules = Rules { max_bytes: 10 * 1024 * 1024, content_types: &["image/png", "image/jpeg"] };
55/// const FORM_LIMIT: usize = IMAGE.max_bytes as usize + 1024 * 1024;
56///
57/// #[derive(Deserialize)]
58/// struct NewPhoto {
59/// title: String,
60/// }
61///
62/// async fn create(State(ctx): State<Ctx>, Multipart(mut form): Multipart<FORM_LIMIT>) -> Result<Redirect> {
63/// let fields: NewPhoto = form.form()?;
64/// let image = form.file("image"); // None when no file was chosen
65/// let mut v = Validator::new();
66/// v.required("title", &fields.title);
67/// if let Some(image) = &image {
68/// v.file("image", image, &IMAGE);
69/// }
70/// v.finish()?;
71/// if let Some(image) = image {
72/// let stored = storage::store(&ctx, "photos/image", image).await?;
73/// # let _ = stored;
74/// }
75/// Ok(Redirect::to("/photos"))
76/// }
77/// ```
78#[derive(Debug)]
79pub struct Multipart<const LIMIT: usize>(pub MultipartForm);
80
81impl<const LIMIT: usize, S: Send + Sync> FromRequest<S> for Multipart<LIMIT> {
82 type Rejection = Response;
83
84 async fn from_request(req: Request, _state: &S) -> Result<Self, Self::Rejection> {
85 let html = wants_html(req.headers());
86 read(req, LIMIT).await.map(Self).map_err(|err| rejection(err, html))
87 }
88}
89
90/// Browsers ask for HTML; API clients (`curl`, `fetch`) do not.
91fn wants_html(headers: &HeaderMap) -> bool {
92 headers.get(header::ACCEPT).and_then(|value| value.to_str().ok()).is_some_and(|accept| accept.contains("text/html"))
93}
94
95fn rejection(err: Error, html: bool) -> Response {
96 #[cfg(feature = "html")]
97 if html {
98 return err.into_response();
99 }
100 let _ = html;
101 ApiError(err).into_response()
102}
103
104fn too_large(limit: usize) -> Error {
105 Error::PayloadTooLarge(format!("The request is too large (maximum is {})", human_size(limit as u64)))
106}
107
108/// Reads and parses the body of `req`, refusing more than `limit` bytes.
109async fn read(req: Request, limit: usize) -> Result<MultipartForm> {
110 let content_type = req.headers().get(header::CONTENT_TYPE).and_then(|value| value.to_str().ok()).unwrap_or("");
111 let boundary = boundary(content_type).ok_or_else(|| {
112 Error::bad_request(
113 "Expected a multipart/form-data body: give the <form> enctype=\"multipart/form-data\", or send it with `curl -F name=value`",
114 )
115 })?;
116 let declared = req
117 .headers()
118 .get(header::CONTENT_LENGTH)
119 .and_then(|value| value.to_str().ok())
120 .and_then(|value| value.parse::<usize>().ok());
121 if declared.is_some_and(|length| length > limit) {
122 return Err(too_large(limit));
123 }
124 let mut body = req.into_body();
125 let mut buffer = Vec::with_capacity(declared.unwrap_or(0));
126 while let Some(frame) = std::future::poll_fn(|cx| Pin::new(&mut body).poll_frame(cx)).await {
127 let frame = frame.map_err(|err| Error::bad_request(format!("Could not read the request body: {err}")))?;
128 // Trailer frames carry no data.
129 let data = frame.into_data().unwrap_or_default();
130 if buffer.len() + data.len() > limit {
131 return Err(too_large(limit));
132 }
133 buffer.extend_from_slice(&data);
134 }
135 MultipartForm::parse(Bytes::from(buffer), &boundary)
136}
137
138/// The `boundary` parameter of a `multipart/form-data` content type.
139pub(crate) fn boundary(content_type: &str) -> Option<String> {
140 let mut parts = split_params(content_type).into_iter();
141 if !parts.next()?.trim().eq_ignore_ascii_case("multipart/form-data") {
142 return None;
143 }
144 parts
145 .filter_map(|part| part.split_once('='))
146 .find(|(name, _)| name.trim().eq_ignore_ascii_case("boundary"))
147 .map(|(_, value)| unquote(value.trim()))
148 .filter(|boundary| !boundary.is_empty() && boundary.len() <= 70)
149}
150
151/// Splits on `;` outside double quotes.
152fn split_params(value: &str) -> Vec<&str> {
153 let (mut parts, mut start, mut quoted, mut escaped) = (Vec::new(), 0, false, false);
154 for (i, c) in value.char_indices() {
155 match c {
156 _ if escaped => escaped = false,
157 '\\' if quoted => escaped = true,
158 '"' => quoted = !quoted,
159 ';' if !quoted => {
160 parts.push(&value[start..i]);
161 start = i + 1;
162 }
163 _ => {}
164 }
165 }
166 parts.push(&value[start..]);
167 parts
168}
169
170/// `"a \"b\""` -> `a "b"`; unquoted values are returned as they are.
171fn unquote(value: &str) -> String {
172 let Some(inner) = value.strip_prefix('"').and_then(|v| v.strip_suffix('"')) else {
173 return value.to_owned();
174 };
175 let mut out = String::with_capacity(inner.len());
176 let mut chars = inner.chars();
177 while let Some(c) = chars.next() {
178 if c == '\\' {
179 out.extend(chars.next());
180 } else {
181 out.push(c);
182 }
183 }
184 out
185}
186
187/// The parsed parts of a multipart body: text fields and files, by field name.
188///
189/// Obtained from the [`Multipart`] extractor. Text fields are read with
190/// [`MultipartForm::form`] (all at once, into a struct) or
191/// [`MultipartForm::text`] (one by one); files are taken with
192/// [`MultipartForm::file`]. Parts without a `name` are skipped; a text
193/// field that is not UTF-8 is a 400.
194///
195/// # Examples
196///
197/// ```
198/// use ocre::storage::MultipartForm;
199///
200/// // An empty form, as `Default` builds it.
201/// let mut form = MultipartForm::default();
202/// assert_eq!(form.text("title"), None);
203/// assert!(form.file("image").is_none());
204/// ```
205#[derive(Debug, Default, Clone, PartialEq)]
206pub struct MultipartForm {
207 fields: Vec<(String, String)>,
208 files: Vec<(String, Upload)>,
209}
210
211impl MultipartForm {
212 /// Deserializes the text fields into `T`, exactly like axum's `Form`.
213 ///
214 /// Numbers and booleans are parsed from their text, `#[serde(default)]`
215 /// fills in missing fields, and file fields are ignored. Unchecked HTML
216 /// checkboxes send nothing, so give `bool` fields `#[serde(default)]`.
217 ///
218 /// # Errors
219 ///
220 /// [`Error::BadRequest`] (400) `Invalid form: ...` when a field is missing
221 /// or `T` cannot parse it.
222 ///
223 /// # Examples
224 ///
225 /// ```
226 /// # use axum::{body::Body, extract::FromRequest, http::Request};
227 /// # use ocre::storage::Multipart;
228 /// # use std::task::{Context, Poll, Waker};
229 /// # fn block_on<F: Future>(future: F) -> F::Output {
230 /// # match std::pin::pin!(future).poll(&mut Context::from_waker(Waker::noop())) {
231 /// # Poll::Ready(output) => output,
232 /// # Poll::Pending => unreachable!("an in-memory body is always ready"),
233 /// # }
234 /// # }
235 /// use serde::Deserialize;
236 ///
237 /// #[derive(Deserialize)]
238 /// struct Profile {
239 /// name: String,
240 /// age: u8,
241 /// #[serde(default)]
242 /// public: bool,
243 /// }
244 ///
245 /// let body = "--x\r\nContent-Disposition: form-data; name=\"name\"\r\n\r\nAda\r\n\
246 /// --x\r\nContent-Disposition: form-data; name=\"age\"\r\n\r\n36\r\n--x--\r\n";
247 /// let request = Request::post("/profile")
248 /// .header("content-type", "multipart/form-data; boundary=x")
249 /// .body(Body::from(body))
250 /// .unwrap();
251 /// let Multipart(form) = block_on(Multipart::<1024>::from_request(request, &())).unwrap();
252 ///
253 /// let profile: Profile = form.form()?;
254 /// assert_eq!((profile.name.as_str(), profile.age, profile.public), ("Ada", 36, false));
255 ///
256 /// #[derive(Debug, Deserialize)]
257 /// struct Strict {
258 /// #[allow(dead_code)]
259 /// email: String,
260 /// }
261 /// let err = form.form::<Strict>().unwrap_err();
262 /// assert!(matches!(err, ocre::Error::BadRequest(message) if message == "Invalid form: missing field `email`"));
263 /// # Ok::<(), ocre::Error>(())
264 /// ```
265 pub fn form<T: DeserializeOwned>(&self) -> Result<T> {
266 serde_urlencoded::from_str(&self.encoded()).map_err(|err| Error::bad_request(format!("Invalid form: {err}")))
267 }
268
269 fn encoded(&self) -> String {
270 serde_urlencoded::to_string(&self.fields).expect("string pairs always encode")
271 }
272
273 /// Returns the first text field named `name`, or `None` when it was not sent.
274 ///
275 /// File fields are not text fields: use [`MultipartForm::file`] for them.
276 ///
277 /// # Examples
278 ///
279 /// ```
280 /// # use axum::{body::Body, extract::FromRequest, http::Request};
281 /// # use ocre::storage::Multipart;
282 /// # use std::task::{Context, Poll, Waker};
283 /// # fn block_on<F: Future>(future: F) -> F::Output {
284 /// # match std::pin::pin!(future).poll(&mut Context::from_waker(Waker::noop())) {
285 /// # Poll::Ready(output) => output,
286 /// # Poll::Pending => unreachable!("an in-memory body is always ready"),
287 /// # }
288 /// # }
289 /// let body = "--x\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nHoliday\r\n--x--\r\n";
290 /// let request = Request::post("/photos")
291 /// .header("content-type", "multipart/form-data; boundary=x")
292 /// .body(Body::from(body))
293 /// .unwrap();
294 /// let Multipart(form) = block_on(Multipart::<1024>::from_request(request, &())).unwrap();
295 /// assert_eq!(form.text("title"), Some("Holiday"));
296 /// assert_eq!(form.text("missing"), None);
297 /// ```
298 pub fn text(&self, name: &str) -> Option<&str> {
299 self.fields.iter().find(|(field, _)| field == name).map(|(_, value)| value.as_str())
300 }
301
302 /// Takes the first file sent as `name`, leaving the others.
303 ///
304 /// `None` when the field is missing or no file was chosen (browsers then
305 /// send an empty, nameless file, which is dropped while parsing). Taking
306 /// moves the [`Upload`] out without copying its bytes; a second call for
307 /// the same name returns the next file with that name, if any.
308 ///
309 /// # Examples
310 ///
311 /// ```
312 /// # use axum::{body::Body, extract::FromRequest, http::Request};
313 /// # use ocre::storage::Multipart;
314 /// # use std::task::{Context, Poll, Waker};
315 /// # fn block_on<F: Future>(future: F) -> F::Output {
316 /// # match std::pin::pin!(future).poll(&mut Context::from_waker(Waker::noop())) {
317 /// # Poll::Ready(output) => output,
318 /// # Poll::Pending => unreachable!("an in-memory body is always ready"),
319 /// # }
320 /// # }
321 /// let body = "--x\r\n\
322 /// Content-Disposition: form-data; name=\"image\"; filename=\"C:\\\\photos\\\\beach.png\"\r\n\
323 /// Content-Type: Image/PNG\r\n\r\n\
324 /// PNG...\r\n\
325 /// --x\r\n\
326 /// Content-Disposition: form-data; name=\"notes\"; filename=\"\"\r\n\
327 /// Content-Type: application/octet-stream\r\n\r\n\
328 /// \r\n\
329 /// --x--\r\n";
330 /// let request = Request::post("/photos")
331 /// .header("content-type", "multipart/form-data; boundary=x")
332 /// .body(Body::from(body))
333 /// .unwrap();
334 /// let Multipart(mut form) = block_on(Multipart::<1024>::from_request(request, &())).unwrap();
335 ///
336 /// let image = form.file("image").unwrap();
337 /// assert_eq!(image.filename, "beach.png");
338 /// assert_eq!(image.content_type, "image/png");
339 /// assert_eq!(image.size(), 6);
340 /// assert!(form.file("image").is_none()); // taken
341 /// assert!(form.file("notes").is_none()); // no file chosen
342 /// ```
343 pub fn file(&mut self, name: &str) -> Option<Upload> {
344 let index = self.files.iter().position(|(field, _)| field == name)?;
345 Some(self.files.remove(index).1)
346 }
347
348 /// Takes every file sent as `name` or `name[]` (an `<input type="file" multiple>`), in order.
349 ///
350 /// # Examples
351 ///
352 /// ```
353 /// # use axum::{body::Body, extract::FromRequest, http::Request};
354 /// # use ocre::storage::Multipart;
355 /// # fn block_on<F: Future>(f: F) -> F::Output {
356 /// # let waker = std::task::Waker::noop();
357 /// # let mut cx = std::task::Context::from_waker(waker);
358 /// # let mut f = std::pin::pin!(f);
359 /// # loop { if let std::task::Poll::Ready(v) = f.as_mut().poll(&mut cx) { return v; } }
360 /// # }
361 /// let body = "--x\r\nContent-Disposition: form-data; name=\"photos\"; filename=\"a.png\"\r\n\
362 /// Content-Type: image/png\r\n\r\nA\r\n\
363 /// --x\r\nContent-Disposition: form-data; name=\"photos\"; filename=\"b.png\"\r\n\
364 /// Content-Type: image/png\r\n\r\nB\r\n--x--\r\n";
365 /// let request = Request::post("/albums/1/photos")
366 /// .header("content-type", "multipart/form-data; boundary=x")
367 /// .body(Body::from(body))
368 /// .unwrap();
369 /// let Multipart(mut form) = block_on(Multipart::<1024>::from_request(request, &())).unwrap();
370 /// let names: Vec<String> = form.files("photos").into_iter().map(|file| file.filename).collect();
371 /// assert_eq!(names, ["a.png", "b.png"]);
372 /// assert!(form.files("photos").is_empty()); // taken
373 /// ```
374 pub fn files(&mut self, name: &str) -> Vec<Upload> {
375 let listed = format!("{name}[]");
376 let (taken, kept) =
377 std::mem::take(&mut self.files).into_iter().partition(|(field, _)| field == name || *field == listed);
378 self.files = kept;
379 taken.into_iter().map(|(_, upload)| upload).collect()
380 }
381
382 /// Splits `body` at `boundary`. Parts without a `name` are skipped.
383 pub(crate) fn parse(body: Bytes, boundary: &str) -> Result<Self> {
384 let malformed = |detail: &str| Error::bad_request(format!("Malformed multipart/form-data body: {detail}"));
385 let delimiter = format!("\r\n--{boundary}");
386 let finder = memmem::Finder::new(delimiter.as_bytes());
387 // The first delimiter has no line break before it when there is no preamble.
388 let mut pos = if body.starts_with(&delimiter.as_bytes()[2..]) {
389 delimiter.len() - 2
390 } else {
391 finder.find(&body).ok_or_else(|| malformed("the boundary never appears"))? + delimiter.len()
392 };
393 let mut form = Self::default();
394 loop {
395 let rest = &body[pos..];
396 if rest.starts_with(b"--") {
397 return Ok(form);
398 }
399 // Transport padding (spaces, tabs) may follow a delimiter.
400 let padding = rest.iter().take_while(|&&b| b == b' ' || b == b'\t').count();
401 if !rest[padding..].starts_with(b"\r\n") {
402 return Err(malformed("expected a line break after the boundary"));
403 }
404 let start = pos + padding + 2;
405 let (head, content_start) = if body[start..].starts_with(b"\r\n") {
406 (&body[start..start], start + 2)
407 } else {
408 let end = memmem::find(&body[start..], b"\r\n\r\n")
409 .ok_or_else(|| malformed("a part has no end of headers"))?;
410 (&body[start..start + end], start + end + 4)
411 };
412 let content_end = content_start
413 + finder.find(&body[content_start..]).ok_or_else(|| malformed("the closing boundary is missing"))?;
414 form.push(head, body.slice(content_start..content_end))?;
415 pos = content_end + delimiter.len();
416 }
417 }
418
419 fn push(&mut self, head: &[u8], content: Bytes) -> Result<()> {
420 let head = String::from_utf8_lossy(head);
421 let mut disposition = None;
422 let mut content_type = None;
423 for line in head.split("\r\n") {
424 let Some((name, value)) = line.split_once(':') else { continue };
425 if name.trim().eq_ignore_ascii_case("content-disposition") {
426 disposition = Some(value.trim().to_owned());
427 } else if name.trim().eq_ignore_ascii_case("content-type") {
428 content_type = Some(value.trim().to_owned());
429 }
430 }
431 let params = disposition.as_deref().map(split_params).unwrap_or_default();
432 let param = |wanted: &str| {
433 params.iter().skip(1).find_map(|part| {
434 let (name, value) = part.split_once('=')?;
435 name.trim().eq_ignore_ascii_case(wanted).then(|| unquote(value.trim()))
436 })
437 };
438 let Some(name) = param("name") else { return Ok(()) };
439 match param("filename") {
440 Some(filename) if filename.is_empty() && content.is_empty() => {}
441 Some(filename) => {
442 let content_type = essence(content_type.as_deref().unwrap_or(""));
443 self.files
444 .push((name, Upload { filename: sanitize_filename(&filename), content_type, bytes: content }));
445 }
446 None => {
447 let value = String::from_utf8(content.to_vec())
448 .map_err(|_| Error::bad_request(format!("Form field `{name}` is not valid UTF-8")))?;
449 self.fields.push((name, value));
450 }
451 }
452 Ok(())
453 }
454}
455
456#[cfg(test)]
457#[path = "../../tests/storage/multipart.rs"]
458mod tests;