1use crate::validate::UploadValidator;
2
3pub(crate) fn extract_extension(filename: &str) -> Option<&str> {
10 let ext = filename.rsplit('.').next()?;
11 if ext == filename { None } else { Some(ext) }
12}
13
14pub(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
34pub struct UploadedFile {
41 name: String,
42 file_name: String,
43 content_type: String,
44 data: bytes::Bytes,
45}
46
47impl UploadedFile {
48 #[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 pub fn name(&self) -> &str {
78 &self.name
79 }
80
81 pub fn file_name(&self) -> &str {
83 &self.file_name
84 }
85
86 pub fn content_type(&self) -> &str {
88 &self.content_type
89 }
90
91 pub fn data(&self) -> &bytes::Bytes {
93 &self.data
94 }
95
96 pub fn size(&self) -> usize {
98 self.data.len()
99 }
100
101 pub fn extension(&self) -> Option<String> {
103 extract_extension(&self.file_name).map(|ext| ext.to_ascii_lowercase())
104 }
105
106 pub fn is_empty(&self) -> bool {
108 self.data.is_empty()
109 }
110
111 #[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 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}