Skip to main content

modo_upload/
file.rs

1use crate::validate::UploadValidator;
2
3/// Extract the file extension from a filename (without dot, not lowercased).
4///
5/// Returns `None` when the filename has no dot or when the dot is at position 0
6/// and the entire string equals the "extension" (i.e. the empty string `""`
7/// — the empty-filename case).  Dotfiles like `".gitignore"` return
8/// `Some("gitignore")`.  Filenames with no dot (e.g. `"noext"`) return `None`.
9pub(crate) fn extract_extension(filename: &str) -> Option<&str> {
10    let ext = filename.rsplit('.').next()?;
11    if ext == filename { None } else { Some(ext) }
12}
13
14/// Metadata extracted from a multipart field (shared by UploadedFile and BufferedUpload).
15pub(crate) struct FieldMeta {
16    pub name: String,
17    pub file_name: String,
18    pub content_type: String,
19}
20
21impl FieldMeta {
22    pub fn from_field(field: &axum::extract::multipart::Field<'_>) -> Self {
23        Self {
24            name: field.name().unwrap_or_default().to_owned(),
25            file_name: field.file_name().unwrap_or_default().to_owned(),
26            content_type: field
27                .content_type()
28                .unwrap_or("application/octet-stream")
29                .to_owned(),
30        }
31    }
32}
33
34/// An uploaded file fully buffered in memory as a single contiguous buffer.
35///
36/// `UploadedFile` holds all bytes in a single [`bytes::Bytes`] buffer after the
37/// multipart field has been drained.  Use [`BufferedUpload`](crate::BufferedUpload)
38/// instead when you need to write to storage via a chunked reader API
39/// (e.g. [`BufferedUpload::into_reader`](crate::BufferedUpload::into_reader)).
40pub struct UploadedFile {
41    name: String,
42    file_name: String,
43    content_type: String,
44    data: bytes::Bytes,
45}
46
47impl UploadedFile {
48    /// Create from an axum multipart field (consumes the field).
49    #[doc(hidden)]
50    pub async fn from_field(
51        field: axum::extract::multipart::Field<'_>,
52        max_size: Option<usize>,
53    ) -> Result<Self, modo::Error> {
54        let meta = FieldMeta::from_field(&field);
55        let mut field = field;
56        let mut buf = bytes::BytesMut::new();
57        while let Some(chunk) = field.chunk().await.map_err(|e| {
58            modo::HttpError::BadRequest.with_message(format!("failed to read multipart chunk: {e}"))
59        })? {
60            buf.extend_from_slice(&chunk);
61            if let Some(max) = max_size
62                && buf.len() > max
63            {
64                return Err(modo::HttpError::PayloadTooLarge
65                    .with_message("upload exceeds maximum allowed size"));
66            }
67        }
68        Ok(Self {
69            name: meta.name,
70            file_name: meta.file_name,
71            content_type: meta.content_type,
72            data: buf.freeze(),
73        })
74    }
75
76    /// The multipart field name.
77    pub fn name(&self) -> &str {
78        &self.name
79    }
80
81    /// The original filename provided by the client.
82    pub fn file_name(&self) -> &str {
83        &self.file_name
84    }
85
86    /// The MIME content type.
87    pub fn content_type(&self) -> &str {
88        &self.content_type
89    }
90
91    /// The raw file bytes.
92    pub fn data(&self) -> &bytes::Bytes {
93        &self.data
94    }
95
96    /// File size in bytes.
97    pub fn size(&self) -> usize {
98        self.data.len()
99    }
100
101    /// File extension from the original filename (lowercase, without dot).
102    pub fn extension(&self) -> Option<String> {
103        extract_extension(&self.file_name).map(|ext| ext.to_ascii_lowercase())
104    }
105
106    /// Whether the file is empty (zero bytes).
107    pub fn is_empty(&self) -> bool {
108        self.data.is_empty()
109    }
110
111    /// Test helper — construct an `UploadedFile` without multipart parsing.
112    #[doc(hidden)]
113    pub fn __test_new(name: &str, file_name: &str, content_type: &str, data: &[u8]) -> Self {
114        Self {
115            name: name.to_owned(),
116            file_name: file_name.to_owned(),
117            content_type: content_type.to_owned(),
118            data: bytes::Bytes::copy_from_slice(data),
119        }
120    }
121
122    /// Start building a fluent validation chain for this file.
123    ///
124    /// Returns an `UploadValidator` that lets you chain `.max_size()` and
125    /// `.accept()` calls before calling `.check()` to get the final result.
126    ///
127    /// # Example
128    ///
129    /// ```rust,ignore
130    /// use modo_upload::mb;
131    ///
132    /// file.validate()
133    ///     .max_size(mb(5))
134    ///     .accept("image/*")
135    ///     .check()?;
136    /// ```
137    pub fn validate(&self) -> UploadValidator<'_> {
138        UploadValidator::new(self)
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    fn file_with_name(file_name: &str) -> UploadedFile {
147        UploadedFile::__test_new("f", file_name, "application/octet-stream", b"")
148    }
149
150    #[test]
151    fn extension_lowercase() {
152        assert_eq!(file_with_name("photo.JPG").extension(), Some("jpg".into()));
153    }
154
155    #[test]
156    fn extension_compound() {
157        assert_eq!(
158            file_with_name("archive.tar.gz").extension(),
159            Some("gz".into())
160        );
161    }
162
163    #[test]
164    fn extension_dotfile() {
165        assert_eq!(
166            file_with_name(".gitignore").extension(),
167            Some("gitignore".into())
168        );
169    }
170
171    #[test]
172    fn extension_none() {
173        assert_eq!(file_with_name("noext").extension(), None);
174    }
175
176    #[test]
177    fn extension_trailing_dot() {
178        assert_eq!(file_with_name("file.").extension(), Some("".into()));
179    }
180
181    #[test]
182    fn extension_empty_filename() {
183        assert_eq!(file_with_name("").extension(), None);
184    }
185
186    #[test]
187    fn extension_only_dots() {
188        assert_eq!(file_with_name("....").extension(), Some("".into()));
189    }
190
191    #[test]
192    fn extension_single_dot() {
193        assert_eq!(file_with_name(".").extension(), Some("".into()));
194    }
195
196    #[test]
197    fn extension_unicode_filename() {
198        assert_eq!(file_with_name("café.txt").extension(), Some("txt".into()));
199    }
200
201    #[test]
202    fn extension_space_in_name() {
203        assert_eq!(
204            file_with_name("my file.tar.gz").extension(),
205            Some("gz".into())
206        );
207    }
208
209    #[test]
210    fn accessors_nonempty_file() {
211        let f = UploadedFile::__test_new("field", "photo.jpg", "image/jpeg", b"imgdata");
212        assert_eq!(f.name(), "field");
213        assert_eq!(f.file_name(), "photo.jpg");
214        assert_eq!(f.content_type(), "image/jpeg");
215        assert_eq!(f.data().as_ref(), b"imgdata");
216        assert_eq!(f.size(), 7);
217        assert!(!f.is_empty());
218    }
219
220    #[test]
221    fn accessors_empty_file() {
222        let f = UploadedFile::__test_new("field", "empty.bin", "application/octet-stream", b"");
223        assert_eq!(f.size(), 0);
224        assert!(f.is_empty());
225    }
226}