Skip to main content

kellnr_settings/
origin.rs

1use provcfg::{ClapArgs, Configurable};
2use serde::{Deserialize, Serialize};
3
4use crate::protocol::Protocol;
5
6fn default_hostname() -> String {
7    "127.0.0.1".to_string()
8}
9
10fn default_origin_port() -> u16 {
11    std::env::var("KELLNR_ORIGIN__PORT")
12        .ok()
13        .and_then(|v| v.parse::<u16>().ok())
14        .unwrap_or(8000)
15}
16
17#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, Clone, Configurable, ClapArgs)]
18#[serde(default)]
19#[configurable(clap_prefix = "origin")]
20pub struct Origin {
21    /// External hostname for URLs
22    pub hostname: String,
23
24    /// External port for URLs
25    pub port: u16,
26
27    /// Protocol (http or https), not exposed on the CLI; set via TOML/env
28    #[arg(skip)]
29    pub protocol: Protocol,
30
31    /// URL path prefix
32    pub path: String,
33
34    /// Force the `Secure` flag on cookies (true) or never set it (false).
35    /// Unset, cookies are secure only when `protocol` is https. Set it to true
36    /// when Kellnr is reached over https through a TLS-terminating proxy but
37    /// `protocol` is http
38    pub cookie_secure: Option<bool>,
39}
40
41impl Default for Origin {
42    fn default() -> Self {
43        Self {
44            hostname: default_hostname(),
45            port: default_origin_port(),
46            protocol: Protocol::Http,
47            path: String::new(),
48            cookie_secure: None,
49        }
50    }
51}
52
53impl Origin {
54    /// Whether cookies must carry the `Secure` flag.
55    pub fn secure_cookies(&self) -> bool {
56        self.cookie_secure
57            .unwrap_or(self.protocol == Protocol::Https)
58    }
59
60    pub fn base_url(&self) -> String {
61        let path_prefix = self.path.trim().trim_end_matches('/');
62        if self.port == 443 || self.port == 80 {
63            format!("{}://{}{}", self.protocol, self.hostname, path_prefix)
64        } else {
65            format!(
66                "{}://{}:{}{}",
67                self.protocol, self.hostname, self.port, path_prefix
68            )
69        }
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    fn origin(port: u16, path: &str) -> Origin {
78        Origin {
79            hostname: "kellnr.example.com".to_string(),
80            port,
81            protocol: Protocol::Https,
82            path: path.to_string(),
83            cookie_secure: None,
84        }
85    }
86
87    #[test]
88    fn secure_cookies_follow_protocol_by_default() {
89        let mut o = origin(443, "");
90        assert!(o.secure_cookies());
91
92        o.protocol = Protocol::Http;
93        assert!(!o.secure_cookies());
94    }
95
96    /// Behind a TLS-terminating proxy the protocol may be http while
97    /// browsers use https; operators can then force secure cookies.
98    #[test]
99    fn secure_cookies_can_be_overridden() {
100        let mut o = origin(80, "");
101        o.protocol = Protocol::Http;
102        o.cookie_secure = Some(true);
103        assert!(o.secure_cookies());
104
105        let mut o = origin(443, "");
106        o.cookie_secure = Some(false);
107        assert!(!o.secure_cookies());
108    }
109
110    #[test]
111    fn base_url_omits_default_ports() {
112        assert_eq!(
113            origin(443, "").base_url(),
114            "https://kellnr.example.com",
115            "443 is implied by https and must not be spelled out"
116        );
117
118        let mut http = origin(80, "");
119        http.protocol = Protocol::Http;
120        assert_eq!(http.base_url(), "http://kellnr.example.com");
121    }
122
123    #[test]
124    fn base_url_includes_non_default_port() {
125        assert_eq!(
126            origin(8000, "").base_url(),
127            "https://kellnr.example.com:8000"
128        );
129    }
130
131    #[test]
132    fn base_url_appends_path_prefix() {
133        assert_eq!(
134            origin(8000, "/registry").base_url(),
135            "https://kellnr.example.com:8000/registry"
136        );
137        assert_eq!(
138            origin(443, "/registry").base_url(),
139            "https://kellnr.example.com/registry"
140        );
141    }
142
143    /// A bare or trailing slash must not leak into the URL, otherwise callers
144    /// appending their own path produce a double slash.
145    #[test]
146    fn base_url_normalizes_slashes_in_path_prefix() {
147        for path in ["", "/", "  /  ", "/registry/", "/registry//"] {
148            let url = origin(8000, path).base_url();
149            assert!(
150                !url.ends_with('/'),
151                "base_url for path {path:?} must not end in a slash, got {url}"
152            );
153        }
154
155        assert_eq!(
156            origin(8000, "/registry/").base_url(),
157            "https://kellnr.example.com:8000/registry"
158        );
159    }
160
161    /// The `OAuth2` callback and post-logout redirect are both built by appending
162    /// to `base_url`, so the join must stay free of double slashes.
163    #[test]
164    fn base_url_composes_into_callback_url() {
165        for path in ["", "/", "/registry", "/registry/"] {
166            let url = format!("{}/api/v1/oauth2/callback", origin(443, path).base_url());
167            assert!(
168                !url.contains("com//") && !url.contains("registry//"),
169                "callback URL for path {path:?} has a double slash: {url}"
170            );
171        }
172    }
173}