Skip to main content

systemprompt_files/services/upload/
validator.rs

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        match mime_type.to_lowercase().as_str() {
220            "image/jpeg" => "jpg",
221            "image/png" => "png",
222            "image/gif" => "gif",
223            "image/webp" => "webp",
224            "image/svg+xml" => "svg",
225            "image/bmp" => "bmp",
226            "image/tiff" => "tiff",
227            "image/x-icon" | "image/vnd.microsoft.icon" => "ico",
228            "application/pdf" => "pdf",
229            "text/plain" => "txt",
230            "text/csv" => "csv",
231            "text/markdown" => "md",
232            "text/html" => "html",
233            "application/json" => "json",
234            "application/xml" | "text/xml" => "xml",
235            "application/rtf" => "rtf",
236            "application/msword" => "doc",
237            "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => "docx",
238            "application/vnd.ms-excel" => "xls",
239            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => "xlsx",
240            "application/vnd.ms-powerpoint" => "ppt",
241            "application/vnd.openxmlformats-officedocument.presentationml.presentation" => "pptx",
242            "audio/mpeg" | "audio/mp3" => "mp3",
243            "audio/wav" | "audio/wave" | "audio/x-wav" => "wav",
244            "audio/ogg" => "ogg",
245            "audio/webm" => "weba",
246            "audio/aac" => "aac",
247            "audio/flac" => "flac",
248            "audio/mp4" | "audio/x-m4a" => "m4a",
249            "video/mp4" => "mp4",
250            "video/webm" => "webm",
251            "video/ogg" => "ogv",
252            "video/quicktime" => "mov",
253            "video/x-msvideo" => "avi",
254            "video/x-matroska" => "mkv",
255            _ => "bin",
256        }
257        .to_string()
258    }
259}