objectstore_server/extractors/
response_options.rs1use std::fmt::Write as _;
20
21use axum::extract::FromRequestParts;
22use http::HeaderValue;
23use http::request::Parts;
24use objectstore_types::headers::ExtValue;
25use serde::Deserialize;
26use serde::de::value::{Error, MapDeserializer};
27use url::form_urlencoded;
28
29use crate::endpoints::common::ApiError;
30
31#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
33#[serde(rename_all = "lowercase")]
34pub enum ContentDisposition {
35 #[default]
37 Attachment,
38 Inline,
40}
41
42impl ContentDisposition {
43 pub fn as_str(self) -> &'static str {
45 match self {
46 Self::Attachment => "attachment",
47 Self::Inline => "inline",
48 }
49 }
50
51 pub fn with_filename(self, filename: &str) -> HeaderValue {
61 let all_dots = filename.chars().all(|c| c == '.');
62
63 let mut result = format!("{}; filename=\"", self.as_str());
64 for c in filename.chars() {
65 let c = match c {
66 '/' | '\\' => '-',
67 '.' if all_dots => '-',
68 '"' => {
69 result.push('\\');
70 '"'
71 }
72 c if !c.is_ascii() || c.is_control() => '_',
73 c => c,
74 };
75 result.push(c);
76 }
77 result.push('"');
78
79 if !filename.is_ascii() {
80 write!(result, "; filename*={}", ExtValue(filename))
81 .expect("writing to a string cannot fail");
82 }
83
84 HeaderValue::from_str(&result).expect("content disposition is a valid header value")
87 }
88}
89
90#[derive(Debug, Default, Deserialize)]
95pub struct ResponseOptions {
96 #[serde(default, rename = "response-content-disposition")]
98 pub disposition: ContentDisposition,
99}
100
101impl<S: Send + Sync> FromRequestParts<S> for ResponseOptions {
102 type Rejection = ApiError;
103
104 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
105 let pairs = form_urlencoded::parse(parts.uri.query().unwrap_or_default().as_bytes())
108 .map(|(name, value)| (name.to_ascii_lowercase(), value.into_owned()));
109
110 Self::deserialize(MapDeserializer::<_, Error>::new(pairs))
111 .map_err(|error| ApiError::map_client("invalid response options", error))
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use axum::extract::FromRequestParts;
118 use http::Request;
119
120 use crate::extractors::response_options::{ContentDisposition, ResponseOptions};
121
122 #[tokio::test]
123 async fn decodes_options_without_changing_signed_uri() {
124 let uri = "/object?unknown=b&unknown=a&%72esponse-content-disposition=inline";
125 let (mut parts, ()) = Request::builder().uri(uri).body(()).unwrap().into_parts();
126 let options = ResponseOptions::from_request_parts(&mut parts, &())
127 .await
128 .unwrap();
129 assert_eq!(options.disposition, ContentDisposition::Inline);
130 assert_eq!(parts.uri.to_string(), uri);
131 }
132
133 #[tokio::test]
134 async fn defaults_preserve_unrelated_parameters() {
135 for uri in ["/object", "/object?unknown=b&unknown=a&flag&&"] {
136 let (mut parts, ()) = Request::builder().uri(uri).body(()).unwrap().into_parts();
137 let options = ResponseOptions::from_request_parts(&mut parts, &())
138 .await
139 .unwrap();
140 assert_eq!(options.disposition, ContentDisposition::Attachment);
141 }
142 }
143}