use askama::Template;
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use laterite_auth::AuthError;
use laterite_core::{t, CoreError, Text, Translator};
#[derive(Debug)]
pub enum AdminError {
NotFound,
Forbidden,
Internal(anyhow::Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ErrorMeta {
pub kind: ErrorKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorKind {
NotFound,
Forbidden,
Internal,
}
impl IntoResponse for AdminError {
fn into_response(self) -> Response {
let (kind, detail) = match self {
AdminError::NotFound => (ErrorKind::NotFound, None),
AdminError::Forbidden => (ErrorKind::Forbidden, None),
AdminError::Internal(err) => {
tracing::error!(error = format!("{err:#}"), "admin request failed");
let detail = laterite_core::config::debug().then(|| format!("{err:#}"));
(ErrorKind::Internal, detail)
}
};
let (title, message) = kind_texts(kind);
page(
kind,
kind_status(kind),
title.source(),
message.source(),
detail,
)
}
}
fn kind_status(kind: ErrorKind) -> StatusCode {
match kind {
ErrorKind::NotFound => StatusCode::NOT_FOUND,
ErrorKind::Forbidden => StatusCode::FORBIDDEN,
ErrorKind::Internal => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn kind_texts(kind: ErrorKind) -> (Text, Text) {
match kind {
ErrorKind::NotFound => (
t!("Not found"),
t!("The page or record you asked for does not exist."),
),
ErrorKind::Forbidden => (
t!("Forbidden"),
t!("You do not have permission to view this."),
),
ErrorKind::Internal => (
t!("Something went wrong"),
t!("The server hit an unexpected error. It has been logged."),
),
}
}
pub(crate) fn localized_error(kind: ErrorKind, tr: &Translator) -> Response {
let (title, message) = kind_texts(kind);
page(
kind,
kind_status(kind),
&tr.t(&title),
&tr.t(&message),
None,
)
}
fn page(
kind: ErrorKind,
status: StatusCode,
title: &str,
message: &str,
detail: Option<String>,
) -> Response {
let body = ErrorPage {
title: title.to_string(),
message: message.to_string(),
detail,
}
.render()
.unwrap_or_else(|_| title.to_string());
let mut response = (status, Html(body)).into_response();
response.extensions_mut().insert(ErrorMeta { kind });
response
}
pub(crate) fn csrf_rejected() -> Response {
page(
ErrorKind::Forbidden,
StatusCode::FORBIDDEN,
t!("Request blocked").source(),
t!("This form expired or its origin was not recognised. Go back, reload the page, and try again.")
.source(),
None,
)
}
pub(crate) fn masked_500() -> Response {
page(
ErrorKind::Internal,
StatusCode::INTERNAL_SERVER_ERROR,
t!("Something went wrong").source(),
t!("The server hit an unexpected error.").source(),
None,
)
}
#[derive(Template)]
#[template(path = "error.html")]
struct ErrorPage {
title: String,
message: String,
detail: Option<String>,
}
impl From<CoreError> for AdminError {
fn from(err: CoreError) -> Self {
match err {
CoreError::NotFound(_) => AdminError::NotFound,
CoreError::Unauthorized | CoreError::Forbidden(_) => AdminError::Forbidden,
other => AdminError::Internal(other.into()),
}
}
}
impl From<AuthError> for AdminError {
fn from(err: AuthError) -> Self {
AdminError::from(CoreError::from(err))
}
}
macro_rules! into_internal {
($($ty:ty),* $(,)?) => {$(
impl From<$ty> for AdminError {
fn from(err: $ty) -> Self {
AdminError::Internal(err.into())
}
}
)*};
}
into_internal!(anyhow::Error, sqlx::Error, askama::Error);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_errors_map_to_their_status() {
assert_eq!(
AdminError::NotFound.into_response().status(),
StatusCode::NOT_FOUND
);
assert_eq!(
AdminError::Forbidden.into_response().status(),
StatusCode::FORBIDDEN
);
}
#[test]
fn core_errors_keep_their_meaning() {
assert!(matches!(
AdminError::from(CoreError::NotFound("x".into())),
AdminError::NotFound
));
assert!(matches!(
AdminError::from(CoreError::Forbidden("x".into())),
AdminError::Forbidden
));
assert!(matches!(
AdminError::from(CoreError::Unauthorized),
AdminError::Forbidden
));
assert!(matches!(
AdminError::from(CoreError::Conflict("x".into())),
AdminError::Internal(_)
));
}
#[tokio::test]
async fn internal_masks_the_cause_and_stamps_meta() {
let resp =
AdminError::Internal(anyhow::anyhow!("secret connection string")).into_response();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(
resp.extensions().get::<ErrorMeta>().map(|m| m.kind),
Some(ErrorKind::Internal)
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8(bytes.to_vec()).unwrap();
assert!(!body.contains("secret connection string"));
}
}