Skip to main content

dioxus_dnd/
files.rs

1//! OS file drops - the one drop type where the payload arrives *in the native
2//! event* (`evt.files()`) rather than through the shared context.
3//!
4//! Works on web and desktop. On desktop, [`dioxus::html::FileData::path`]
5//! gives you the real filesystem path; on web you read contents with
6//! `read_bytes()` / `read_string()` / `byte_stream()`.
7//!
8//! ```text
9//! rsx! {
10//!     FileDropZone {
11//!         filter: FileFilter::new().extensions(["png", "jpg"]).max_size(5_000_000),
12//!         on_files: move |drop: FileDrop| async move {
13//!             for f in drop.files {
14//!                 let bytes = f.read_bytes().await.unwrap();
15//!                 // ...
16//!             }
17//!         },
18//!         "Drop images here"
19//!     }
20//! }
21//! ```
22
23use dioxus::html::{FileData, HasFileData};
24use dioxus::prelude::*;
25
26use crate::core::{client_point, element_point, Point};
27
28/// A batch of dropped files plus where they landed.
29#[derive(Clone, PartialEq)]
30pub struct FileDrop {
31    pub files: Vec<FileData>,
32    /// Pointer position in client (viewport) coordinates.
33    pub client: Point,
34    /// Pointer position relative to the drop zone element.
35    pub element: Point,
36}
37
38/// Why a file was rejected by a [`FileFilter`].
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum FileRejection {
41    /// Extension not in the allow-list.
42    Extension,
43    /// MIME type not in the allow-list.
44    ContentType,
45    /// Larger than `max_size` bytes.
46    TooLarge,
47    /// Batch exceeded `max_files`; this file was over the limit.
48    TooMany,
49}
50
51/// Declarative acceptance rules for dropped files.
52///
53/// **Advisory, not a security boundary.** These rules match on the browser-
54/// and OS-reported name, content type and size, all of which are
55/// attacker-controllable: a `.exe` can be renamed `photo.png` and report
56/// `content_type: "image/png"`, and `size` is self-reported. Use the filter
57/// for UX (rejecting obviously wrong drops early), but validate the actual
58/// bytes server-side or via content sniffing before trusting a file.
59#[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    /// Allow only these extensions (case-insensitive, leading dot optional).
73    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    /// Allow only these MIME types.
87    ///
88    /// Supported patterns:
89    ///
90    /// - exact types: `"application/pdf"`
91    /// - top-level wildcards: `"image/*"`
92    /// - all typed files: `"*/*"`
93    /// - structured suffix wildcards: `"application/*+json"` and `"*/*+json"`
94    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    /// Reject files larger than this many bytes.
108    pub fn max_size(mut self, bytes: u64) -> Self {
109        self.max_size = Some(bytes);
110        self
111    }
112
113    /// Accept at most this many files per drop.
114    pub fn max_files(mut self, n: usize) -> Self {
115        self.max_files = Some(n);
116        self
117    }
118
119    /// Check a single file against the rules (ignores `max_files`).
120    pub fn check(&self, file: &FileData) -> Result<(), FileRejection> {
121        if !self.extensions.is_empty() {
122            // ASCII-lowercase to match `normalize_extension`; a full Unicode
123            // `to_lowercase()` here could case-fold a non-ASCII filename
124            // differently from the (ASCII-lowered) extension and spuriously
125            // mismatch. File extensions are ASCII in practice.
126            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    /// Split a batch into `(accepted, rejected)` applying every rule.
157    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/// A zone that accepts files dragged in from the operating system.
231///
232/// Independent of `DndContext` - file drops don't come from inside your app,
233/// so no provider is required.
234///
235/// While a drag hovers the zone the div carries `data-over="true"` (absent
236/// otherwise), so the classic "highlight the dropzone" style needs no
237/// `on_hover` wiring: Tailwind `data-over:border-blue-500`, CSS
238/// `[data-over]`.
239#[component]
240pub fn FileDropZone(
241    /// Acceptance rules; everything is accepted when omitted.
242    #[props(default)]
243    filter: Option<FileFilter>,
244    /// Fired with the accepted files of a drop (only if at least one passed).
245    on_files: EventHandler<FileDrop>,
246    /// Fired with the rejected files of a drop, if any.
247    #[props(default)]
248    on_rejected: Option<EventHandler<Vec<(FileData, FileRejection)>>>,
249    /// Fired with `true` when a drag hovers the zone, `false` when it leaves.
250    #[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                // Required: without preventDefault the browser never delivers
262                // the drop (it would open the file instead).
263                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    /// Minimal test double for the platform file object.
325    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        // extension must match at the end, not merely appear
394        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        // missing content type fails a type-restricted filter
410        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), // rejected on extension, doesn't consume a slot
525            file("c.png", 1, None),
526            file("d.png", 1, None), // over the count
527        ];
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}