1use axum::body::Body;
6use axum::extract::{FromRequest, Request};
7use axum::http::{HeaderValue, Request as HttpRequest, header};
8use axum::middleware::Next;
9use axum::response::{IntoResponse, Response as AxumResponse};
10use serde::de::DeserializeOwned;
11use std::sync::Arc;
12use std::time::Duration;
13
14use crate::module::HARNESS_API;
15use crate::ports::{Defer, IdGen};
16use crate::problem::Problem;
17use crate::scope::Scope;
18use crate::sidecar::{X_HARNESS_API, X_HARNESS_MODULE};
19use tracing::info_span;
20
21pub const X_REQUEST_ID: &str = "x-request-id";
25
26pub const MAX_BODY_BYTES: usize = 64 * 1024;
28
29#[allow(clippy::duration_suboptimal_units)]
32const CORS_PREFLIGHT_MAX_AGE: Duration = Duration::from_secs(86_400);
33
34pub fn request_id_is_valid(value: &str) -> bool {
36 (8..=128).contains(&value.len())
37 && value
38 .bytes()
39 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
40}
41
42#[derive(Clone)]
45pub(crate) struct ScopeState {
46 pub defer: Arc<dyn Defer>,
47 pub id_gen: Arc<dyn IdGen>,
48 pub module: Option<&'static str>,
55}
56
57pub(crate) async fn scope_layer(
61 axum::extract::State(state): axum::extract::State<ScopeState>,
62 mut request: Request,
63 next: Next,
64) -> AxumResponse {
65 use tracing::Instrument as _;
66 use tracing::field::Empty;
67
68 let incoming = request
69 .headers()
70 .get(X_REQUEST_ID)
71 .and_then(|value| value.to_str().ok())
72 .filter(|value| request_id_is_valid(value));
73 let request_id = match incoming {
74 Some(valid) => valid.to_owned(),
75 None => state.id_gen.ulid(),
76 };
77
78 let method = request.method().as_str().to_owned();
82 let ip_hash = crate::logging::subject_hash(
83 &crate::rate_limit::client_ip(request.headers()).unwrap_or_default(),
84 );
85 let ua_family = request
86 .headers()
87 .get(header::USER_AGENT)
88 .and_then(|value| value.to_str().ok())
89 .map_or_else(|| "unknown".to_owned(), ua_family_of);
90 let span = info_span!(
91 "request",
92 request_id = %request_id,
93 method = %method,
94 route = Empty,
95 module = Empty,
96 status = Empty,
97 duration_ms = Empty,
98 ip_hash = %ip_hash,
99 ua_family = %ua_family,
100 );
101
102 let scope = Scope {
103 defer: Arc::clone(&state.defer),
104 span: span.clone(),
105 request_id: request_id.clone(),
106 };
107 request.extensions_mut().insert(scope);
108
109 let route = request
110 .extensions()
111 .get::<axum::extract::MatchedPath>()
112 .map(|matched| matched.as_str().to_owned())
113 .unwrap_or_default();
114
115 #[cfg(not(target_arch = "wasm32"))]
123 let started = std::time::Instant::now();
124 let future = next.run(request);
125 let mut response = future.instrument(span.clone()).await;
126
127 if let Ok(value) = HeaderValue::from_str(&request_id) {
128 response.headers_mut().insert(X_REQUEST_ID, value);
129 }
130 if let Ok(value) = HeaderValue::from_str(&HARNESS_API.to_string()) {
139 response.headers_mut().insert(X_HARNESS_API, value);
140 }
141 if let Some(name) = state.module
146 && let Ok(value) = HeaderValue::from_str(name)
147 {
148 response.headers_mut().insert(X_HARNESS_MODULE, value);
149 }
150 span.record("route", route.as_str());
151 span.record(
152 "module",
153 route
154 .strip_prefix("/v1/")
155 .and_then(|rest| rest.split('/').next())
156 .unwrap_or_default(),
157 );
158 span.record("status", response.status().as_u16());
159 #[cfg(not(target_arch = "wasm32"))]
160 {
161 let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
162 span.record("duration_ms", duration_ms);
163 }
164 response
165}
166
167fn ua_family_of(user_agent: &str) -> String {
170 let token = user_agent
171 .split(['/', ' ', ';', '('])
172 .next()
173 .unwrap_or_default()
174 .to_lowercase();
175 let truncated: String = token.chars().take(24).collect();
176 if truncated.is_empty() {
177 "unknown".to_owned()
178 } else {
179 truncated
180 }
181}
182
183pub(crate) async fn security_headers_layer(request: Request, next: Next) -> AxumResponse {
187 let is_api = request.uri().path().starts_with("/v1/");
188 let mut response = next.run(request).await;
189 if is_api {
190 insert_no_store_headers(response.headers_mut());
191 }
192 response
193}
194
195pub(crate) async fn token_response_layer(request: Request, next: Next) -> AxumResponse {
203 let carries_token = request.uri().query().is_some_and(|query| {
204 query
205 .split('&')
206 .any(|pair| pair == "token" || pair.starts_with("token="))
207 });
208 let mut response = next.run(request).await;
209 if carries_token {
210 insert_no_store_headers(response.headers_mut());
211 }
212 response
213}
214
215fn insert_no_store_headers(headers: &mut axum::http::HeaderMap) {
216 headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
217 headers.insert(
218 header::X_CONTENT_TYPE_OPTIONS,
219 HeaderValue::from_static("nosniff"),
220 );
221 headers.insert(
222 header::HeaderName::from_static("referrer-policy"),
223 HeaderValue::from_static("no-referrer"),
224 );
225}
226
227pub struct Json<T>(pub T);
231
232impl<T, S> FromRequest<S> for Json<T>
233where
234 T: DeserializeOwned,
235 S: Send + Sync,
236{
237 type Rejection = Problem;
238
239 async fn from_request(request: HttpRequest<Body>, state: &S) -> Result<Self, Self::Rejection> {
240 let instance = request
241 .extensions()
242 .get::<Scope>()
243 .map(|scope| scope.request_id.clone());
244 match axum::Json::<T>::from_request(request, state).await {
245 Ok(axum::Json(value)) => Ok(Json(value)),
246 Err(rejection) => {
247 let mut problem = match &rejection {
250 axum::extract::rejection::JsonRejection::BytesRejection(_) => {
251 Problem::request_too_large()
252 }
253 _ => Problem::validation_failed(rejection.body_text()),
254 };
255 if let Some(instance) = instance {
256 problem = problem.instance(&instance);
257 }
258 Err(problem)
259 }
260 }
261 }
262}
263
264impl<T: serde::Serialize> IntoResponse for Json<T> {
265 fn into_response(self) -> AxumResponse {
266 axum::Json(self.0).into_response()
267 }
268}
269
270pub struct Form<T>(pub T);
276
277impl<T, S> FromRequest<S> for Form<T>
278where
279 T: DeserializeOwned,
280 S: Send + Sync,
281{
282 type Rejection = Problem;
283
284 async fn from_request(request: HttpRequest<Body>, state: &S) -> Result<Self, Self::Rejection> {
285 let instance = request
286 .extensions()
287 .get::<Scope>()
288 .map(|scope| scope.request_id.clone());
289 match axum::Form::<T>::from_request(request, state).await {
290 Ok(axum::Form(value)) => Ok(Form(value)),
291 Err(rejection) => {
292 let mut problem = match &rejection {
293 axum::extract::rejection::FormRejection::BytesRejection(_) => {
294 Problem::request_too_large()
295 }
296 _ => Problem::validation_failed(rejection.body_text()),
297 };
298 if let Some(instance) = instance {
299 problem = problem.instance(&instance);
300 }
301 Err(problem)
302 }
303 }
304 }
305}
306
307pub fn rate_limited(retry_after: Option<Duration>) -> AxumResponse {
310 let problem = Problem::new(&crate::problems::SLUGS.rate_limited);
311 let mut response = problem.into_response();
312 if let Some(pause) = retry_after {
313 let secs = pause.as_secs().max(1);
314 if let Ok(value) = HeaderValue::from_str(&secs.to_string()) {
315 response
316 .headers_mut()
317 .insert(header::HeaderName::from_static("retry-after"), value);
318 }
319 }
320 response
321}
322
323pub(crate) fn cors_layer(origins: &[String]) -> tower_http::cors::CorsLayer {
339 use tower_http::cors::{AllowOrigin, CorsLayer};
340 let allowed: Vec<HeaderValue> = origins
341 .iter()
342 .filter_map(|origin| HeaderValue::from_str(origin).ok())
343 .collect();
344 CorsLayer::new()
345 .allow_origin(AllowOrigin::list(allowed))
346 .allow_methods([
347 axum::http::Method::GET,
348 axum::http::Method::POST,
349 axum::http::Method::PUT,
350 axum::http::Method::DELETE,
351 axum::http::Method::OPTIONS,
352 ])
353 .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
354 .max_age(CORS_PREFLIGHT_MAX_AGE)
355}