Skip to main content

renox_core/
download.rs

1//! File downloads: `Download`.
2
3use std::path::Path;
4
5use axum::body::{Body, Bytes};
6use axum::http::header::{
7    CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_TYPE, X_CONTENT_TYPE_OPTIONS,
8};
9use axum::http::{HeaderValue, StatusCode};
10use axum::response::{IntoResponse, Response};
11use futures_util::Stream;
12
13use crate::storage::Storage;
14use crate::{Error, Result};
15
16/// Files sent as downloads: bytes made in the handler (a PDF, a CSV),
17/// a file on disk (streamed), a key in `Storage`, or any stream.
18///
19/// ```
20/// # use renox::prelude::*;
21/// use renox::Download;
22///
23/// async fn invoice(State(state): State<AppState>, Path(id): Path<i64>) -> Result<Download> {
24///     let pdf: Vec<u8> = b"%PDF-1.7 ...".to_vec(); // made by your PDF library
25///     Ok(Download::bytes(format!("invoice-{id}.pdf"), "application/pdf", pdf).inline())
26/// }
27///
28/// async fn export(State(state): State<AppState>) -> Result<Download> {
29///     Download::from_storage(&state.storage, "exports/sales.csv", "sales.csv").await
30/// }
31///
32/// async fn backup() -> Result<Download> {
33///     Download::file("storage/backup.db", "backup.db").await // streamed, not read into memory
34/// }
35/// ```
36pub struct Download {
37    filename: String,
38    content_type: String,
39    body: Body,
40    length: Option<u64>,
41    inline: bool,
42}
43
44impl Download {
45    /// `data` as a file named `filename`.
46    pub fn bytes(
47        filename: impl Into<String>,
48        content_type: impl Into<String>,
49        data: impl Into<Bytes>,
50    ) -> Self {
51        let data = data.into();
52        Self {
53            filename: filename.into(),
54            content_type: content_type.into(),
55            length: Some(data.len() as u64),
56            body: Body::from(data),
57            inline: false,
58        }
59    }
60
61    /// A file on disk, streamed; its type comes from its extension. A
62    /// missing file is a 404.
63    pub async fn file(path: impl AsRef<Path>, filename: impl Into<String>) -> Result<Self> {
64        let path = path.as_ref();
65        let file = match tokio::fs::File::open(path).await {
66            Ok(file) => file,
67            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Err(Error::NotFound),
68            Err(err) => {
69                return Err(anyhow::Error::from(err)
70                    .context(format!("could not open {}", path.display()))
71                    .into());
72            }
73        };
74        let length = file.metadata().await.ok().map(|m| m.len());
75        let filename = filename.into();
76        Ok(Self {
77            content_type: guess(&filename, path),
78            filename,
79            length,
80            body: Body::from_stream(tokio_util::io::ReaderStream::new(file)),
81            inline: false,
82        })
83    }
84
85    /// A file in `Storage` (local disk or S3). A missing key is a 404.
86    pub async fn from_storage(
87        storage: &Storage,
88        key: &str,
89        filename: impl Into<String>,
90    ) -> Result<Self> {
91        let bytes = storage.get(key).await?.ok_or(Error::NotFound)?;
92        let filename = filename.into();
93        let content_type = guess(&filename, Path::new(key));
94        Ok(Self::bytes(filename, content_type, bytes))
95    }
96
97    /// Any stream of bytes, e.g. a CSV written row by row as the database
98    /// hands them over.
99    pub fn stream<S, E>(
100        filename: impl Into<String>,
101        content_type: impl Into<String>,
102        stream: S,
103    ) -> Self
104    where
105        S: Stream<Item = std::result::Result<Bytes, E>> + Send + 'static,
106        E: Into<axum::BoxError>,
107    {
108        Self {
109            filename: filename.into(),
110            content_type: content_type.into(),
111            length: None,
112            body: Body::from_stream(stream),
113            inline: false,
114        }
115    }
116
117    /// Shown in the browser (a PDF, an image) instead of saved. HTML, XML and
118    /// JavaScript are always downloads, so a file can't run as a page of
119    /// the app.
120    pub fn inline(mut self) -> Self {
121        self.inline = true;
122        self
123    }
124}
125
126/// The type for `filename`'s extension, else `path`'s, else binary.
127fn guess(filename: &str, path: &Path) -> String {
128    mime_guess::from_path(filename)
129        .first()
130        .or_else(|| mime_guess::from_path(path).first())
131        .map_or_else(|| "application/octet-stream".to_owned(), |m| m.to_string())
132}
133
134/// `attachment; filename="plain.pdf"; filename*=UTF-8''%C3%BCnic%C3%B6de.pdf`
135fn disposition(filename: &str, inline: bool) -> String {
136    let ascii: String = filename
137        .chars()
138        .map(|c| {
139            if c.is_ascii_graphic() && c != '"' && c != '\\' || c == ' ' {
140                c
141            } else {
142                '_'
143            }
144        })
145        .collect();
146    let mut encoded = String::new();
147    crate::routing::encode(&mut encoded, filename, false);
148    let kind = if inline { "inline" } else { "attachment" };
149    format!("{kind}; filename=\"{ascii}\"; filename*=UTF-8''{encoded}")
150}
151
152impl IntoResponse for Download {
153    fn into_response(self) -> Response {
154        let active = {
155            let t = self.content_type.to_ascii_lowercase();
156            t.contains("html") || t.contains("xml") || t.contains("javascript")
157        };
158        let mut res = (StatusCode::OK, self.body).into_response();
159        let headers = res.headers_mut();
160        if let Ok(value) = HeaderValue::from_str(&self.content_type) {
161            headers.insert(CONTENT_TYPE, value);
162        }
163        if let Ok(value) =
164            HeaderValue::from_str(&disposition(&self.filename, self.inline && !active))
165        {
166            headers.insert(CONTENT_DISPOSITION, value);
167        }
168        headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
169        if let Some(length) = self.length {
170            headers.insert(CONTENT_LENGTH, length.into());
171        }
172        res
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn filenames_are_safe_in_the_header() {
182        assert_eq!(
183            disposition("faktur \"Mei\".pdf", false),
184            "attachment; filename=\"faktur _Mei_.pdf\"; filename*=UTF-8''faktur%20%22Mei%22.pdf"
185        );
186        assert!(
187            disposition("laporan\r\nX: y.pdf", true)
188                .starts_with("inline; filename=\"laporan__X: y.pdf\"")
189        );
190        assert!(disposition("ünï.pdf", false).ends_with("filename*=UTF-8''%C3%BCn%C3%AF.pdf"));
191    }
192
193    /// A missing file is a 404; a path that can't be opened for another
194    /// reason (here a file used as a folder) is an error naming the path.
195    #[tokio::test]
196    async fn files_that_cant_be_opened() {
197        let dir = tempfile::tempdir().unwrap();
198        assert!(matches!(
199            Download::file(dir.path().join("gone.pdf"), "gone.pdf").await,
200            Err(Error::NotFound)
201        ));
202        // (Windows reports this one as "not found".)
203        #[cfg(unix)]
204        {
205            let file = dir.path().join("report.pdf");
206            std::fs::write(&file, b"%PDF-").unwrap();
207            let Err(err) = Download::file(file.join("inside.pdf"), "x.pdf").await else {
208                panic!("opened a file inside a file");
209            };
210            assert!(
211                format!("{err:?}").contains("could not open")
212                    && format!("{err:?}").contains("report.pdf"),
213                "{err:?}"
214            );
215        }
216    }
217}