use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::extract::{Request, State};
use axum::http::header::{
ACCEPT, AUTHORIZATION, CACHE_CONTROL, CONTENT_SECURITY_POLICY, CONTENT_TYPE, LOCATION,
REFERRER_POLICY, RETRY_AFTER, X_CONTENT_TYPE_OPTIONS, X_FRAME_OPTIONS,
};
use axum::http::{HeaderName, HeaderValue, Method, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Redirect, Response};
use tower_http::cors::{AllowOrigin, CorsLayer};
use crate::error::AdminError;
pub(crate) async fn timeout(
State(limit): State<Duration>,
request: Request,
next: Next,
) -> Response {
let (method, path) = (request.method().clone(), request.uri().path().to_owned());
match tokio::time::timeout(limit, next.run(request)).await {
Ok(response) => response,
Err(_) => {
tracing::warn!(%method, %path, limit_secs = limit.as_secs_f32(), "request timed out");
AdminError::custom(
StatusCode::SERVICE_UNAVAILABLE,
"timeout",
"the request took too long",
)
.into_response()
}
}
}
pub(crate) async fn access_log(request: Request, next: Next) -> Response {
let started = Instant::now();
let (method, path) = (request.method().clone(), request.uri().path().to_owned());
let response = next.run(request).await;
tracing::info!(
%method,
%path,
status = response.status().as_u16(),
ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
"request"
);
response
}
pub(crate) struct BrowserRedirect {
pub(crate) api_base: String,
pub(crate) pages: Vec<String>,
}
pub(crate) async fn browser_redirect(
State(rules): State<Arc<BrowserRedirect>>,
request: Request,
next: Next,
) -> Response {
let path = request.uri().path();
let under_api = path
.strip_prefix(&rules.api_base)
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
let headers = request.headers();
let visit = matches!(*request.method(), Method::GET | Method::HEAD)
&& under_api
&& !rules.pages.iter().any(|page| page == path)
&& !headers.contains_key(AUTHORIZATION)
&& match headers.get("sec-fetch-mode").and_then(|v| v.to_str().ok()) {
Some(mode) => mode == "navigate",
None => headers
.get(ACCEPT)
.and_then(|v| v.to_str().ok())
.is_some_and(|accept| accept.contains("text/html")),
};
if visit {
return Redirect::to("/").into_response();
}
next.run(request).await
}
pub(crate) fn protect(response: &mut Response) {
let defaults = [
(X_CONTENT_TYPE_OPTIONS, "nosniff"),
(X_FRAME_OPTIONS, "DENY"),
(CONTENT_SECURITY_POLICY, "frame-ancestors 'none'"),
(REFERRER_POLICY, "same-origin"),
(CACHE_CONTROL, "no-store"),
];
for (name, value) in defaults {
response
.headers_mut()
.entry(name)
.or_insert(HeaderValue::from_static(value));
}
}
pub(crate) fn parse_origins(value: &str) -> Result<Vec<String>, AdminError> {
let mut origins = Vec::new();
for origin in value.split(',').map(str::trim).filter(|o| !o.is_empty()) {
if origin != "*" {
let parsed = url::Url::parse(origin).ok();
let exact = parsed.is_some_and(|url| {
matches!(url.scheme(), "http" | "https")
&& url.origin().ascii_serialization() == origin
});
if !exact {
return Err(AdminError::Config(format!(
"`{origin}` is not an origin: write it like https://app.example.com, with no path or trailing slash"
)));
}
}
origins.push(origin.to_owned());
}
Ok(origins)
}
pub(crate) fn cors(origins: &[String]) -> CorsLayer {
let allowed = if origins.iter().any(|o| o == "*") {
AllowOrigin::any()
} else {
AllowOrigin::list(origins.iter().filter_map(|o| HeaderValue::from_str(o).ok()))
};
let upload_offset = HeaderName::from_static("upload-offset");
CorsLayer::new()
.allow_origin(allowed)
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::PATCH,
Method::DELETE,
])
.allow_headers([AUTHORIZATION, CONTENT_TYPE, upload_offset.clone()])
.expose_headers([LOCATION, RETRY_AFTER, upload_offset])
.max_age(Duration::from_secs(600))
}