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