1use crate::config::FileUploadConfig;
12use thiserror::Error;
13
14#[derive(Debug, Error)]
15pub enum FileValidationError {
16 #[error("File uploads are disabled")]
17 UploadsDisabled,
18
19 #[error("File size {size} bytes exceeds maximum allowed {max} bytes")]
20 FileTooLarge { size: u64, max: u64 },
21
22 #[error("File type '{mime_type}' is not allowed")]
23 TypeNotAllowed { mime_type: String },
24
25 #[error("File type '{mime_type}' is blocked for security reasons")]
26 TypeBlocked { mime_type: String },
27
28 #[error("File category '{category}' is disabled in configuration")]
29 CategoryDisabled { category: String },
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum FileCategory {
34 Image,
35 Document,
36 Audio,
37 Video,
38}
39
40impl FileCategory {
41 pub const fn storage_subdir(&self) -> &'static str {
42 match self {
43 Self::Image => "images",
44 Self::Document => "documents",
45 Self::Audio => "audio",
46 Self::Video => "video",
47 }
48 }
49
50 pub const fn display_name(&self) -> &'static str {
51 match self {
52 Self::Image => "image",
53 Self::Document => "document",
54 Self::Audio => "audio",
55 Self::Video => "video",
56 }
57 }
58}
59
60#[derive(Debug, Clone, Copy)]
61pub struct FileValidator {
62 config: FileUploadConfig,
63}
64
65impl FileValidator {
66 const IMAGE_TYPES: &'static [&'static str] = &[
67 "image/jpeg",
68 "image/png",
69 "image/gif",
70 "image/webp",
71 "image/svg+xml",
72 "image/bmp",
73 "image/tiff",
74 "image/x-icon",
75 "image/vnd.microsoft.icon",
76 ];
77
78 const DOCUMENT_TYPES: &'static [&'static str] = &[
79 "application/pdf",
80 "application/msword",
81 "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
82 "application/vnd.ms-excel",
83 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
84 "application/vnd.ms-powerpoint",
85 "application/vnd.openxmlformats-officedocument.presentationml.presentation",
86 "text/plain",
87 "text/csv",
88 "text/markdown",
89 "text/html",
90 "application/json",
91 "application/xml",
92 "text/xml",
93 "application/rtf",
94 ];
95
96 const AUDIO_TYPES: &'static [&'static str] = &[
97 "audio/mpeg",
98 "audio/mp3",
99 "audio/wav",
100 "audio/wave",
101 "audio/x-wav",
102 "audio/ogg",
103 "audio/webm",
104 "audio/aac",
105 "audio/flac",
106 "audio/mp4",
107 "audio/x-m4a",
108 ];
109
110 const VIDEO_TYPES: &'static [&'static str] = &[
111 "video/mp4",
112 "video/webm",
113 "video/ogg",
114 "video/quicktime",
115 "video/x-msvideo",
116 "video/x-matroska",
117 ];
118
119 const BLOCKED_TYPES: &'static [&'static str] = &[
120 "application/x-executable",
121 "application/x-msdos-program",
122 "application/x-msdownload",
123 "application/x-sh",
124 "application/x-shellscript",
125 "application/x-csh",
126 "application/x-bash",
127 "application/bat",
128 "application/x-bat",
129 "application/x-msi",
130 "application/vnd.microsoft.portable-executable",
131 "application/x-dosexec",
132 "application/x-python-code",
133 "application/javascript",
134 "text/javascript",
135 "application/x-httpd-php",
136 "application/x-php",
137 "text/x-php",
138 "application/x-perl",
139 "text/x-perl",
140 "application/x-ruby",
141 "text/x-ruby",
142 "application/java-archive",
143 "application/x-java-class",
144 ];
145
146 pub const fn new(config: FileUploadConfig) -> Self {
147 Self { config }
148 }
149
150 pub fn validate(
151 &self,
152 mime_type: &str,
153 size_bytes: u64,
154 ) -> Result<FileCategory, FileValidationError> {
155 if !self.config.enabled {
156 return Err(FileValidationError::UploadsDisabled);
157 }
158
159 if size_bytes > self.config.max_file_size_bytes {
160 return Err(FileValidationError::FileTooLarge {
161 size: size_bytes,
162 max: self.config.max_file_size_bytes,
163 });
164 }
165
166 let normalized_mime = mime_type.to_lowercase();
167
168 if Self::BLOCKED_TYPES.contains(&normalized_mime.as_str()) {
169 return Err(FileValidationError::TypeBlocked {
170 mime_type: mime_type.to_owned(),
171 });
172 }
173
174 let category = Self::categorize_mime_type(&normalized_mime)?;
175
176 if !self.is_category_allowed(&category) {
177 return Err(FileValidationError::CategoryDisabled {
178 category: category.display_name().to_owned(),
179 });
180 }
181
182 Ok(category)
183 }
184
185 fn categorize_mime_type(mime_type: &str) -> Result<FileCategory, FileValidationError> {
186 if Self::IMAGE_TYPES.contains(&mime_type) {
187 return Ok(FileCategory::Image);
188 }
189
190 if Self::DOCUMENT_TYPES.contains(&mime_type) {
191 return Ok(FileCategory::Document);
192 }
193
194 if Self::AUDIO_TYPES.contains(&mime_type) {
195 return Ok(FileCategory::Audio);
196 }
197
198 if Self::VIDEO_TYPES.contains(&mime_type) {
199 return Ok(FileCategory::Video);
200 }
201
202 Err(FileValidationError::TypeNotAllowed {
203 mime_type: mime_type.to_owned(),
204 })
205 }
206
207 const fn is_category_allowed(&self, category: &FileCategory) -> bool {
208 match category {
209 FileCategory::Image => self.config.allowed_types.images,
210 FileCategory::Document => self.config.allowed_types.documents,
211 FileCategory::Audio => self.config.allowed_types.audio,
212 FileCategory::Video => self.config.allowed_types.video,
213 }
214 }
215
216 pub fn get_extension(mime_type: &str, filename: Option<&str>) -> String {
217 if let Some(name) = filename
218 && let Some(ext) = name.rsplit('.').next()
219 && !ext.is_empty()
220 && ext.len() <= 10
221 && ext != name
222 && ext.chars().all(|c| c.is_ascii_alphanumeric())
223 {
224 return ext.to_lowercase();
225 }
226
227 let lower = mime_type.to_lowercase();
228 MIME_EXTENSION_TABLE
229 .iter()
230 .find(|(mimes, _)| mimes.contains(&lower.as_str()))
231 .map_or("bin", |(_, ext)| *ext)
232 .to_owned()
233 }
234}
235
236const MIME_EXTENSION_TABLE: &[(&[&str], &str)] = &[
237 (&["image/jpeg"], "jpg"),
238 (&["image/png"], "png"),
239 (&["image/gif"], "gif"),
240 (&["image/webp"], "webp"),
241 (&["image/svg+xml"], "svg"),
242 (&["image/bmp"], "bmp"),
243 (&["image/tiff"], "tiff"),
244 (&["image/x-icon", "image/vnd.microsoft.icon"], "ico"),
245 (&["application/pdf"], "pdf"),
246 (&["text/plain"], "txt"),
247 (&["text/csv"], "csv"),
248 (&["text/markdown"], "md"),
249 (&["text/html"], "html"),
250 (&["application/json"], "json"),
251 (&["application/xml", "text/xml"], "xml"),
252 (&["application/rtf"], "rtf"),
253 (&["application/msword"], "doc"),
254 (
255 &["application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
256 "docx",
257 ),
258 (&["application/vnd.ms-excel"], "xls"),
259 (
260 &["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
261 "xlsx",
262 ),
263 (&["application/vnd.ms-powerpoint"], "ppt"),
264 (
265 &["application/vnd.openxmlformats-officedocument.presentationml.presentation"],
266 "pptx",
267 ),
268 (&["audio/mpeg", "audio/mp3"], "mp3"),
269 (&["audio/wav", "audio/wave", "audio/x-wav"], "wav"),
270 (&["audio/ogg"], "ogg"),
271 (&["audio/webm"], "weba"),
272 (&["audio/aac"], "aac"),
273 (&["audio/flac"], "flac"),
274 (&["audio/mp4", "audio/x-m4a"], "m4a"),
275 (&["video/mp4"], "mp4"),
276 (&["video/webm"], "webm"),
277 (&["video/ogg"], "ogv"),
278 (&["video/quicktime"], "mov"),
279 (&["video/x-msvideo"], "avi"),
280 (&["video/x-matroska"], "mkv"),
281];