dioxus_fullstack/payloads/
files.rs

1use super::*;
2use axum_core::extract::Request;
3use dioxus_fullstack_core::RequestError;
4use dioxus_html::FileData;
5
6#[cfg(feature = "server")]
7use std::path::Path;
8use std::{
9    pin::Pin,
10    task::{Context, Poll},
11};
12
13/// A payload for uploading files using streams.
14///
15/// The `FileUpload` struct allows you to upload files by streaming their data. It can be constructed
16/// from a stream of bytes and can be sent as part of an HTTP request. This is particularly useful for
17/// handling large files without loading them entirely into memory.
18///
19/// On the web, this uses the `ReadableStream` API to stream file data.
20pub struct FileStream {
21    data: Option<FileData>,
22    name: String,
23    size: Option<u64>,
24    content_type: Option<String>,
25    #[cfg(feature = "server")]
26    server_body: Option<axum_core::body::BodyDataStream>,
27
28    // For downloaded files...
29    #[allow(clippy::type_complexity)]
30    client_body: Option<Pin<Box<dyn Stream<Item = Result<Bytes, StreamingError>> + Send>>>,
31}
32
33impl FileStream {
34    /// Get the name of the file.
35    pub fn file_name(&self) -> &str {
36        &self.name
37    }
38
39    /// Get the size of the file, if known.
40    pub fn size(&self) -> Option<u64> {
41        self.size
42    }
43
44    /// Get the content type of the file, if available.
45    pub fn content_type(&self) -> Option<&str> {
46        self.content_type.as_deref()
47    }
48
49    /// Return the underlying body stream, assuming the `FileStream` was created by a server request.
50    #[cfg(feature = "server")]
51    pub fn body_mut(&mut self) -> Option<&mut axum_core::body::BodyDataStream> {
52        self.server_body.as_mut()
53    }
54
55    /// Create a new `FileStream` from a file path. This is only available on the server.
56    #[cfg(feature = "server")]
57    pub async fn from_path(file: impl AsRef<Path>) -> Result<Self, std::io::Error> {
58        Self::from_path_buf(file.as_ref()).await
59    }
60
61    #[cfg(feature = "server")]
62    async fn from_path_buf(file: &Path) -> Result<Self, std::io::Error> {
63        let metadata = file.metadata()?;
64        let contents = tokio::fs::File::open(&file).await?;
65        let mime = dioxus_asset_resolver::native::get_mime_from_ext(
66            file.extension().and_then(|s| s.to_str()),
67        );
68        let size = metadata.len();
69        let name = file
70            .file_name()
71            .and_then(|s| s.to_str())
72            .unwrap_or("file")
73            .to_string();
74
75        // Convert the tokio file into an async byte stream
76        let reader_stream = tokio_util::io::ReaderStream::new(contents);
77
78        // Attempt to construct a BodyDataStream from the reader stream.
79        // Many axum_core versions provide a `from_stream` or similar constructor.
80        let body = axum_core::body::Body::from_stream(reader_stream).into_data_stream();
81
82        Ok(Self {
83            data: None,
84            name,
85            size: Some(size),
86            content_type: Some(mime.to_string()),
87            #[cfg(feature = "server")]
88            server_body: Some(body),
89            client_body: None,
90        })
91    }
92
93    /// Create a new `FileStream` from raw components.
94    ///
95    /// This is meant to be used on the server where a file might not even exist but you still want
96    /// to stream it to the client as a download.
97    #[cfg(feature = "server")]
98    pub fn from_raw(
99        name: String,
100        size: Option<u64>,
101        content_type: String,
102        body: axum_core::body::BodyDataStream,
103    ) -> Self {
104        Self {
105            data: None,
106            name,
107            size,
108            content_type: Some(content_type),
109            #[cfg(feature = "server")]
110            server_body: Some(body),
111            client_body: None,
112        }
113    }
114}
115
116impl IntoRequest for FileStream {
117    #[allow(unreachable_code)]
118    fn into_request(
119        self,
120        #[allow(unused_mut)] mut builder: ClientRequest,
121    ) -> impl Future<Output = ClientResult> + 'static {
122        async move {
123            let Some(file_data) = self.data else {
124                return Err(RequestError::Request(
125                    "FileStream has no data to send".into(),
126                ));
127            };
128
129            #[cfg(feature = "web")]
130            if cfg!(target_arch = "wasm32") {
131                use js_sys::escape;
132                use wasm_bindgen::JsCast;
133
134                let as_file = file_data.inner().downcast_ref::<web_sys::File>().unwrap();
135                let as_blob = as_file.dyn_ref::<web_sys::Blob>().unwrap();
136                let content_type = as_blob.type_();
137                let content_length = as_blob.size().to_string();
138                let name = as_file.name();
139
140                // Set both Content-Length and X-Content-Size for compatibility with server extraction.
141                // In browsers, content-length is often overwritten, so we set X-Content-Size as well
142                // for better compatibility with dioxus-based clients.
143                return builder
144                    .header("Content-Type", content_type)?
145                    .header("Content-Length", content_length.clone())?
146                    .header("X-Content-Size", content_length)?
147                    .header(
148                        "Content-Disposition",
149                        format!("attachment; filename=\"{}\"", escape(&name)),
150                    )?
151                    .send_js_value(as_blob.clone().into())
152                    .await;
153            }
154
155            #[cfg(not(target_arch = "wasm32"))]
156            {
157                use std::ascii::escape_default;
158
159                use futures::TryStreamExt;
160
161                let content_type = self
162                    .content_type
163                    .unwrap_or_else(|| "application/octet-stream".to_string());
164                let content_length = self.size.map(|s| s.to_string());
165                let name = self.name;
166                let stream = file_data.byte_stream().map_err(|_| StreamingError::Failed);
167
168                // Ascii escape the filename to avoid issues with special characters.
169                let mut chars = vec![];
170                for byte in name.chars() {
171                    chars.extend(escape_default(byte as u8));
172                }
173                let filename = String::from_utf8(chars).map_err(|_| {
174                    RequestError::Request(
175                        "Failed to escape filename for Content-Disposition".into(),
176                    )
177                });
178
179                if let Some(length) = content_length {
180                    builder = builder.header("Content-Length", length)?;
181                }
182
183                if let Ok(filename) = filename {
184                    builder = builder.header(
185                        "Content-Disposition",
186                        format!("attachment; filename=\"{}\"", filename),
187                    )?;
188                }
189
190                return builder
191                    .header("Content-Type", content_type)?
192                    .send_body_stream(stream)
193                    .await;
194            }
195
196            unimplemented!("FileStream::into_request is only implemented for web targets");
197        }
198    }
199}
200
201impl<S> FromRequest<S> for FileStream {
202    type Rejection = ServerFnError;
203
204    fn from_request(
205        req: Request,
206        _: &S,
207    ) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
208        async move {
209            tracing::info!("Extracting FileUpload from request: {:?}", req);
210
211            let disposition = req.headers().get("Content-Disposition");
212            let filename = match disposition.map(|s| s.to_str()) {
213                Some(Ok(dis)) => {
214                    let content = content_disposition::parse_content_disposition(dis);
215                    content
216                        .filename_full()
217                        .unwrap_or_else(|| "file".to_string())
218                }
219                _ => "file".to_string(),
220            };
221
222            // Content-length is unreliable, so we use `X-Content-Size` as an indicator.
223            // For stream requests with known bodies, the browser will still set Content-Length to 0, unfortunately.
224            let size = req
225                .headers()
226                .get("X-Content-Size")
227                .and_then(|s| s.to_str().ok())
228                .and_then(|s| s.parse::<u64>().ok());
229
230            let content_type = req
231                .headers()
232                .get("Content-Type")
233                .and_then(|s| s.to_str().ok())
234                .map(|s| s.to_string());
235
236            Ok(FileStream {
237                data: None,
238                name: filename,
239                content_type,
240                size,
241                client_body: None,
242                #[cfg(feature = "server")]
243                server_body: Some(req.into_body().into_data_stream()),
244            })
245        }
246    }
247}
248
249impl FromResponse for FileStream {
250    fn from_response(res: ClientResponse) -> impl Future<Output = Result<Self, ServerFnError>> {
251        async move {
252            // Check status code first - don't try to stream error responses as files
253            if !res.status().is_success() {
254                let status_code = res.status().as_u16();
255                let canonical_reason = res
256                    .status()
257                    .canonical_reason()
258                    .unwrap_or("Unknown error")
259                    .to_string();
260                let bytes = res.bytes().await.unwrap_or_default();
261                let message = String::from_utf8(bytes.to_vec()).unwrap_or(canonical_reason);
262
263                return Err(ServerFnError::ServerError {
264                    message,
265                    code: status_code,
266                    details: None,
267                });
268            }
269
270            // Extract filename from Content-Disposition header if present.
271            let name = res
272                .headers()
273                .get("Content-Disposition")
274                .and_then(|h| h.to_str().ok())
275                .and_then(|dis| {
276                    let cd = content_disposition::parse_content_disposition(dis);
277                    cd.filename().map(|(name, _)| name.to_string())
278                })
279                .unwrap_or_else(|| "file".to_string());
280
281            // Extract content type header
282            let content_type = res
283                .headers()
284                .get("Content-Type")
285                .and_then(|h| h.to_str().ok())
286                .map(|s| s.to_string());
287
288            // Prefer the response's known content length but fall back to X-Content-Size header.
289            let size = res.content_length().or_else(|| {
290                res.headers()
291                    .get("X-Content-Size")
292                    .and_then(|h| h.to_str().ok())
293                    .and_then(|s| s.parse::<u64>().ok())
294            });
295
296            Ok(Self {
297                data: None,
298                name,
299                size,
300                content_type,
301                client_body: Some(Box::pin(res.bytes_stream())),
302                #[cfg(feature = "server")]
303                server_body: None,
304            })
305        }
306    }
307}
308
309#[cfg(feature = "server")]
310impl IntoResponse for FileStream {
311    fn into_response(self) -> axum::response::Response {
312        use axum::body::Body;
313
314        let Some(body) = self.server_body else {
315            use dioxus_fullstack_core::HttpError;
316            return HttpError::new(http::StatusCode::BAD_REQUEST, "FileStream has no body")
317                .into_response();
318        };
319
320        let mut res = axum::response::Response::new(Body::from_stream(body));
321
322        // Set relevant headers if available
323        if let Some(content_type) = &self.content_type {
324            res.headers_mut()
325                .insert("Content-Type", content_type.parse().unwrap());
326        }
327        if let Some(size) = self.size {
328            res.headers_mut()
329                .insert("Content-Length", size.to_string().parse().unwrap());
330        }
331        res.headers_mut().insert(
332            "Content-Disposition",
333            format!("attachment; filename=\"{}\"", self.name)
334                .parse()
335                .unwrap(),
336        );
337
338        res
339    }
340}
341
342impl From<FileData> for FileStream {
343    fn from(value: FileData) -> Self {
344        Self {
345            name: value.name().to_string(),
346            content_type: value.content_type().map(|s| s.to_string()),
347            size: Some(value.size()),
348            data: Some(value),
349            client_body: None,
350            #[cfg(feature = "server")]
351            server_body: None,
352        }
353    }
354}
355
356impl Stream for FileStream {
357    type Item = Result<Bytes, StreamingError>;
358
359    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
360        // For server-side builds, poll the server_body stream if it exists.
361        #[cfg(feature = "server")]
362        if let Some(body) = self.server_body.as_mut() {
363            return Pin::new(body)
364                .poll_next(cx)
365                .map_err(|_| StreamingError::Failed);
366        }
367
368        // For client-side builds, poll the client_body stream if it exists.
369        if let Some(body) = self.client_body.as_mut() {
370            return body.as_mut().poll_next(cx);
371        }
372
373        // Otherwise, the stream is exhausted.
374        Poll::Ready(None)
375    }
376}
377
378impl std::fmt::Debug for FileStream {
379    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
380        f.debug_struct("FileStream")
381            .field("name", &self.name)
382            .field("size", &self.size)
383            .field("content_type", &self.content_type)
384            .finish()
385    }
386}