Skip to main content

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;