Skip to main content

sova_core/request/
input.rs

1//! Unified form / multipart input (`Request::input`, [`FormData`], [`Upload`]).
2
3use crate::error::{Error, Result};
4use crate::request::Request;
5use bytes::Bytes;
6use serde::de::DeserializeOwned;
7use std::collections::HashMap;
8use std::path::{Component, Path, PathBuf};
9
10/// One uploaded file (or any multipart part with a filename).
11///
12/// Prefer [`Upload::validate`] + Storage `store`/`store_as` for app uploads;
13/// [`Self::save`] / [`Self::save_in`] write raw paths (no Storage / public URL).
14#[derive(Debug, Clone)]
15pub struct Upload {
16    pub field: String,
17    pub filename: Option<String>,
18    pub content_type: Option<String>,
19    pub data: Bytes,
20}
21
22impl Upload {
23    /// Byte length of the upload body.
24    pub fn size(&self) -> usize {
25        self.data.len()
26    }
27
28    /// Lowercase extension from the client filename (no leading dot).
29    pub fn extension(&self) -> Option<String> {
30        self.filename
31            .as_deref()
32            .and_then(|n| Path::new(n).extension())
33            .and_then(|e| e.to_str())
34            .map(|e| e.to_ascii_lowercase())
35    }
36
37    /// Client `Content-Type` (trimmed), if present.
38    pub fn mime(&self) -> Option<&str> {
39        self.content_type
40            .as_deref()
41            .map(str::trim)
42            .filter(|s| !s.is_empty())
43    }
44
45    /// Primary MIME type without parameters (`image/png; charset=utf-8` → `image/png`).
46    pub fn mime_type(&self) -> Option<String> {
47        self.mime()
48            .map(|m| m.split(';').next().unwrap_or(m).trim().to_ascii_lowercase())
49            .filter(|s| !s.is_empty())
50    }
51
52    /// Apply [`UploadRules`] (empty / max size / extensions / mimes).
53    pub fn validate(&self, rules: &UploadRules) -> Result<()> {
54        rules.check(self)
55    }
56
57    /// Write bytes to `path` (creates parent dirs).
58    pub async fn save(&self, path: impl AsRef<Path>) -> Result<()> {
59        let path = path.as_ref();
60        if let Some(parent) = path.parent() {
61            if !parent.as_os_str().is_empty() {
62                tokio::fs::create_dir_all(parent)
63                    .await
64                    .map_err(|e| Error::Internal(e.to_string()))?;
65            }
66        }
67        tokio::fs::write(path, &self.data)
68            .await
69            .map_err(|e| Error::Internal(e.to_string()))
70    }
71
72    /// Save under `dir` / `filename` (rejects `..` and absolute names).
73    pub async fn save_in(&self, dir: impl AsRef<Path>, filename: &str) -> Result<PathBuf> {
74        let name = Path::new(filename);
75        if !is_safe_relative(name) {
76            return Err(Error::BadRequest("unsafe upload filename".into()));
77        }
78        let dest = dir.as_ref().join(name);
79        self.save(&dest).await?;
80        Ok(dest)
81    }
82
83    /// Preferred download name: client filename or field name.
84    pub fn suggested_name(&self) -> &str {
85        self.filename
86            .as_deref()
87            .filter(|s| !s.is_empty())
88            .unwrap_or(self.field.as_str())
89    }
90}
91
92/// Per-file constraints for [`Upload::validate`].
93#[derive(Debug, Clone, Default)]
94pub struct UploadRules {
95    max_bytes: Option<usize>,
96    extensions: Vec<String>,
97    mimes: Vec<String>,
98}
99
100impl UploadRules {
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    pub fn max_bytes(mut self, n: usize) -> Self {
106        self.max_bytes = Some(n);
107        self
108    }
109
110    pub fn extensions<I, S>(mut self, exts: I) -> Self
111    where
112        I: IntoIterator<Item = S>,
113        S: AsRef<str>,
114    {
115        self.extensions = exts
116            .into_iter()
117            .map(|s| s.as_ref().trim_start_matches('.').to_ascii_lowercase())
118            .filter(|s| !s.is_empty())
119            .collect();
120        self
121    }
122
123    pub fn mimes<I, S>(mut self, mimes: I) -> Self
124    where
125        I: IntoIterator<Item = S>,
126        S: AsRef<str>,
127    {
128        self.mimes = mimes
129            .into_iter()
130            .map(|s| s.as_ref().trim().to_ascii_lowercase())
131            .filter(|s| !s.is_empty())
132            .collect();
133        self
134    }
135
136    fn check(&self, upload: &Upload) -> Result<()> {
137        if upload.size() == 0 {
138            return Err(Error::BadRequest("empty file".into()));
139        }
140        if let Some(max) = self.max_bytes {
141            if upload.size() > max {
142                return Err(Error::BadRequest(format!(
143                    "file too large (max {max} bytes)"
144                )));
145            }
146        }
147        if !self.extensions.is_empty() {
148            let ext = upload
149                .extension()
150                .ok_or_else(|| Error::BadRequest("file extension required".into()))?;
151            if !self.extensions.iter().any(|e| e == &ext) {
152                return Err(Error::BadRequest(format!("invalid file extension `{ext}`")));
153            }
154        }
155        if !self.mimes.is_empty() {
156            let mime = upload
157                .mime_type()
158                .ok_or_else(|| Error::BadRequest("file content-type required".into()))?;
159            if !self.mimes.iter().any(|m| m == &mime) {
160                return Err(Error::BadRequest(format!("invalid content-type `{mime}`")));
161            }
162        }
163        Ok(())
164    }
165}
166
167/// Parsed form body: text fields + file uploads (urlencoded or multipart).
168#[derive(Debug, Clone, Default)]
169pub struct FormData {
170    texts: HashMap<String, Vec<String>>,
171    files: HashMap<String, Vec<Upload>>,
172}
173
174impl FormData {
175    pub fn get(&self, name: &str) -> Option<&str> {
176        self.texts.get(name)?.first().map(String::as_str)
177    }
178
179    pub fn get_all(&self, name: &str) -> &[String] {
180        self.texts.get(name).map(Vec::as_slice).unwrap_or(&[])
181    }
182
183    pub fn file(&self, name: &str) -> Option<&Upload> {
184        self.files.get(name)?.first()
185    }
186
187    pub fn files(&self, name: &str) -> &[Upload] {
188        self.files.get(name).map(Vec::as_slice).unwrap_or(&[])
189    }
190
191    pub fn text_map(&self) -> &HashMap<String, Vec<String>> {
192        &self.texts
193    }
194
195    pub fn file_map(&self) -> &HashMap<String, Vec<Upload>> {
196        &self.files
197    }
198
199    fn push_text(&mut self, name: String, value: String) {
200        self.texts.entry(name).or_default().push(value);
201    }
202
203    #[cfg(feature = "multipart")]
204    fn push_file(&mut self, upload: Upload) {
205        self.files
206            .entry(upload.field.clone())
207            .or_default()
208            .push(upload);
209    }
210
211    /// Flatten first text value per key for `serde_urlencoded` / simple structs.
212    fn first_values(&self) -> HashMap<String, String> {
213        self.texts
214            .iter()
215            .filter_map(|(k, v)| v.first().cloned().map(|val| (k.clone(), val)))
216            .collect()
217    }
218}
219
220impl Request {
221    /// Parse form body once (urlencoded or multipart); cached on the request.
222    pub async fn input(&mut self) -> Result<&FormData> {
223        if self.get::<FormData>().is_some() {
224            return Ok(self.get::<FormData>().expect("FormData"));
225        }
226        let parsed = parse_form_data(self).await?;
227        self.set(parsed);
228        Ok(self.get::<FormData>().expect("FormData"))
229    }
230
231    /// Deserialize text fields into `T` (works for urlencoded and multipart text parts).
232    pub async fn form<T: DeserializeOwned>(&mut self) -> Result<T> {
233        let data = self.input().await?;
234        let map = data.first_values();
235        let encoded = serde_urlencoded::to_string(&map)
236            .map_err(|e| Error::BadRequest(format!("form encode: {e}")))?;
237        serde_urlencoded::from_str(&encoded)
238            .map_err(|e| Error::BadRequest(format!("form error: {e}")))
239    }
240}
241
242async fn parse_form_data(req: &mut Request) -> Result<FormData> {
243    let ct = req.content_type().unwrap_or("").to_ascii_lowercase();
244    if ct.starts_with("multipart/") {
245        #[cfg(feature = "multipart")]
246        {
247            return parse_multipart(req).await;
248        }
249        #[cfg(not(feature = "multipart"))]
250        {
251            return Err(Error::BadRequest(
252                "multipart body requires the `multipart` feature".into(),
253            ));
254        }
255    }
256
257    // Default: urlencoded (also empty / missing CT for classic forms).
258    let bytes = req.collect_body("form").await?;
259    let mut data = FormData::default();
260    if bytes.is_empty() {
261        return Ok(data);
262    }
263    let pairs: Vec<(String, String)> = serde_urlencoded::from_bytes(&bytes)
264        .map_err(|e| Error::BadRequest(format!("form error: {e}")))?;
265    for (k, v) in pairs {
266        data.push_text(k, v);
267    }
268    Ok(data)
269}
270
271#[cfg(feature = "multipart")]
272async fn parse_multipart(req: &mut Request) -> Result<FormData> {
273    use bytes::BytesMut;
274    use futures_util::stream;
275    use http_body_util::BodyExt;
276    use multer::Multipart;
277
278    let ct = req
279        .header("content-type")
280        .ok_or_else(|| Error::BadRequest("missing content-type".into()))?
281        .to_string();
282    let boundary = multer::parse_boundary(&ct)
283        .map_err(|e| Error::BadRequest(format!("multipart boundary: {e}")))?;
284
285    let limit = req.body_limit();
286    let mut body = req.into_body_stream_as("multipart")?;
287    let mut collected = BytesMut::new();
288    while let Some(frame) = body.frame().await {
289        let frame = frame.map_err(|e| Error::BadRequest(format!("multipart: {e}")))?;
290        if let Ok(chunk) = frame.into_data() {
291            if collected.len().saturating_add(chunk.len()) > limit {
292                return Err(Error::PayloadTooLarge);
293            }
294            collected.extend_from_slice(&chunk);
295        }
296    }
297    let bytes = collected.freeze();
298    // Restore body so later readers (if any) see the same bytes; FormData is cached.
299    req.body = crate::request::ReqBody::Bytes(bytes.clone());
300
301    let stream = stream::once(async move { Ok::<_, std::io::Error>(bytes) });
302    let mut mp = Multipart::new(stream, boundary);
303    let mut data = FormData::default();
304    while let Some(field) = mp
305        .next_field()
306        .await
307        .map_err(|e| Error::BadRequest(format!("multipart: {e}")))?
308    {
309        let name = field.name().unwrap_or("").to_string();
310        let filename = field.file_name().map(str::to_string);
311        let content_type = field.content_type().map(|m| m.to_string());
312        let part = field
313            .bytes()
314            .await
315            .map_err(|e| Error::BadRequest(format!("multipart field: {e}")))?;
316        if filename.is_some() {
317            data.push_file(Upload {
318                field: name,
319                filename,
320                content_type,
321                data: part,
322            });
323        } else {
324            let s = String::from_utf8_lossy(&part).into_owned();
325            data.push_text(name, s);
326        }
327    }
328    Ok(data)
329}
330
331fn is_safe_relative(path: &Path) -> bool {
332    !path.as_os_str().is_empty() && path.components().all(|c| matches!(c, Component::Normal(_)))
333}
334
335#[cfg(test)]
336mod upload_rules_tests {
337    use super::*;
338    use bytes::Bytes;
339
340    fn upload(name: &str, ct: Option<&str>, data: &'static [u8]) -> Upload {
341        Upload {
342            field: "file".into(),
343            filename: Some(name.into()),
344            content_type: ct.map(str::to_owned),
345            data: Bytes::from_static(data),
346        }
347    }
348
349    #[test]
350    fn helpers_extension_mime_size() {
351        let u = upload("Photo.PNG", Some("image/png; charset=binary"), b"abc");
352        assert_eq!(u.size(), 3);
353        assert_eq!(u.extension().as_deref(), Some("png"));
354        assert_eq!(u.mime_type().as_deref(), Some("image/png"));
355    }
356
357    #[test]
358    fn rejects_empty_and_oversized() {
359        let empty = upload("a.txt", None, b"");
360        assert!(empty.validate(&UploadRules::new()).is_err());
361
362        let big = upload("a.txt", None, b"hello");
363        assert!(big.validate(&UploadRules::new().max_bytes(4)).is_err());
364        assert!(big.validate(&UploadRules::new().max_bytes(5)).is_ok());
365    }
366
367    #[test]
368    fn extensions_and_mimes() {
369        let u = upload("a.JPG", Some("image/jpeg"), b"x");
370        assert!(u
371            .validate(&UploadRules::new().extensions(["png", "jpg"]))
372            .is_ok());
373        assert!(u.validate(&UploadRules::new().extensions(["png"])).is_err());
374        assert!(u
375            .validate(&UploadRules::new().mimes(["image/jpeg"]))
376            .is_ok());
377        assert!(u
378            .validate(&UploadRules::new().mimes(["image/png"]))
379            .is_err());
380    }
381}
382
383#[cfg(all(test, feature = "multipart"))]
384mod tests {
385    use super::*;
386    use crate::Request;
387    use bytes::Bytes;
388    use http::Method;
389
390    fn multipart_body(boundary: &str, parts: &str) -> Bytes {
391        Bytes::from(format!("--{boundary}\r\n{parts}--{boundary}--\r\n"))
392    }
393
394    fn multipart_req(boundary: &str, parts: &str) -> Request {
395        Request::builder()
396            .method(Method::POST)
397            .path("/upload")
398            .header(
399                "content-type",
400                format!("multipart/form-data; boundary={boundary}"),
401            )
402            .body(multipart_body(boundary, parts))
403            .build()
404    }
405
406    #[tokio::test]
407    async fn parses_text_and_file_fields() {
408        let boundary = "----sovaBound";
409        let parts = concat!(
410            "Content-Disposition: form-data; name=\"title\"\r\n\r\n",
411            "hello\r\n",
412            "------sovaBound\r\n",
413            "Content-Disposition: form-data; name=\"file\"; filename=\"a.txt\"\r\n",
414            "Content-Type: text/plain\r\n\r\n",
415            "file-bytes\r\n",
416        );
417        let mut req = multipart_req(boundary, parts);
418        let data = req.input().await.unwrap();
419        assert_eq!(data.get("title"), Some("hello"));
420        let file = data.file("file").unwrap();
421        assert_eq!(file.filename.as_deref(), Some("a.txt"));
422        assert_eq!(file.data.as_ref(), b"file-bytes");
423    }
424
425    #[tokio::test]
426    async fn urlencoded_form_via_input() {
427        let mut req = Request::builder()
428            .method(Method::POST)
429            .path("/")
430            .header("content-type", "application/x-www-form-urlencoded")
431            .body("name=Ada&age=1")
432            .build();
433        #[derive(serde::Deserialize, Debug, PartialEq)]
434        struct Body {
435            name: String,
436            age: u32,
437        }
438        let body: Body = req.form().await.unwrap();
439        assert_eq!(
440            body,
441            Body {
442                name: "Ada".into(),
443                age: 1
444            }
445        );
446    }
447
448    #[tokio::test]
449    async fn missing_boundary_is_bad_request() {
450        let mut req = Request::builder()
451            .method(Method::POST)
452            .path("/")
453            .header("content-type", "multipart/form-data")
454            .body("x")
455            .build();
456        let err = req.input().await.unwrap_err();
457        assert!(matches!(err, Error::BadRequest(_)));
458    }
459
460    #[tokio::test]
461    async fn oversize_body_is_413() {
462        let boundary = "b";
463        let big = "x".repeat(64);
464        let parts = format!("Content-Disposition: form-data; name=\"f\"\r\n\r\n{big}\r\n");
465        let mut req = Request::builder()
466            .method(Method::POST)
467            .path("/")
468            .header(
469                "content-type",
470                format!("multipart/form-data; boundary={boundary}"),
471            )
472            .body(multipart_body(boundary, &parts))
473            .body_limit(16)
474            .build();
475        let err = req.input().await.unwrap_err();
476        assert!(matches!(err, Error::PayloadTooLarge), "got {err:?}");
477    }
478
479    #[tokio::test]
480    async fn broken_delimiter_is_bad_request() {
481        let mut req = Request::builder()
482            .method(Method::POST)
483            .path("/")
484            .header("content-type", "multipart/form-data; boundary=abc")
485            .body("not-a-multipart-body")
486            .build();
487        let err = req.input().await.unwrap_err();
488        assert!(matches!(err, Error::BadRequest(_)), "got {err:?}");
489    }
490}