1use crate::validate::UploadValidator;
2
3pub(crate) fn extract_extension(filename: &str) -> Option<&str> {
7 let ext = filename.rsplit('.').next()?;
8 if ext == filename { None } else { Some(ext) }
9}
10
11pub(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
31pub struct UploadedFile {
33 name: String,
34 file_name: String,
35 content_type: String,
36 data: bytes::Bytes,
37}
38
39impl UploadedFile {
40 #[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 pub fn name(&self) -> &str {
70 &self.name
71 }
72
73 pub fn file_name(&self) -> &str {
75 &self.file_name
76 }
77
78 pub fn content_type(&self) -> &str {
80 &self.content_type
81 }
82
83 pub fn data(&self) -> &bytes::Bytes {
85 &self.data
86 }
87
88 pub fn size(&self) -> usize {
90 self.data.len()
91 }
92
93 pub fn extension(&self) -> Option<String> {
95 extract_extension(&self.file_name).map(|ext| ext.to_ascii_lowercase())
96 }
97
98 pub fn is_empty(&self) -> bool {
100 self.data.is_empty()
101 }
102
103 #[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 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}