Skip to main content

objectstore_server/extractors/
response_options.rs

1//! Presentation options for successful individual-object downloads.
2//!
3//! The request extractor validates these options without modifying the signed URI. Handlers
4//! attach their typed headers only to successful GET/HEAD responses, including partial content.
5//! Error responses retain their existing headers.
6//!
7//! # Query options
8//!
9//! - `response-content-disposition`: exactly `inline` or `attachment` (the default). The stored
10//!   filename is retained and formatted using the existing download filename rules.
11//!
12//! Parameter names are case-insensitive, matching pre-signed URL canonicalization. Duplicate
13//! supported options and invalid values are rejected with 400. Unrelated parameters are ignored
14//! by this extractor and remain covered by the existing signature scheme.
15//!
16//! Content type and content encoding always come from stored metadata. Content-language and
17//! expires overrides are not supported. No MIME allowlist is applied to inline responses.
18
19use 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/// Whether a browser should download an object or display it inline.
32#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
33#[serde(rename_all = "lowercase")]
34pub enum ContentDisposition {
35    /// Download the object (the default).
36    #[default]
37    Attachment,
38    /// Display the object with browser sandboxing enabled.
39    Inline,
40}
41
42impl ContentDisposition {
43    /// Returns the disposition's HTTP header value.
44    pub fn as_str(self) -> &'static str {
45        match self {
46            Self::Attachment => "attachment",
47            Self::Inline => "inline",
48        }
49    }
50
51    /// Formats the selected disposition with a stored filename.
52    ///
53    /// The filename is sanitized (`/` and `\` become `-`, dots-only names become all dashes, non-ASCII
54    /// and control characters become `_`) and then escaped for the RFC 6266 quoted-string (`"` is
55    /// backslash-escaped).
56    ///
57    /// A filename that is not pure ASCII cannot be represented in that quoted-string, so it
58    /// additionally gets an RFC 8187 `filename*` parameter carrying the full UTF-8 value. The
59    /// quoted-string then serves as the ASCII fallback for clients that ignore `filename*`.
60    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        // INVARIANT: every character written above is visible ASCII — the quoted-string replaces
85        // non-ASCII and control characters with `_`, and the `ext-value` is percent-encoded.
86        HeaderValue::from_str(&result).expect("content disposition is a valid header value")
87    }
88}
89
90/// Validated presentation options for successful object downloads.
91///
92/// Disposition defaults to attachment. Stored MIME type and encoding are never overridden,
93/// and inline content is not restricted by a MIME allowlist.
94#[derive(Debug, Default, Deserialize)]
95pub struct ResponseOptions {
96    /// Selected browser disposition, defaulting to attachment.
97    #[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        // Query<ResponseOptions> matches names case-sensitively. Normalize them to match
106        // pre-signed URL canonicalization without modifying the original URI.
107        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}