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        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];