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