Skip to main content

dioxus_dnd/
files.rs

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/// A batch of dropped files plus where they landed.
9#[derive(Clone, PartialEq)]
10pub struct FileDrop {
11    pub files: Vec<FileData>,
12    /// Pointer position in client (viewport) coordinates.
13    pub client: Point,
14    /// Pointer position relative to the drop zone element.
15    pub element: Point,
16}
17
18/// Why a file was rejected by a [`FileFilter`].
19///
20/// Non-exhaustive: new acceptance rules mean new rejection reasons, so
21/// keep a wildcard arm with a generic "not accepted" message.
22#[derive(Debug, Clone, PartialEq, Eq)]
23#[non_exhaustive]
24pub enum FileRejection {
25    /// Extension not in the allow-list.
26    Extension,
27    /// MIME type not in the allow-list.
28    ContentType,
29    /// Larger than `max_size` bytes.
30    TooLarge,
31    /// Batch exceeded `max_files`; this file was over the limit.
32    TooMany,
33}
34
35/// Declarative acceptance rules for dropped files.
36///
37/// **Advisory, not a security boundary.** These rules match on the browser-
38/// and OS-reported name, content type and size, all of which are
39/// attacker-controllable: a `.exe` can be renamed `photo.png` and report
40/// `content_type: "image/png"`, and `size` is self-reported. Use the filter
41/// for UX (rejecting obviously wrong drops early), but validate the actual
42/// bytes server-side or via content sniffing before trusting a file.
43#[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    /// Allow only these extensions (case-insensitive, leading dot optional).
57    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    /// Allow only these MIME types.
71    ///
72    /// Supported patterns:
73    ///
74    /// - exact types: `"application/pdf"`
75    /// - top-level wildcards: `"image/*"`
76    /// - all typed files: `"*/*"`
77    /// - structured suffix wildcards: `"application/*+json"` and `"*/*+json"`
78    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    /// Reject files larger than this many bytes.
92    pub fn max_size(mut self, bytes: u64) -> Self {
93        self.max_size = Some(bytes);
94        self
95    }
96
97    /// Accept at most this many files per drop.
98    pub fn max_files(mut self, n: usize) -> Self {
99        self.max_files = Some(n);
100        self
101    }
102
103    /// Check a single file against the rules (ignores `max_files`).
104    pub fn check(&self, file: &FileData) -> Result<(), FileRejection> {
105        if !self.extensions.is_empty() {
106            // ASCII-lowercase to match `normalize_extension`; a full Unicode
107            // `to_lowercase()` here could case-fold a non-ASCII filename
108            // differently from the (ASCII-lowered) extension and spuriously
109            // mismatch. File extensions are ASCII in practice.
110            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    /// Split a batch into `(accepted, rejected)` applying every rule.
141    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/// A zone that accepts files dragged in from the operating system.
215///
216/// Independent of `DndContext` - file drops don't come from inside your app,
217/// so no provider is required.
218///
219/// While a drag hovers the zone the div carries `data-over="true"` (absent
220/// otherwise), so the classic "highlight the dropzone" style needs no
221/// `on_hover` wiring: Tailwind `data-over:border-blue-500`, CSS
222/// `[data-over]`.
223#[component]
224pub fn FileDropZone(
225    /// Acceptance rules; everything is accepted when omitted.
226    #[props(default)]
227    filter: Option<FileFilter>,
228    /// Fired with the accepted files of a drop (only if at least one passed).
229    on_files: EventHandler<FileDrop>,
230    /// Fired with the rejected files of a drop, if any.
231    #[props(default)]
232    on_rejected: Option<EventHandler<Vec<(FileData, FileRejection)>>>,
233    /// Fired with `true` when a drag hovers the zone, `false` when it leaves.
234    #[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                // Required: without preventDefault the browser never delivers
246                // the drop (it would open the file instead).
247                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    /// Minimal test double for the platform file object.
309    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        // extension must match at the end, not merely appear
378        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        // missing content type fails a type-restricted filter
394        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), // rejected on extension, doesn't consume a slot
509            file("c.png", 1, None),
510            file("d.png", 1, None), // over the count
511        ];
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}