1use 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
16pub struct Download {
37 filename: String,
38 content_type: String,
39 body: Body,
40 length: Option<u64>,
41 inline: bool,
42}
43
44impl Download {
45 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 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 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 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 pub fn inline(mut self) -> Self {
121 self.inline = true;
122 self
123 }
124}
125
126fn 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
134fn 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 #[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 #[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}