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 or selected files plus its interaction coordinates.
9#[derive(Clone, PartialEq)]
10pub struct FileDrop {
11    pub files: Vec<FileData>,
12    /// Pointer position in client (viewport) coordinates.
13    ///
14    /// File-picker selections have no drop position, so this is `(0, 0)`.
15    pub client: Point,
16    /// Pointer position relative to the drop zone element.
17    ///
18    /// File-picker selections have no drop position, so this is `(0, 0)`.
19    pub element: Point,
20}
21
22/// Why a file was rejected by a [`FileFilter`].
23///
24/// Non-exhaustive: new acceptance rules mean new rejection reasons, so
25/// keep a wildcard arm with a generic "not accepted" message.
26#[derive(Debug, Clone, PartialEq, Eq)]
27#[non_exhaustive]
28pub enum FileRejection {
29    /// Extension not in the allow-list.
30    Extension,
31    /// MIME type not in the allow-list.
32    ContentType,
33    /// Larger than `max_size` bytes.
34    TooLarge,
35    /// Batch exceeded `max_files`; this file was over the limit.
36    TooMany,
37}
38
39/// Declarative acceptance rules for dropped or picker-selected files.
40///
41/// **Advisory, not a security boundary.** These rules match on the browser-
42/// and OS-reported name, content type and size, all of which are
43/// attacker-controllable: a `.exe` can be renamed `photo.png` and report
44/// `content_type: "image/png"`, and `size` is self-reported. Use the filter
45/// for UX (rejecting obviously wrong drops early), but validate the actual
46/// bytes server-side or via content sniffing before trusting a file.
47#[derive(Debug, Clone, PartialEq, Default)]
48pub struct FileFilter {
49    extensions: Vec<String>,
50    content_types: Vec<String>,
51    max_size: Option<u64>,
52    max_files: Option<usize>,
53}
54
55impl FileFilter {
56    pub fn new() -> Self {
57        Self::default()
58    }
59
60    /// Allow only these extensions (case-insensitive, leading dot optional).
61    pub fn extensions<I, S>(mut self, exts: I) -> Self
62    where
63        I: IntoIterator<Item = S>,
64        S: Into<String>,
65    {
66        self.extensions = exts
67            .into_iter()
68            .map(|s| normalize_extension(&s.into()))
69            .filter(|s| !s.is_empty())
70            .collect();
71        self
72    }
73
74    /// Allow only these MIME types.
75    ///
76    /// Supported patterns:
77    ///
78    /// - exact types: `"application/pdf"`
79    /// - top-level wildcards: `"image/*"`
80    /// - all typed files: `"*/*"`
81    /// - structured suffix wildcards: `"application/*+json"` and `"*/*+json"`
82    pub fn content_types<I, S>(mut self, types: I) -> Self
83    where
84        I: IntoIterator<Item = S>,
85        S: Into<String>,
86    {
87        self.content_types = types
88            .into_iter()
89            .map(|s| normalize_content_type(&s.into()))
90            .filter(|s| !s.is_empty())
91            .collect();
92        self
93    }
94
95    /// Reject files larger than this many bytes.
96    pub fn max_size(mut self, bytes: u64) -> Self {
97        self.max_size = Some(bytes);
98        self
99    }
100
101    /// Accept at most this many files per incoming batch.
102    pub fn max_files(mut self, n: usize) -> Self {
103        self.max_files = Some(n);
104        self
105    }
106
107    /// Check a single file against the rules (ignores `max_files`).
108    pub fn check(&self, file: &FileData) -> Result<(), FileRejection> {
109        if !self.extensions.is_empty() {
110            // ASCII-lowercase to match `normalize_extension`; a full Unicode
111            // `to_lowercase()` here could case-fold a non-ASCII filename
112            // differently from the (ASCII-lowered) extension and spuriously
113            // mismatch. File extensions are ASCII in practice.
114            let name = file.name().to_ascii_lowercase();
115            let ok = self
116                .extensions
117                .iter()
118                .any(|ext| name.ends_with(&format!(".{ext}")));
119            if !ok {
120                return Err(FileRejection::Extension);
121            }
122        }
123        if !self.content_types.is_empty() {
124            let ct = file
125                .content_type()
126                .map(|s| normalize_content_type(&s))
127                .unwrap_or_default();
128            let ok = self
129                .content_types
130                .iter()
131                .any(|allowed| content_type_matches(allowed, &ct));
132            if !ok {
133                return Err(FileRejection::ContentType);
134            }
135        }
136        if let Some(max) = self.max_size {
137            if file.size() > max {
138                return Err(FileRejection::TooLarge);
139            }
140        }
141        Ok(())
142    }
143
144    /// Split a batch into `(accepted, rejected)` applying every rule.
145    pub fn partition(
146        &self,
147        files: Vec<FileData>,
148    ) -> (Vec<FileData>, Vec<(FileData, FileRejection)>) {
149        let mut ok = Vec::new();
150        let mut bad = Vec::new();
151        for file in files {
152            if let Some(max) = self.max_files {
153                if ok.len() >= max {
154                    bad.push((file, FileRejection::TooMany));
155                    continue;
156                }
157            }
158            match self.check(&file) {
159                Ok(()) => ok.push(file),
160                Err(why) => bad.push((file, why)),
161            }
162        }
163        (ok, bad)
164    }
165
166    /// Build the advisory `accept` value for the native file picker.
167    ///
168    /// The picker grammar cannot express every rule supported by
169    /// `FileFilter` (sizes, counts, or structured-suffix wildcards), so the
170    /// selected files still pass through `partition` before callbacks fire.
171    fn picker_accept(&self) -> Option<String> {
172        let mut hints = self
173            .extensions
174            .iter()
175            .map(|extension| format!(".{extension}"))
176            .collect::<Vec<_>>();
177
178        for content_type in &self.content_types {
179            let Some((ty, subtype)) = split_content_type(content_type) else {
180                continue;
181            };
182            // HTML file inputs understand exact MIME types and top-level
183            // wildcards. Broader and structured-suffix wildcards remain
184            // enforced after selection without accidentally hiding valid
185            // files in the dialog.
186            if ty == "*" || ty.contains('*') || (subtype != "*" && subtype.contains('*')) {
187                continue;
188            }
189            if !hints.contains(content_type) {
190                hints.push(content_type.clone());
191            }
192        }
193
194        (!hints.is_empty()).then(|| hints.join(","))
195    }
196}
197
198fn normalize_extension(ext: &str) -> String {
199    ext.trim().trim_start_matches('.').to_ascii_lowercase()
200}
201
202fn normalize_content_type(content_type: &str) -> String {
203    content_type
204        .split_once(';')
205        .map(|(base, _)| base)
206        .unwrap_or(content_type)
207        .trim()
208        .to_ascii_lowercase()
209}
210
211fn split_content_type(content_type: &str) -> Option<(&str, &str)> {
212    let (ty, subtype) = content_type.split_once('/')?;
213    if ty.is_empty() || subtype.is_empty() || subtype.contains('/') {
214        return None;
215    }
216    Some((ty, subtype))
217}
218
219fn content_type_matches(pattern: &str, content_type: &str) -> bool {
220    let Some((pattern_type, pattern_subtype)) = split_content_type(pattern) else {
221        return false;
222    };
223    let Some((actual_type, actual_subtype)) = split_content_type(content_type) else {
224        return false;
225    };
226
227    if pattern_type == "*" && pattern_subtype == "*" {
228        return true;
229    }
230    if pattern_type == "*" && !pattern_subtype.starts_with("*+") {
231        return false;
232    }
233    if pattern_type != "*" && pattern_type != actual_type {
234        return false;
235    }
236    if pattern_subtype == "*" {
237        return true;
238    }
239    if let Some(suffix) = pattern_subtype.strip_prefix("*+") {
240        return actual_subtype
241            .rsplit_once('+')
242            .map(|(_, actual_suffix)| actual_suffix == suffix)
243            .unwrap_or(false);
244    }
245
246    pattern_subtype == actual_subtype
247}
248
249fn deliver_files(
250    files: Vec<FileData>,
251    filter: Option<&FileFilter>,
252    on_files: &EventHandler<FileDrop>,
253    on_rejected: Option<&EventHandler<Vec<(FileData, FileRejection)>>>,
254    client: Point,
255    element: Point,
256) {
257    if files.is_empty() {
258        return;
259    }
260    let (accepted, rejected) = match filter {
261        Some(filter) => filter.partition(files),
262        None => (files, Vec::new()),
263    };
264    if !rejected.is_empty() {
265        if let Some(handler) = on_rejected {
266            handler.call(rejected);
267        }
268    }
269    if !accepted.is_empty() {
270        on_files.call(FileDrop {
271            files: accepted,
272            client,
273            element,
274        });
275    }
276}
277
278/// A zone that accepts files dragged in from the operating system or chosen
279/// from the native file picker opened by clicking the zone.
280///
281/// Independent of `DndContext` - native files don't come from inside your
282/// app, so no provider is required.
283///
284/// While a drag hovers the zone the div carries `data-over="true"` (absent
285/// otherwise), so the classic "highlight the dropzone" style needs no
286/// `on_hover` wiring: Tailwind `data-over:border-blue-500`, CSS
287/// `[data-over]`.
288#[component]
289pub fn FileDropZone(
290    /// Acceptance rules; everything is accepted when omitted.
291    #[props(default)]
292    filter: Option<FileFilter>,
293    /// Fired with accepted dropped or selected files (if at least one passed).
294    on_files: EventHandler<FileDrop>,
295    /// Fired with rejected dropped or selected files, if any.
296    #[props(default)]
297    on_rejected: Option<EventHandler<Vec<(FileData, FileRejection)>>>,
298    /// Fired with `true` when a drag hovers the zone, `false` when it leaves.
299    #[props(default)]
300    on_hover: Option<EventHandler<bool>>,
301    /// Allow selecting more than one file in the native picker.
302    #[props(default = true)]
303    multiple: bool,
304    /// Disable picker activation and native file input.
305    #[props(default)]
306    disabled: bool,
307    /// Accessible name for the keyboard-focusable drop zone.
308    #[props(default = "Choose or drop files".to_string())]
309    label: String,
310    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
311    children: Element,
312) -> Element {
313    let mut depth = use_signal(|| 0u32);
314    let input_id = use_hook(|| {
315        format!(
316            "dioxus-dnd-file-input-{}",
317            dioxus::core::current_scope_id().0
318        )
319    });
320    let picker_input_id = input_id.clone();
321    let picker_accept = filter.as_ref().and_then(FileFilter::picker_accept);
322    let picker_filter = filter.clone();
323    let picker_on_files = on_files;
324    let picker_on_rejected = on_rejected;
325    let open_picker = use_callback(move |_: ()| {
326        if disabled {
327            return;
328        }
329        // Clear the value before opening so choosing the same file twice
330        // still produces a change event.
331        let script = format!(
332            "const input = document.getElementById({picker_input_id:?}); \
333             if (input) {{ input.value = ''; input.click(); }}"
334        );
335        let _ = dioxus::document::eval(&script);
336    });
337    let mut attributes = attributes;
338    crate::core::components::protect_attributes(
339        &mut attributes,
340        &[
341            "data-over",
342            "data-disabled",
343            "role",
344            "tabindex",
345            "aria-label",
346            "aria-disabled",
347            "onclick",
348            "onkeydown",
349            "ondragover",
350            "ondragenter",
351            "ondragleave",
352            "ondrop",
353        ],
354    );
355
356    rsx! {
357        div {
358            "data-over": if !disabled && depth() > 0 { "true" },
359            "data-disabled": if disabled { "true" },
360            role: "button",
361            tabindex: if disabled { -1_i64 } else { 0 },
362            aria_label: label,
363            aria_disabled: disabled,
364            onclick: move |_| open_picker.call(()),
365            onkeydown: move |event: KeyboardEvent| {
366                if disabled {
367                    return;
368                }
369                let key = event.key();
370                if matches!(key, Key::Enter)
371                    || matches!(&key, Key::Character(value) if value == " ")
372                {
373                    event.prevent_default();
374                    open_picker.call(());
375                }
376            },
377            ondragover: move |evt: DragEvent| {
378                // Required: without preventDefault the browser never delivers
379                // the drop (it would open the file instead).
380                evt.prevent_default();
381            },
382            ondragenter: move |evt: DragEvent| {
383                evt.prevent_default();
384                if disabled {
385                    return;
386                }
387                let d = depth() + 1;
388                depth.set(d);
389                if d == 1 {
390                    if let Some(h) = &on_hover {
391                        h.call(true);
392                    }
393                }
394            },
395            ondragleave: move |_| {
396                if disabled {
397                    depth.set(0);
398                    return;
399                }
400                let d = depth().saturating_sub(1);
401                depth.set(d);
402                if d == 0 {
403                    if let Some(h) = &on_hover {
404                        h.call(false);
405                    }
406                }
407            },
408            ondrop: move |evt: DragEvent| {
409                evt.prevent_default();
410                depth.set(0);
411                if let Some(h) = &on_hover {
412                    h.call(false);
413                }
414                if disabled {
415                    return;
416                }
417                deliver_files(
418                    evt.files(),
419                    filter.as_ref(),
420                    &on_files,
421                    on_rejected.as_ref(),
422                    client_point(&evt),
423                    element_point(&evt),
424                );
425            },
426            ..attributes,
427            {children}
428            input {
429                id: input_id,
430                type: "file",
431                accept: picker_accept,
432                multiple,
433                disabled,
434                hidden: true,
435                // The programmatic input click bubbles. Stop it here so it
436                // cannot reopen the picker through the zone's click handler.
437                onclick: move |evt: MouseEvent| evt.stop_propagation(),
438                onchange: move |evt: FormEvent| {
439                    if disabled {
440                        return;
441                    }
442                    deliver_files(
443                        evt.files(),
444                        picker_filter.as_ref(),
445                        &picker_on_files,
446                        picker_on_rejected.as_ref(),
447                        Point::default(),
448                        Point::default(),
449                    );
450                },
451            }
452        }
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use dioxus::html::NativeFileData;
460    use std::path::PathBuf;
461    use std::pin::Pin;
462
463    /// Minimal test double for the platform file object.
464    struct MockFile {
465        name: &'static str,
466        size: u64,
467        content_type: Option<&'static str>,
468    }
469
470    impl NativeFileData for MockFile {
471        fn name(&self) -> String {
472            self.name.to_string()
473        }
474        fn size(&self) -> u64 {
475            self.size
476        }
477        fn last_modified(&self) -> u64 {
478            0
479        }
480        fn path(&self) -> PathBuf {
481            PathBuf::new()
482        }
483        fn content_type(&self) -> Option<String> {
484            self.content_type.map(str::to_string)
485        }
486        fn read_bytes(
487            &self,
488        ) -> Pin<Box<dyn std::future::Future<Output = Result<bytes::Bytes, dioxus::CapturedError>>>>
489        {
490            Box::pin(std::future::ready(Ok(bytes::Bytes::new())))
491        }
492        fn byte_stream(
493            &self,
494        ) -> Pin<
495            Box<
496                dyn futures_util::Stream<Item = Result<bytes::Bytes, dioxus::CapturedError>>
497                    + Send
498                    + 'static,
499            >,
500        > {
501            Box::pin(futures_util::stream::empty())
502        }
503        fn read_string(
504            &self,
505        ) -> Pin<Box<dyn std::future::Future<Output = Result<String, dioxus::CapturedError>>>>
506        {
507            Box::pin(std::future::ready(Ok(String::new())))
508        }
509        fn inner(&self) -> &dyn std::any::Any {
510            self
511        }
512    }
513
514    fn file(name: &'static str, size: u64, ct: Option<&'static str>) -> FileData {
515        FileData::new(MockFile {
516            name,
517            size,
518            content_type: ct,
519        })
520    }
521
522    #[test]
523    fn extension_filter_is_case_insensitive() {
524        let f = FileFilter::new().extensions(["png", ".JPG", " gif "]);
525        assert!(f.check(&file("Photo.PNG", 10, None)).is_ok());
526        assert!(f.check(&file("photo.jpg", 10, None)).is_ok());
527        assert!(f.check(&file("clip.GIF", 10, None)).is_ok());
528        assert_eq!(
529            f.check(&file("notes.txt", 10, None)),
530            Err(FileRejection::Extension)
531        );
532        // extension must match at the end, not merely appear
533        assert_eq!(
534            f.check(&file("png.txt", 10, None)),
535            Err(FileRejection::Extension)
536        );
537    }
538
539    #[test]
540    fn content_type_wildcards() {
541        let f = FileFilter::new().content_types(["image/*", "application/pdf"]);
542        assert!(f.check(&file("a", 1, Some("image/webp"))).is_ok());
543        assert!(f.check(&file("b", 1, Some("application/pdf"))).is_ok());
544        assert_eq!(
545            f.check(&file("c", 1, Some("text/plain"))),
546            Err(FileRejection::ContentType)
547        );
548        // missing content type fails a type-restricted filter
549        assert_eq!(
550            f.check(&file("d", 1, None)),
551            Err(FileRejection::ContentType)
552        );
553    }
554
555    #[test]
556    fn content_type_wildcards_match_whole_type_only() {
557        let f = FileFilter::new().content_types(["image/*"]);
558        assert!(f.check(&file("a", 1, Some("image/svg+xml"))).is_ok());
559        assert_eq!(
560            f.check(&file("b", 1, Some("imageevil/png"))),
561            Err(FileRejection::ContentType)
562        );
563        assert_eq!(
564            f.check(&file("c", 1, Some("application/image"))),
565            Err(FileRejection::ContentType)
566        );
567        assert_eq!(
568            f.check(&file("d", 1, Some("image/png/extra"))),
569            Err(FileRejection::ContentType)
570        );
571    }
572
573    #[test]
574    fn content_type_matching_normalizes_case_whitespace_and_parameters() {
575        let f = FileFilter::new().content_types([" Application/PDF ", "text/plain"]);
576        assert!(f.check(&file("a", 1, Some("application/pdf"))).is_ok());
577        assert!(f
578            .check(&file("b", 1, Some("TEXT/PLAIN; charset=utf-8")))
579            .is_ok());
580    }
581
582    #[test]
583    fn content_type_all_wildcard_accepts_any_typed_file() {
584        let f = FileFilter::new().content_types(["*/*"]);
585        assert!(f.check(&file("a", 1, Some("image/png"))).is_ok());
586        assert!(f
587            .check(&file("b", 1, Some("application/octet-stream")))
588            .is_ok());
589        assert_eq!(
590            f.check(&file("c", 1, None)),
591            Err(FileRejection::ContentType)
592        );
593    }
594
595    #[test]
596    fn content_type_structured_suffix_wildcards() {
597        let app_json = FileFilter::new().content_types(["application/*+json"]);
598        assert!(app_json
599            .check(&file("a", 1, Some("application/ld+json")))
600            .is_ok());
601        assert!(app_json
602            .check(&file("b", 1, Some("application/vnd.api+json")))
603            .is_ok());
604        assert_eq!(
605            app_json.check(&file("c", 1, Some("text/ld+json"))),
606            Err(FileRejection::ContentType)
607        );
608        assert_eq!(
609            app_json.check(&file("d", 1, Some("application/json"))),
610            Err(FileRejection::ContentType)
611        );
612
613        let any_json = FileFilter::new().content_types(["*/*+json"]);
614        assert!(any_json
615            .check(&file("e", 1, Some("application/problem+json")))
616            .is_ok());
617        assert!(any_json
618            .check(&file("f", 1, Some("model/gltf+json")))
619            .is_ok());
620        assert_eq!(
621            any_json.check(&file("g", 1, Some("application/json"))),
622            Err(FileRejection::ContentType)
623        );
624    }
625
626    #[test]
627    fn malformed_content_type_patterns_do_not_match() {
628        let f = FileFilter::new().content_types(["image", "image/", "/png", "image/png/extra"]);
629        assert_eq!(
630            f.check(&file("a", 1, Some("image/png"))),
631            Err(FileRejection::ContentType)
632        );
633    }
634
635    #[test]
636    fn unsupported_subtype_only_wildcard_does_not_match() {
637        let f = FileFilter::new().content_types(["*/json"]);
638        assert_eq!(
639            f.check(&file("a", 1, Some("application/json"))),
640            Err(FileRejection::ContentType)
641        );
642        assert_eq!(
643            f.check(&file("b", 1, Some("text/json"))),
644            Err(FileRejection::ContentType)
645        );
646    }
647
648    #[test]
649    fn size_limit() {
650        let f = FileFilter::new().max_size(100);
651        assert!(f.check(&file("ok", 100, None)).is_ok());
652        assert_eq!(
653            f.check(&file("big", 101, None)),
654            Err(FileRejection::TooLarge)
655        );
656    }
657
658    #[test]
659    fn partition_applies_count_after_other_rules() {
660        let f = FileFilter::new().extensions(["png"]).max_files(2);
661        let batch = vec![
662            file("a.png", 1, None),
663            file("b.txt", 1, None), // rejected on extension, doesn't consume a slot
664            file("c.png", 1, None),
665            file("d.png", 1, None), // over the count
666        ];
667        let (ok, bad) = f.partition(batch);
668        assert_eq!(
669            ok.iter().map(|f| f.name()).collect::<Vec<_>>(),
670            vec!["a.png", "c.png"]
671        );
672        assert_eq!(bad.len(), 2);
673        assert_eq!(bad[0].1, FileRejection::Extension);
674        assert_eq!(bad[1].1, FileRejection::TooMany);
675    }
676
677    #[test]
678    fn picker_accept_uses_only_rules_the_dialog_can_represent() {
679        let filter = FileFilter::new()
680            .extensions(["png", ".JPG"])
681            .content_types(["image/*", "application/pdf", "application/*+json", "*/*"]);
682
683        assert_eq!(
684            filter.picker_accept().as_deref(),
685            Some(".png,.jpg,image/*,application/pdf")
686        );
687        assert_eq!(
688            FileFilter::new().max_size(10).picker_accept(),
689            None,
690            "non-picker rules must not create an empty accept restriction"
691        );
692    }
693}