systemprompt_api/services/
request_base_url.rs1use axum::extract::FromRequestParts;
18use http::request::Parts;
19use http::{StatusCode, header};
20use systemprompt_models::Config;
21
22#[derive(Debug, Clone)]
23pub struct RequestBaseUrl {
24 base: String,
25 origin: url::Origin,
26}
27
28impl RequestBaseUrl {
29 #[must_use]
30 pub fn as_str(&self) -> &str {
31 &self.base
32 }
33
34 #[must_use]
35 pub const fn origin(&self) -> &url::Origin {
36 &self.origin
37 }
38
39 #[must_use]
40 pub fn into_string(self) -> String {
41 self.base
42 }
43}
44
45fn is_loopback_host(host: &str) -> bool {
46 let bare = host.split(':').next().unwrap_or(host).to_ascii_lowercase();
47 bare == "localhost" || bare == "127.0.0.1" || bare == "[::1]" || bare == "::1"
48}
49
50fn host_in_allowlist(candidate_host: &str, configured: &url::Url) -> bool {
51 let candidate_bare = candidate_host
52 .rsplit_once(':')
53 .map_or(candidate_host, |(h, _)| h)
54 .to_ascii_lowercase();
55 let configured_host = configured.host_str().unwrap_or("").to_ascii_lowercase();
56
57 if candidate_bare == configured_host {
58 return true;
59 }
60 if is_loopback_host(&configured_host) && is_loopback_host(&candidate_bare) {
61 return true;
62 }
63 false
64}
65
66fn fallback_from_url(configured: &url::Url) -> RequestBaseUrl {
67 let trimmed = configured.as_str().trim_end_matches('/').to_owned();
68 RequestBaseUrl {
69 base: trimmed,
70 origin: configured.origin(),
71 }
72}
73
74#[must_use]
77pub fn resolve(raw_host: Option<&str>, configured: &url::Url) -> RequestBaseUrl {
78 if let Some(host) = raw_host.map(str::trim).filter(|s| !s.is_empty())
79 && let Ok(resolved) = build_from_host(host, configured)
80 {
81 return resolved;
82 }
83 fallback_from_url(configured)
84}
85
86fn build_from_host(raw_host: &str, configured: &url::Url) -> Result<RequestBaseUrl, &'static str> {
87 if raw_host.is_empty() || raw_host.contains('/') || raw_host.contains(' ') {
88 return Err("invalid host header");
89 }
90 if !host_in_allowlist(raw_host, configured) {
91 return Err("host not in allowlist");
92 }
93 let host_bare = raw_host
94 .rsplit_once(':')
95 .map_or(raw_host, |(h, _)| h)
96 .to_ascii_lowercase();
97 let scheme = if is_loopback_host(&host_bare) {
98 "http"
99 } else {
100 configured.scheme()
101 };
102 let base = format!("{scheme}://{raw_host}");
103 let parsed = url::Url::parse(&base).map_err(|_e| "host header did not parse as URL")?;
104 Ok(RequestBaseUrl {
105 base: base.trim_end_matches('/').to_owned(),
106 origin: parsed.origin(),
107 })
108}
109
110impl<S: Send + Sync> FromRequestParts<S> for RequestBaseUrl {
111 type Rejection = (StatusCode, String);
112
113 #[expect(
114 clippy::unused_async_trait_impl,
115 reason = "async signature required by the FromRequestParts trait; this \
116 extractor resolves the base URL synchronously"
117 )]
118 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
119 let cfg = Config::get().map_err(|e| {
120 tracing::error!(error = %e, "Failed to load config for RequestBaseUrl");
121 (
122 StatusCode::INTERNAL_SERVER_ERROR,
123 "Configuration unavailable".to_owned(),
124 )
125 })?;
126 let configured = url::Url::parse(&cfg.api_external_url).map_err(|e| {
127 tracing::error!(
128 error = %e,
129 api_external_url = %cfg.api_external_url,
130 "api_external_url is not a valid URL — bootstrap validation should have caught this"
131 );
132 (
133 StatusCode::INTERNAL_SERVER_ERROR,
134 "Configuration invalid".to_owned(),
135 )
136 })?;
137
138 let raw_host = parts
139 .headers
140 .get(header::HOST)
141 .and_then(|v| v.to_str().ok());
142 Ok(resolve(raw_host, &configured))
143 }
144}