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