Skip to main content

kellnr_settings/
origin.rs

1use std::fmt;
2
3use provcfg::{ClapArgs, Configurable};
4use serde::{Deserialize, Serialize};
5use utoipa::ToSchema;
6
7use crate::protocol::Protocol;
8
9fn default_hostname() -> String {
10    "127.0.0.1".to_string()
11}
12
13fn default_origin_port() -> u16 {
14    std::env::var("KELLNR_ORIGIN__PORT")
15        .ok()
16        .and_then(|v| v.parse::<u16>().ok())
17        .unwrap_or(8000)
18}
19
20#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, Clone, Configurable, ClapArgs, ToSchema)]
21#[serde(default)]
22#[configurable(clap_prefix = "origin")]
23pub struct Origin {
24    /// External hostname for URLs
25    pub hostname: String,
26
27    /// External port for URLs
28    pub port: u16,
29
30    /// Protocol (http or https)
31    pub protocol: Protocol,
32
33    /// URL path prefix
34    pub path: String,
35
36    /// Force the `Secure` flag on cookies (true) or never set it (false).
37    /// Unset, cookies are secure only when `protocol` is https. Set it to true
38    /// when Kellnr is reached over https through a TLS-terminating proxy but
39    /// `protocol` is http
40    pub cookie_secure: Option<bool>,
41}
42
43impl Default for Origin {
44    fn default() -> Self {
45        Self {
46            hostname: default_hostname(),
47            port: default_origin_port(),
48            protocol: Protocol::Http,
49            path: String::new(),
50            cookie_secure: None,
51        }
52    }
53}
54
55impl Origin {
56    /// Whether cookies must carry the `Secure` flag.
57    pub fn secure_cookies(&self) -> bool {
58        self.cookie_secure
59            .unwrap_or(self.protocol == Protocol::Https)
60    }
61
62    /// The URL path below which Kellnr is served.
63    pub fn path_prefix(&self) -> UrlPathPrefix {
64        UrlPathPrefix::new(&self.path)
65    }
66
67    /// Brings `path` into the form of [`UrlPathPrefix`]. Applied once when
68    /// the settings are loaded, so the settings show the path in use.
69    pub fn normalize(&mut self) {
70        self.path = self.path_prefix().0;
71    }
72
73    /// External URL of Kellnr without a trailing slash. The port is left out
74    /// if it is the default port of the protocol.
75    pub fn base_url(&self) -> String {
76        let default_port = match self.protocol {
77            Protocol::Http => 80,
78            Protocol::Https => 443,
79        };
80        let path = self.path_prefix();
81        if self.port == default_port {
82            format!("{}://{}{path}", self.protocol, self.hostname)
83        } else {
84            format!("{}://{}:{}{path}", self.protocol, self.hostname, self.port)
85        }
86    }
87}
88
89/// URL path prefix of Kellnr: empty for the root, otherwise `/prefix` with
90/// one leading and no trailing slash, so callers can append `/...` to it.
91#[derive(Debug, Clone, Default, PartialEq, Eq)]
92pub struct UrlPathPrefix(String);
93
94impl UrlPathPrefix {
95    /// Accepts the path with or without leading and trailing slashes and
96    /// surrounding whitespace.
97    pub fn new(path: &str) -> Self {
98        let path = path.trim().trim_matches('/');
99        if path.is_empty() {
100            Self::default()
101        } else {
102            Self(format!("/{path}"))
103        }
104    }
105
106    pub fn as_str(&self) -> &str {
107        &self.0
108    }
109
110    /// Whether Kellnr is served at the root, without a prefix.
111    pub fn is_root(&self) -> bool {
112        self.0.is_empty()
113    }
114}
115
116impl fmt::Display for UrlPathPrefix {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        f.write_str(&self.0)
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    fn origin(port: u16, path: &str) -> Origin {
127        Origin {
128            hostname: "kellnr.example.com".to_string(),
129            port,
130            protocol: Protocol::Https,
131            path: path.to_string(),
132            cookie_secure: None,
133        }
134    }
135
136    #[test]
137    fn secure_cookies_follow_protocol_by_default() {
138        let mut o = origin(443, "");
139        assert!(o.secure_cookies());
140
141        o.protocol = Protocol::Http;
142        assert!(!o.secure_cookies());
143    }
144
145    /// Behind a TLS-terminating proxy the protocol may be http while
146    /// browsers use https; operators can then force secure cookies.
147    #[test]
148    fn secure_cookies_can_be_overridden() {
149        let mut o = origin(80, "");
150        o.protocol = Protocol::Http;
151        o.cookie_secure = Some(true);
152        assert!(o.secure_cookies());
153
154        let mut o = origin(443, "");
155        o.cookie_secure = Some(false);
156        assert!(!o.secure_cookies());
157    }
158
159    #[test]
160    fn base_url_omits_default_ports() {
161        assert_eq!(
162            origin(443, "").base_url(),
163            "https://kellnr.example.com",
164            "443 is implied by https and must not be spelled out"
165        );
166
167        let mut http = origin(80, "");
168        http.protocol = Protocol::Http;
169        assert_eq!(http.base_url(), "http://kellnr.example.com");
170    }
171
172    #[test]
173    fn base_url_includes_non_default_port() {
174        assert_eq!(
175            origin(8000, "").base_url(),
176            "https://kellnr.example.com:8000"
177        );
178    }
179
180    #[test]
181    fn base_url_appends_path_prefix() {
182        assert_eq!(
183            origin(8000, "/registry").base_url(),
184            "https://kellnr.example.com:8000/registry"
185        );
186        assert_eq!(
187            origin(443, "/registry").base_url(),
188            "https://kellnr.example.com/registry"
189        );
190    }
191
192    #[test]
193    fn base_url_keeps_port_that_is_not_the_protocol_default() {
194        assert_eq!(origin(80, "").base_url(), "https://kellnr.example.com:80");
195
196        let mut http = origin(443, "");
197        http.protocol = Protocol::Http;
198        assert_eq!(http.base_url(), "http://kellnr.example.com:443");
199    }
200
201    #[test]
202    fn path_prefix_has_one_leading_and_no_trailing_slash() {
203        for (path, expected) in [
204            ("", ""),
205            ("/", ""),
206            ("//", ""),
207            ("  /  ", ""),
208            ("registry", "/registry"),
209            ("/registry", "/registry"),
210            ("/registry/", "/registry"),
211            (" registry// ", "/registry"),
212            ("/a/b/", "/a/b"),
213        ] {
214            let prefix = UrlPathPrefix::new(path);
215            assert_eq!(prefix.as_str(), expected, "path {path:?}");
216            assert_eq!(prefix.to_string(), expected, "path {path:?}");
217            assert_eq!(prefix.is_root(), expected.is_empty(), "path {path:?}");
218            assert_eq!(origin(443, path).path_prefix(), prefix, "path {path:?}");
219        }
220    }
221
222    #[test]
223    fn base_url_normalizes_path_that_was_not_normalized() {
224        assert_eq!(
225            origin(443, "registry/").base_url(),
226            "https://kellnr.example.com/registry"
227        );
228    }
229
230    #[test]
231    fn normalize_brings_path_into_prefix_form() {
232        for (path, expected) in [
233            ("", ""),
234            ("/", ""),
235            ("  /  ", ""),
236            ("registry", "/registry"),
237            ("/registry", "/registry"),
238            ("/registry/", "/registry"),
239            (" registry// ", "/registry"),
240            ("/a/b/", "/a/b"),
241        ] {
242            let mut o = origin(443, path);
243            o.normalize();
244            assert_eq!(o.path, expected, "path {path:?}");
245        }
246    }
247
248    /// A bare, missing or trailing slash must not leak into the URL, otherwise
249    /// callers appending their own path produce a double slash.
250    #[test]
251    fn base_url_of_normalized_origin_composes_into_callback_url() {
252        for path in ["", "/", "registry", "/registry", "/registry/"] {
253            let mut o = origin(443, path);
254            o.normalize();
255            let url = format!("{}/api/v1/oauth2/callback", o.base_url());
256            assert!(
257                url == "https://kellnr.example.com/api/v1/oauth2/callback"
258                    || url == "https://kellnr.example.com/registry/api/v1/oauth2/callback",
259                "callback URL for path {path:?} is malformed: {url}"
260            );
261        }
262    }
263}