1use dioxus::html::{FileData, HasFileData};
24use dioxus::prelude::*;
25
26use crate::core::{client_point, element_point, Point};
27
28#[derive(Clone, PartialEq)]
30pub struct FileDrop {
31 pub files: Vec<FileData>,
32 pub client: Point,
34 pub element: Point,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum FileRejection {
41 Extension,
43 ContentType,
45 TooLarge,
47 TooMany,
49}
50
51#[derive(Debug, Clone, PartialEq, Default)]
60pub struct FileFilter {
61 extensions: Vec<String>,
62 content_types: Vec<String>,
63 max_size: Option<u64>,
64 max_files: Option<usize>,
65}
66
67impl FileFilter {
68 pub fn new() -> Self {
69 Self::default()
70 }
71
72 pub fn extensions<I, S>(mut self, exts: I) -> Self
74 where
75 I: IntoIterator<Item = S>,
76 S: Into<String>,
77 {
78 self.extensions = exts
79 .into_iter()
80 .map(|s| normalize_extension(&s.into()))
81 .filter(|s| !s.is_empty())
82 .collect();
83 self
84 }
85
86 pub fn content_types<I, S>(mut self, types: I) -> Self
95 where
96 I: IntoIterator<Item = S>,
97 S: Into<String>,
98 {
99 self.content_types = types
100 .into_iter()
101 .map(|s| normalize_content_type(&s.into()))
102 .filter(|s| !s.is_empty())
103 .collect();
104 self
105 }
106
107 pub fn max_size(mut self, bytes: u64) -> Self {
109 self.max_size = Some(bytes);
110 self
111 }
112
113 pub fn max_files(mut self, n: usize) -> Self {
115 self.max_files = Some(n);
116 self
117 }
118
119 pub fn check(&self, file: &FileData) -> Result<(), FileRejection> {
121 if !self.extensions.is_empty() {
122 let name = file.name().to_ascii_lowercase();
127 let ok = self
128 .extensions
129 .iter()
130 .any(|ext| name.ends_with(&format!(".{ext}")));
131 if !ok {
132 return Err(FileRejection::Extension);
133 }
134 }
135 if !self.content_types.is_empty() {
136 let ct = file
137 .content_type()
138 .map(|s| normalize_content_type(&s))
139 .unwrap_or_default();
140 let ok = self
141 .content_types
142 .iter()
143 .any(|allowed| content_type_matches(allowed, &ct));
144 if !ok {
145 return Err(FileRejection::ContentType);
146 }
147 }
148 if let Some(max) = self.max_size {
149 if file.size() > max {
150 return Err(FileRejection::TooLarge);
151 }
152 }
153 Ok(())
154 }
155
156 pub fn partition(
158 &self,
159 files: Vec<FileData>,
160 ) -> (Vec<FileData>, Vec<(FileData, FileRejection)>) {
161 let mut ok = Vec::new();
162 let mut bad = Vec::new();
163 for file in files {
164 if let Some(max) = self.max_files {
165 if ok.len() >= max {
166 bad.push((file, FileRejection::TooMany));
167 continue;
168 }
169 }
170 match self.check(&file) {
171 Ok(()) => ok.push(file),
172 Err(why) => bad.push((file, why)),
173 }
174 }
175 (ok, bad)
176 }
177}
178
179fn normalize_extension(ext: &str) -> String {
180 ext.trim().trim_start_matches('.').to_ascii_lowercase()
181}
182
183fn normalize_content_type(content_type: &str) -> String {
184 content_type
185 .split_once(';')
186 .map(|(base, _)| base)
187 .unwrap_or(content_type)
188 .trim()
189 .to_ascii_lowercase()
190}
191
192fn split_content_type(content_type: &str) -> Option<(&str, &str)> {
193 let (ty, subtype) = content_type.split_once('/')?;
194 if ty.is_empty() || subtype.is_empty() || subtype.contains('/') {
195 return None;
196 }
197 Some((ty, subtype))
198}
199
200fn content_type_matches(pattern: &str, content_type: &str) -> bool {
201 let Some((pattern_type, pattern_subtype)) = split_content_type(pattern) else {
202 return false;
203 };
204 let Some((actual_type, actual_subtype)) = split_content_type(content_type) else {
205 return false;
206 };
207
208 if pattern_type == "*" && pattern_subtype == "*" {
209 return true;
210 }
211 if pattern_type == "*" && !pattern_subtype.starts_with("*+") {
212 return false;
213 }
214 if pattern_type != "*" && pattern_type != actual_type {
215 return false;
216 }
217 if pattern_subtype == "*" {
218 return true;
219 }
220 if let Some(suffix) = pattern_subtype.strip_prefix("*+") {
221 return actual_subtype
222 .rsplit_once('+')
223 .map(|(_, actual_suffix)| actual_suffix == suffix)
224 .unwrap_or(false);
225 }
226
227 pattern_subtype == actual_subtype
228}
229
230#[component]
240pub fn FileDropZone(
241 #[props(default)]
243 filter: Option<FileFilter>,
244 on_files: EventHandler<FileDrop>,
246 #[props(default)]
248 on_rejected: Option<EventHandler<Vec<(FileData, FileRejection)>>>,
249 #[props(default)]
251 on_hover: Option<EventHandler<bool>>,
252 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
253 children: Element,
254) -> Element {
255 let mut depth = use_signal(|| 0u32);
256
257 rsx! {
258 div {
259 "data-over": if depth() > 0 { "true" },
260 ondragover: move |evt: DragEvent| {
261 evt.prevent_default();
264 },
265 ondragenter: move |evt: DragEvent| {
266 evt.prevent_default();
267 let d = depth() + 1;
268 depth.set(d);
269 if d == 1 {
270 if let Some(h) = &on_hover {
271 h.call(true);
272 }
273 }
274 },
275 ondragleave: move |_| {
276 let d = depth().saturating_sub(1);
277 depth.set(d);
278 if d == 0 {
279 if let Some(h) = &on_hover {
280 h.call(false);
281 }
282 }
283 },
284 ondrop: move |evt: DragEvent| {
285 evt.prevent_default();
286 depth.set(0);
287 if let Some(h) = &on_hover {
288 h.call(false);
289 }
290 let files = evt.files();
291 if files.is_empty() {
292 return;
293 }
294 let (accepted, rejected) = match &filter {
295 Some(f) => f.partition(files),
296 None => (files, Vec::new()),
297 };
298 if !rejected.is_empty() {
299 if let Some(h) = &on_rejected {
300 h.call(rejected);
301 }
302 }
303 if !accepted.is_empty() {
304 on_files.call(FileDrop {
305 files: accepted,
306 client: client_point(&evt),
307 element: element_point(&evt),
308 });
309 }
310 },
311 ..attributes,
312 {children}
313 }
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use dioxus::html::NativeFileData;
321 use std::path::PathBuf;
322 use std::pin::Pin;
323
324 struct MockFile {
326 name: &'static str,
327 size: u64,
328 content_type: Option<&'static str>,
329 }
330
331 impl NativeFileData for MockFile {
332 fn name(&self) -> String {
333 self.name.to_string()
334 }
335 fn size(&self) -> u64 {
336 self.size
337 }
338 fn last_modified(&self) -> u64 {
339 0
340 }
341 fn path(&self) -> PathBuf {
342 PathBuf::new()
343 }
344 fn content_type(&self) -> Option<String> {
345 self.content_type.map(str::to_string)
346 }
347 fn read_bytes(
348 &self,
349 ) -> Pin<Box<dyn std::future::Future<Output = Result<bytes::Bytes, dioxus::CapturedError>>>>
350 {
351 Box::pin(std::future::ready(Ok(bytes::Bytes::new())))
352 }
353 fn byte_stream(
354 &self,
355 ) -> Pin<
356 Box<
357 dyn futures_util::Stream<Item = Result<bytes::Bytes, dioxus::CapturedError>>
358 + Send
359 + 'static,
360 >,
361 > {
362 Box::pin(futures_util::stream::empty())
363 }
364 fn read_string(
365 &self,
366 ) -> Pin<Box<dyn std::future::Future<Output = Result<String, dioxus::CapturedError>>>>
367 {
368 Box::pin(std::future::ready(Ok(String::new())))
369 }
370 fn inner(&self) -> &dyn std::any::Any {
371 self
372 }
373 }
374
375 fn file(name: &'static str, size: u64, ct: Option<&'static str>) -> FileData {
376 FileData::new(MockFile {
377 name,
378 size,
379 content_type: ct,
380 })
381 }
382
383 #[test]
384 fn extension_filter_is_case_insensitive() {
385 let f = FileFilter::new().extensions(["png", ".JPG", " gif "]);
386 assert!(f.check(&file("Photo.PNG", 10, None)).is_ok());
387 assert!(f.check(&file("photo.jpg", 10, None)).is_ok());
388 assert!(f.check(&file("clip.GIF", 10, None)).is_ok());
389 assert_eq!(
390 f.check(&file("notes.txt", 10, None)),
391 Err(FileRejection::Extension)
392 );
393 assert_eq!(
395 f.check(&file("png.txt", 10, None)),
396 Err(FileRejection::Extension)
397 );
398 }
399
400 #[test]
401 fn content_type_wildcards() {
402 let f = FileFilter::new().content_types(["image/*", "application/pdf"]);
403 assert!(f.check(&file("a", 1, Some("image/webp"))).is_ok());
404 assert!(f.check(&file("b", 1, Some("application/pdf"))).is_ok());
405 assert_eq!(
406 f.check(&file("c", 1, Some("text/plain"))),
407 Err(FileRejection::ContentType)
408 );
409 assert_eq!(
411 f.check(&file("d", 1, None)),
412 Err(FileRejection::ContentType)
413 );
414 }
415
416 #[test]
417 fn content_type_wildcards_match_whole_type_only() {
418 let f = FileFilter::new().content_types(["image/*"]);
419 assert!(f.check(&file("a", 1, Some("image/svg+xml"))).is_ok());
420 assert_eq!(
421 f.check(&file("b", 1, Some("imageevil/png"))),
422 Err(FileRejection::ContentType)
423 );
424 assert_eq!(
425 f.check(&file("c", 1, Some("application/image"))),
426 Err(FileRejection::ContentType)
427 );
428 assert_eq!(
429 f.check(&file("d", 1, Some("image/png/extra"))),
430 Err(FileRejection::ContentType)
431 );
432 }
433
434 #[test]
435 fn content_type_matching_normalizes_case_whitespace_and_parameters() {
436 let f = FileFilter::new().content_types([" Application/PDF ", "text/plain"]);
437 assert!(f.check(&file("a", 1, Some("application/pdf"))).is_ok());
438 assert!(f
439 .check(&file("b", 1, Some("TEXT/PLAIN; charset=utf-8")))
440 .is_ok());
441 }
442
443 #[test]
444 fn content_type_all_wildcard_accepts_any_typed_file() {
445 let f = FileFilter::new().content_types(["*/*"]);
446 assert!(f.check(&file("a", 1, Some("image/png"))).is_ok());
447 assert!(f
448 .check(&file("b", 1, Some("application/octet-stream")))
449 .is_ok());
450 assert_eq!(
451 f.check(&file("c", 1, None)),
452 Err(FileRejection::ContentType)
453 );
454 }
455
456 #[test]
457 fn content_type_structured_suffix_wildcards() {
458 let app_json = FileFilter::new().content_types(["application/*+json"]);
459 assert!(app_json
460 .check(&file("a", 1, Some("application/ld+json")))
461 .is_ok());
462 assert!(app_json
463 .check(&file("b", 1, Some("application/vnd.api+json")))
464 .is_ok());
465 assert_eq!(
466 app_json.check(&file("c", 1, Some("text/ld+json"))),
467 Err(FileRejection::ContentType)
468 );
469 assert_eq!(
470 app_json.check(&file("d", 1, Some("application/json"))),
471 Err(FileRejection::ContentType)
472 );
473
474 let any_json = FileFilter::new().content_types(["*/*+json"]);
475 assert!(any_json
476 .check(&file("e", 1, Some("application/problem+json")))
477 .is_ok());
478 assert!(any_json
479 .check(&file("f", 1, Some("model/gltf+json")))
480 .is_ok());
481 assert_eq!(
482 any_json.check(&file("g", 1, Some("application/json"))),
483 Err(FileRejection::ContentType)
484 );
485 }
486
487 #[test]
488 fn malformed_content_type_patterns_do_not_match() {
489 let f = FileFilter::new().content_types(["image", "image/", "/png", "image/png/extra"]);
490 assert_eq!(
491 f.check(&file("a", 1, Some("image/png"))),
492 Err(FileRejection::ContentType)
493 );
494 }
495
496 #[test]
497 fn unsupported_subtype_only_wildcard_does_not_match() {
498 let f = FileFilter::new().content_types(["*/json"]);
499 assert_eq!(
500 f.check(&file("a", 1, Some("application/json"))),
501 Err(FileRejection::ContentType)
502 );
503 assert_eq!(
504 f.check(&file("b", 1, Some("text/json"))),
505 Err(FileRejection::ContentType)
506 );
507 }
508
509 #[test]
510 fn size_limit() {
511 let f = FileFilter::new().max_size(100);
512 assert!(f.check(&file("ok", 100, None)).is_ok());
513 assert_eq!(
514 f.check(&file("big", 101, None)),
515 Err(FileRejection::TooLarge)
516 );
517 }
518
519 #[test]
520 fn partition_applies_count_after_other_rules() {
521 let f = FileFilter::new().extensions(["png"]).max_files(2);
522 let batch = vec![
523 file("a.png", 1, None),
524 file("b.txt", 1, None), file("c.png", 1, None),
526 file("d.png", 1, None), ];
528 let (ok, bad) = f.partition(batch);
529 assert_eq!(
530 ok.iter().map(|f| f.name()).collect::<Vec<_>>(),
531 vec!["a.png", "c.png"]
532 );
533 assert_eq!(bad.len(), 2);
534 assert_eq!(bad[0].1, FileRejection::Extension);
535 assert_eq!(bad[1].1, FileRejection::TooMany);
536 }
537}