use std::any::Any;
use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use axum::Json;
use axum::extract::{Request, State};
use axum::http::header::{ALLOW, CONTENT_TYPE, RETRY_AFTER, WWW_AUTHENTICATE};
use axum::http::{HeaderValue, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use futures_util::FutureExt;
use minijinja::context;
use sea_orm::SqlErr;
use serde_json::json;
use crate::AdminState;
use crate::auth::Session;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum AdminError {
#[error("not found: {0}")]
NotFound(String),
#[error("invalid value for `{field}`: {message}")]
BadInput {
field: String,
message: String,
},
#[error("unsupported: {0}")]
Unsupported(String),
#[error("configuration error: {0}")]
Config(String),
#[error("authentication required")]
Unauthorized,
#[error("forbidden: {0}")]
Forbidden(String),
#[error(transparent)]
Db(#[from] sea_orm::DbErr),
#[error("migration error: {0}")]
Migration(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("too large: {0}")]
TooLarge(String),
#[error(transparent)]
Storage(#[from] object_store::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Template(#[from] minijinja::Error),
#[error("too many attempts; try again in {retry_after_secs} seconds")]
RateLimited {
retry_after_secs: u64,
},
#[error("{message}")]
Custom {
status: StatusCode,
code: String,
message: String,
},
}
impl AdminError {
#[must_use]
pub fn custom(status: StatusCode, code: impl Into<String>, message: impl Into<String>) -> Self {
Self::Custom {
status,
code: code.into(),
message: message.into(),
}
}
fn describe(&self) -> (StatusCode, ErrorInfo) {
let (status, code, message) = match self {
Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found", self.to_string()),
Self::BadInput { message, .. } => (
StatusCode::UNPROCESSABLE_ENTITY,
"invalid_field",
message.clone(),
),
Self::Unsupported(message) => (StatusCode::BAD_REQUEST, "bad_request", message.clone()),
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized", self.to_string()),
Self::Forbidden(message) => (StatusCode::FORBIDDEN, "forbidden", message.clone()),
Self::Conflict(message) => (StatusCode::CONFLICT, "conflict", message.clone()),
Self::TooLarge(message) => {
(StatusCode::PAYLOAD_TOO_LARGE, "too_large", message.clone())
}
Self::RateLimited { retry_after_secs } => {
return (
StatusCode::TOO_MANY_REQUESTS,
ErrorInfo {
retry_after: Some(*retry_after_secs),
..ErrorInfo::new("rate_limited", &self.to_string())
},
);
}
Self::Custom {
status,
code,
message,
} => {
return (
*status,
ErrorInfo {
code: code.clone(),
message: message.clone(),
field: None,
retry_after: None,
json: false,
},
);
}
Self::Db(db)
if matches!(
db.sql_err(),
Some(
SqlErr::UniqueConstraintViolation(_)
| SqlErr::ForeignKeyConstraintViolation(_)
)
) =>
{
(
StatusCode::CONFLICT,
"conflict",
"the change conflicts with existing data (unique or foreign key constraint)"
.to_owned(),
)
}
Self::Config(_)
| Self::Db(_)
| Self::Migration(_)
| Self::Io(_)
| Self::Storage(_)
| Self::Template(_) => {
tracing::error!(error = %self, "request failed");
return (StatusCode::INTERNAL_SERVER_ERROR, ErrorInfo::internal());
}
};
let field = match self {
Self::BadInput { field, .. } => Some(field.clone()),
_ => None,
};
(
status,
ErrorInfo {
code: code.to_owned(),
message,
field,
retry_after: None,
json: false,
},
)
}
}
impl IntoResponse for AdminError {
fn into_response(self) -> Response {
let (status, info) = self.describe();
info.text(status)
}
}
#[derive(Debug)]
pub struct ApiError(AdminError);
impl ApiError {
#[must_use]
pub fn into_inner(self) -> AdminError {
self.0
}
}
impl<E: Into<AdminError>> From<E> for ApiError {
fn from(error: E) -> Self {
Self(error.into())
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, info) = self.0.describe();
info.json(status)
}
}
pub type ApiResult<T> = Result<T, ApiError>;
#[derive(Clone)]
struct ErrorInfo {
code: String,
message: String,
field: Option<String>,
retry_after: Option<u64>,
json: bool,
}
impl ErrorInfo {
fn new(code: &str, message: &str) -> Self {
Self {
code: code.to_owned(),
message: message.to_owned(),
field: None,
retry_after: None,
json: false,
}
}
fn finish(self, mut response: Response) -> Response {
if let Some(secs) = self.retry_after {
response
.headers_mut()
.insert(RETRY_AFTER, HeaderValue::from(secs));
}
response.extensions_mut().insert(self);
response
}
fn internal() -> Self {
Self::new("internal", "internal error")
}
fn unrouted(response: &Response) -> Option<Self> {
if response.headers().contains_key(CONTENT_TYPE) {
return None;
}
match response.status() {
StatusCode::NOT_FOUND => Some(Self::new("not_found", "not found")),
StatusCode::METHOD_NOT_ALLOWED => {
Some(Self::new("method_not_allowed", "method not allowed"))
}
_ => None,
}
}
fn text(self, status: StatusCode) -> Response {
let response = (status, self.message.clone()).into_response();
self.finish(response)
}
fn json(mut self, status: StatusCode) -> Response {
self.json = true;
let body = Json(json!({
"error": { "code": self.code, "message": self.message, "field": self.field }
}));
let mut response = (status, body).into_response();
if status == StatusCode::UNAUTHORIZED {
response
.headers_mut()
.insert(WWW_AUTHENTICATE, HeaderValue::from_static("Bearer"));
}
self.finish(response)
}
fn page(self, status: StatusCode, state: &AdminState, session: Option<&Session>) -> Response {
let page = context! {
status => status.as_u16(),
title => status.canonical_reason().unwrap_or("Error"),
message => &self.message,
};
match crate::handlers::render(state, "error.html", session, page) {
Ok(html) => {
let mut response = (status, html).into_response();
if let Some(secs) = self.retry_after {
response
.headers_mut()
.insert(RETRY_AFTER, HeaderValue::from(secs));
}
response
}
Err(error) => {
tracing::error!(%error, "could not render the error page");
self.text(status)
}
}
}
}
#[derive(PartialEq)]
enum Area {
Api,
Admin,
Other,
}
fn under(path: &str, base: &str) -> bool {
path.strip_prefix(base)
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
}
fn panic_message(panic: &(dyn Any + Send)) -> &str {
panic
.downcast_ref::<&str>()
.copied()
.or_else(|| panic.downcast_ref::<String>().map(String::as_str))
.unwrap_or("unknown panic")
}
pub(crate) async fn middleware(
State(state): State<Arc<AdminState>>,
request: Request,
next: Next,
) -> Response {
let path = request.uri().path();
let ours = under(path, &state.api_base) || under(path, &state.base);
let mut response = respond(&state, request, next).await;
if ours {
crate::server::protect(&mut response);
}
response
}
async fn respond(state: &Arc<AdminState>, request: Request, next: Next) -> Response {
let path = request.uri().path().to_owned();
let area = if under(&path, &state.api_base) {
Area::Api
} else if under(&path, &state.base) {
Area::Admin
} else {
Area::Other
};
let headers = (area == Area::Admin).then(|| request.headers().clone());
let mut response = match AssertUnwindSafe(next.run(request)).catch_unwind().await {
Ok(response) => response,
Err(panic) => {
tracing::error!(%path, panic = panic_message(&*panic), "handler panicked");
ErrorInfo::internal().text(StatusCode::INTERNAL_SERVER_ERROR)
}
};
let status = response.status();
let known = response.extensions_mut().remove::<ErrorInfo>();
let Some(info) = known.or_else(|| ErrorInfo::unrouted(&response)) else {
return response;
};
let allow = response.headers().get(ALLOW).cloned();
let mut rendered = match (area, headers) {
(Area::Api, _) if !info.json => info.json(status),
(Area::Admin, Some(headers)) => {
let session = Session::from_cookie(&headers, state).await.ok().flatten();
info.page(status, state, session.as_ref())
}
_ => return response,
};
if let Some(allow) = allow {
rendered.headers_mut().insert(ALLOW, allow);
}
rendered
}