Skip to main content

a3s_boot/http/
streamable_file.rs

1use crate::{BootError, Result};
2use futures_core::Stream;
3use std::fmt;
4use std::pin::Pin;
5
6pub type StreamableFileStream = Pin<Box<dyn Stream<Item = Result<Vec<u8>>> + Send + 'static>>;
7
8/// Options for Nest-style streamable file responses.
9#[derive(Debug, Clone, Default, PartialEq, Eq)]
10pub struct StreamableFileOptions {
11    content_type: Option<String>,
12    content_disposition: Option<String>,
13    content_length: Option<u64>,
14}
15
16impl StreamableFileOptions {
17    pub fn new() -> Self {
18        Self::default()
19    }
20
21    pub fn with_content_type(mut self, content_type: impl Into<String>) -> Self {
22        self.content_type = Some(content_type.into());
23        self
24    }
25
26    pub fn with_content_disposition(mut self, content_disposition: impl Into<String>) -> Self {
27        self.content_disposition = Some(content_disposition.into());
28        self
29    }
30
31    pub fn with_attachment(self, file_name: impl AsRef<str>) -> Result<Self> {
32        Ok(self.with_content_disposition(content_disposition("attachment", file_name.as_ref())?))
33    }
34
35    pub fn with_inline(self, file_name: impl AsRef<str>) -> Result<Self> {
36        Ok(self.with_content_disposition(content_disposition("inline", file_name.as_ref())?))
37    }
38
39    pub fn with_content_length(mut self, content_length: u64) -> Self {
40        self.content_length = Some(content_length);
41        self
42    }
43
44    pub fn content_type(&self) -> Option<&str> {
45        self.content_type.as_deref()
46    }
47
48    pub fn content_disposition(&self) -> Option<&str> {
49        self.content_disposition.as_deref()
50    }
51
52    pub fn content_length(&self) -> Option<u64> {
53        self.content_length
54    }
55}
56
57/// Adapter-neutral file response body, similar to Nest's `StreamableFile`.
58pub struct StreamableFile {
59    body: StreamableFileBody,
60    options: StreamableFileOptions,
61}
62
63impl StreamableFile {
64    pub fn bytes(body: impl Into<Vec<u8>>) -> Self {
65        Self {
66            body: StreamableFileBody::Bytes(body.into()),
67            options: StreamableFileOptions::default(),
68        }
69    }
70
71    pub fn stream<S>(stream: S) -> Self
72    where
73        S: Stream<Item = Result<Vec<u8>>> + Send + 'static,
74    {
75        Self {
76            body: StreamableFileBody::Stream(Box::pin(stream)),
77            options: StreamableFileOptions::default(),
78        }
79    }
80
81    pub fn with_options(mut self, options: StreamableFileOptions) -> Self {
82        self.options = options;
83        self
84    }
85
86    pub fn with_content_type(mut self, content_type: impl Into<String>) -> Self {
87        self.options = self.options.with_content_type(content_type);
88        self
89    }
90
91    pub fn with_content_disposition(mut self, content_disposition: impl Into<String>) -> Self {
92        self.options = self.options.with_content_disposition(content_disposition);
93        self
94    }
95
96    pub fn with_attachment(mut self, file_name: impl AsRef<str>) -> Result<Self> {
97        self.options = self.options.with_attachment(file_name)?;
98        Ok(self)
99    }
100
101    pub fn with_inline(mut self, file_name: impl AsRef<str>) -> Result<Self> {
102        self.options = self.options.with_inline(file_name)?;
103        Ok(self)
104    }
105
106    pub fn with_content_length(mut self, content_length: u64) -> Self {
107        self.options = self.options.with_content_length(content_length);
108        self
109    }
110
111    pub fn options(&self) -> &StreamableFileOptions {
112        &self.options
113    }
114
115    pub(crate) fn into_parts(self) -> (StreamableFileBody, StreamableFileOptions) {
116        (self.body, self.options)
117    }
118}
119
120impl fmt::Debug for StreamableFile {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        let body = match &self.body {
123            StreamableFileBody::Bytes(bytes) => {
124                return f
125                    .debug_struct("StreamableFile")
126                    .field("body", &format_args!("{} bytes", bytes.len()))
127                    .field("options", &self.options)
128                    .finish();
129            }
130            StreamableFileBody::Stream(_) => "stream",
131        };
132
133        f.debug_struct("StreamableFile")
134            .field("body", &body)
135            .field("options", &self.options)
136            .finish()
137    }
138}
139
140pub(crate) enum StreamableFileBody {
141    Bytes(Vec<u8>),
142    Stream(StreamableFileStream),
143}
144
145fn content_disposition(disposition_type: &str, file_name: &str) -> Result<String> {
146    validate_file_name(file_name)?;
147    let quoted = quote_file_name(&ascii_file_name_fallback(file_name));
148    let mut value = format!("{disposition_type}; filename=\"{quoted}\"");
149    if !file_name.is_ascii() || quoted != file_name {
150        value.push_str("; filename*=UTF-8''");
151        value.push_str(&percent_encode_attr(file_name));
152    }
153    Ok(value)
154}
155
156fn validate_file_name(file_name: &str) -> Result<()> {
157    if file_name.is_empty() {
158        return Err(BootError::Internal("file name cannot be empty".to_string()));
159    }
160
161    if file_name
162        .bytes()
163        .any(|byte| byte == b'\r' || byte == b'\n' || byte == 0)
164    {
165        return Err(BootError::Internal(
166            "file name contains invalid characters".to_string(),
167        ));
168    }
169
170    Ok(())
171}
172
173fn ascii_file_name_fallback(file_name: &str) -> String {
174    let value = file_name
175        .chars()
176        .map(|character| {
177            if character.is_ascii()
178                && !character.is_ascii_control()
179                && !matches!(character, '"' | '\\' | '/' | ';')
180            {
181                character
182            } else {
183                '_'
184            }
185        })
186        .collect::<String>()
187        .trim_matches(|character: char| character == '_' || character.is_ascii_whitespace())
188        .to_string();
189
190    if value.is_empty() {
191        "download".to_string()
192    } else {
193        value
194    }
195}
196
197fn quote_file_name(file_name: &str) -> String {
198    file_name
199        .replace('\\', "\\\\")
200        .replace('"', "\\\"")
201        .to_string()
202}
203
204fn percent_encode_attr(value: &str) -> String {
205    let mut encoded = String::new();
206    for byte in value.bytes() {
207        if is_attr_char(byte) {
208            encoded.push(char::from(byte));
209        } else {
210            encoded.push_str(&format!("%{byte:02X}"));
211        }
212    }
213    encoded
214}
215
216fn is_attr_char(byte: u8) -> bool {
217    matches!(
218        byte,
219        b'A'..=b'Z'
220            | b'a'..=b'z'
221            | b'0'..=b'9'
222            | b'!'
223            | b'#'
224            | b'$'
225            | b'&'
226            | b'+'
227            | b'-'
228            | b'.'
229            | b'^'
230            | b'_'
231            | b'`'
232            | b'|'
233            | b'~'
234    )
235}