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