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::ports::{Defer, IdGen};
15use crate::problem::Problem;
16use crate::scope::Scope;
17use tracing::info_span;
18
19pub const X_REQUEST_ID: &str = "x-request-id";
23
24pub const MAX_BODY_BYTES: usize = 64 * 1024;
26
27#[allow(clippy::duration_suboptimal_units)]
30const CORS_PREFLIGHT_MAX_AGE: Duration = Duration::from_secs(86_400);
31
32pub fn request_id_is_valid(value: &str) -> bool {
34 (8..=128).contains(&value.len())
35 && value
36 .bytes()
37 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
38}
39
40#[derive(Clone)]
43pub(crate) struct ScopeState {
44 pub defer: Arc<dyn Defer>,
45 pub id_gen: Arc<dyn IdGen>,
46}
47
48pub(crate) async fn scope_layer(
52 axum::extract::State(state): axum::extract::State<ScopeState>,
53 mut request: Request,
54 next: Next,
55) -> AxumResponse {
56 use tracing::Instrument as _;
57 use tracing::field::Empty;
58
59 let incoming = request
60 .headers()
61 .get(X_REQUEST_ID)
62 .and_then(|value| value.to_str().ok())
63 .filter(|value| request_id_is_valid(value));
64 let request_id = match incoming {
65 Some(valid) => valid.to_owned(),
66 None => state.id_gen.ulid(),
67 };
68
69 let method = request.method().as_str().to_owned();
73 let ip_hash = crate::logging::subject_hash(
74 &crate::rate_limit::client_ip(request.headers()).unwrap_or_default(),
75 );
76 let ua_family = request
77 .headers()
78 .get(header::USER_AGENT)
79 .and_then(|value| value.to_str().ok())
80 .map_or_else(|| "unknown".to_owned(), ua_family_of);
81 let span = info_span!(
82 "request",
83 request_id = %request_id,
84 method = %method,
85 route = Empty,
86 module = Empty,
87 status = Empty,
88 duration_ms = Empty,
89 ip_hash = %ip_hash,
90 ua_family = %ua_family,
91 );
92
93 let scope = Scope {
94 defer: Arc::clone(&state.defer),
95 span: span.clone(),
96 request_id: request_id.clone(),
97 };
98 request.extensions_mut().insert(scope);
99
100 let route = request
101 .extensions()
102 .get::<axum::extract::MatchedPath>()
103 .map(|matched| matched.as_str().to_owned())
104 .unwrap_or_default();
105
106 #[cfg(not(target_arch = "wasm32"))]
114 let started = std::time::Instant::now();
115 let future = next.run(request);
116 let mut response = future.instrument(span.clone()).await;
117
118 if let Ok(value) = HeaderValue::from_str(&request_id) {
119 response.headers_mut().insert(X_REQUEST_ID, value);
120 }
121 span.record("route", route.as_str());
122 span.record(
123 "module",
124 route
125 .strip_prefix("/v1/")
126 .and_then(|rest| rest.split('/').next())
127 .unwrap_or_default(),
128 );
129 span.record("status", response.status().as_u16());
130 #[cfg(not(target_arch = "wasm32"))]
131 {
132 let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
133 span.record("duration_ms", duration_ms);
134 }
135 response
136}
137
138fn ua_family_of(user_agent: &str) -> String {
141 let token = user_agent
142 .split(['/', ' ', ';', '('])
143 .next()
144 .unwrap_or_default()
145 .to_lowercase();
146 let truncated: String = token.chars().take(24).collect();
147 if truncated.is_empty() {
148 "unknown".to_owned()
149 } else {
150 truncated
151 }
152}
153
154pub(crate) async fn security_headers_layer(request: Request, next: Next) -> AxumResponse {
158 let is_api = request.uri().path().starts_with("/v1/");
159 let mut response = next.run(request).await;
160 if is_api {
161 let headers = response.headers_mut();
162 headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
163 headers.insert(
164 header::X_CONTENT_TYPE_OPTIONS,
165 HeaderValue::from_static("nosniff"),
166 );
167 headers.insert(
168 header::HeaderName::from_static("referrer-policy"),
169 HeaderValue::from_static("no-referrer"),
170 );
171 }
172 response
173}
174
175pub struct Json<T>(pub T);
179
180impl<T, S> FromRequest<S> for Json<T>
181where
182 T: DeserializeOwned,
183 S: Send + Sync,
184{
185 type Rejection = Problem;
186
187 async fn from_request(request: HttpRequest<Body>, state: &S) -> Result<Self, Self::Rejection> {
188 let instance = request
189 .extensions()
190 .get::<Scope>()
191 .map(|scope| scope.request_id.clone());
192 match axum::Json::<T>::from_request(request, state).await {
193 Ok(axum::Json(value)) => Ok(Json(value)),
194 Err(rejection) => {
195 let mut problem = match &rejection {
198 axum::extract::rejection::JsonRejection::BytesRejection(_) => {
199 Problem::request_too_large()
200 }
201 _ => Problem::validation_failed(rejection.body_text()),
202 };
203 if let Some(instance) = instance {
204 problem = problem.instance(&instance);
205 }
206 Err(problem)
207 }
208 }
209 }
210}
211
212impl<T: serde::Serialize> IntoResponse for Json<T> {
213 fn into_response(self) -> AxumResponse {
214 axum::Json(self.0).into_response()
215 }
216}
217
218pub struct Form<T>(pub T);
224
225impl<T, S> FromRequest<S> for Form<T>
226where
227 T: DeserializeOwned,
228 S: Send + Sync,
229{
230 type Rejection = Problem;
231
232 async fn from_request(request: HttpRequest<Body>, state: &S) -> Result<Self, Self::Rejection> {
233 let instance = request
234 .extensions()
235 .get::<Scope>()
236 .map(|scope| scope.request_id.clone());
237 match axum::Form::<T>::from_request(request, state).await {
238 Ok(axum::Form(value)) => Ok(Form(value)),
239 Err(rejection) => {
240 let mut problem = match &rejection {
241 axum::extract::rejection::FormRejection::BytesRejection(_) => {
242 Problem::request_too_large()
243 }
244 _ => Problem::validation_failed(rejection.body_text()),
245 };
246 if let Some(instance) = instance {
247 problem = problem.instance(&instance);
248 }
249 Err(problem)
250 }
251 }
252 }
253}
254
255pub fn rate_limited(retry_after: Option<Duration>) -> AxumResponse {
258 let problem = Problem::new(&crate::problems::SLUGS.rate_limited);
259 let mut response = problem.into_response();
260 if let Some(pause) = retry_after {
261 let secs = pause.as_secs().max(1);
262 if let Ok(value) = HeaderValue::from_str(&secs.to_string()) {
263 response
264 .headers_mut()
265 .insert(header::HeaderName::from_static("retry-after"), value);
266 }
267 }
268 response
269}
270
271pub(crate) fn cors_layer(origins: &[String]) -> tower_http::cors::CorsLayer {
275 use tower_http::cors::{AllowOrigin, CorsLayer};
276 let allowed: Vec<HeaderValue> = origins
277 .iter()
278 .filter_map(|origin| HeaderValue::from_str(origin).ok())
279 .collect();
280 CorsLayer::new()
281 .allow_origin(AllowOrigin::list(allowed))
282 .allow_methods([
283 axum::http::Method::GET,
284 axum::http::Method::POST,
285 axum::http::Method::DELETE,
286 axum::http::Method::OPTIONS,
287 ])
288 .allow_headers([header::CONTENT_TYPE])
289 .max_age(CORS_PREFLIGHT_MAX_AGE)
290}