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 pub hostname: String,
26
27 pub port: u16,
29
30 pub protocol: Protocol,
32
33 pub path: String,
35
36 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 pub fn secure_cookies(&self) -> bool {
58 self.cookie_secure
59 .unwrap_or(self.protocol == Protocol::Https)
60 }
61
62 pub fn path_prefix(&self) -> UrlPathPrefix {
64 UrlPathPrefix::new(&self.path)
65 }
66
67 pub fn normalize(&mut self) {
70 self.path = self.path_prefix().0;
71 }
72
73 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
92pub struct UrlPathPrefix(String);
93
94impl UrlPathPrefix {
95 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 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 #[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 #[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}