use axum::http::{StatusCode, header};
use axum::response::{Html, IntoResponse, Response};
use crate::webadmin::error::AdminError;
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
pub enum PageError {
#[error("redirect: {location}")]
Redirect { location: String, hx: bool },
#[error("{code}: {message}")]
Rendered {
status: StatusCode,
code: &'static str,
message: String,
},
}
pub const LOGIN_PATH: &str = "/ui/login";
impl PageError {
#[must_use]
pub fn login_required(hx: bool) -> Self {
Self::Redirect {
location: LOGIN_PATH.to_string(),
hx,
}
}
#[must_use]
pub fn not_found(message: impl Into<String>) -> Self {
Self::Rendered {
status: StatusCode::NOT_FOUND,
code: "not_found",
message: message.into(),
}
}
#[must_use]
pub fn bad_request(message: impl Into<String>) -> Self {
Self::Rendered {
status: StatusCode::BAD_REQUEST,
code: "bad_request",
message: message.into(),
}
}
#[must_use]
pub fn internal() -> Self {
Self::Rendered {
status: StatusCode::INTERNAL_SERVER_ERROR,
code: "internal",
message: "internal error".to_string(),
}
}
#[must_use]
pub fn status(&self) -> StatusCode {
match self {
Self::Redirect { hx: true, .. } => StatusCode::NO_CONTENT,
Self::Redirect { hx: false, .. } => StatusCode::SEE_OTHER,
Self::Rendered { status, .. } => *status,
}
}
}
impl From<AdminError> for PageError {
fn from(error: AdminError) -> Self {
Self::Rendered {
status: error.status,
code: error.code,
message: error.message,
}
}
}
impl From<sqlx::Error> for PageError {
fn from(error: sqlx::Error) -> Self {
AdminError::from(error).into()
}
}
impl IntoResponse for PageError {
fn into_response(self) -> Response {
let status = self.status();
let mut response = match self {
Self::Redirect { location, hx } => redirect(&location, hx),
Self::Rendered { code, message, .. } => {
(status, Html(document(status, code, &message))).into_response()
}
};
response.headers_mut().insert(
header::CACHE_CONTROL,
header::HeaderValue::from_static("no-store"),
);
response
}
}
pub(crate) fn redirect(location: &str, hx: bool) -> Response {
let Ok(value) = header::HeaderValue::from_str(location) else {
tracing::error!(
event = "admin_redirect_unencodable",
outcome = "failure",
location = location
);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
};
if hx {
(StatusCode::NO_CONTENT, [("hx-redirect", value)]).into_response()
} else {
(StatusCode::SEE_OTHER, [(header::LOCATION, value)]).into_response()
}
}
fn document(status: StatusCode, code: &str, message: &str) -> String {
format!(
"<!doctype html>\n\
<html lang=\"en\">\n\
<head><meta charset=\"utf-8\"><title>{status} — acme-proxy admin</title>\
<link rel=\"stylesheet\" href=\"/ui/static/admin.css\"></head>\n\
<body><main><h1>{status}</h1>\
<div class=\"panel\"><p>{}</p>\
<p class=\"muted small\">Error code: <code>{}</code></p></div>\
<p class=\"small\"><a href=\"/ui/\">← Back to the panel</a></p>\
</main></body></html>\n",
escape_html(message),
escape_html(code),
)
}
fn escape_html(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
for character in raw.chars() {
match character {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::to_bytes;
async fn body_of(error: PageError) -> String {
let response = error.into_response();
let bytes = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
#[test]
fn a_browser_redirect_is_a_see_other_with_a_location() {
let response = PageError::login_required(false).into_response();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers()["location"], LOGIN_PATH);
assert!(!response.headers().contains_key("hx-redirect"));
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
}
#[test]
fn an_htmx_redirect_is_a_header_and_no_body() {
let response = PageError::login_required(true).into_response();
assert_eq!(response.status(), StatusCode::NO_CONTENT);
assert_eq!(response.headers()["hx-redirect"], LOGIN_PATH);
assert!(!response.headers().contains_key("location"));
}
#[tokio::test]
async fn a_rendered_error_is_an_html_document_carrying_its_code() {
let error = PageError::not_found("no such account: acct-1");
assert_eq!(error.status(), StatusCode::NOT_FOUND);
let body = body_of(error).await;
assert!(body.starts_with("<!doctype html>"));
assert!(body.contains("no such account: acct-1"));
assert!(body.contains("<code>not_found</code>"));
}
#[tokio::test]
async fn a_message_carrying_markup_is_escaped() {
let body = body_of(PageError::not_found(
"no such account: <script>alert(1)</script>",
))
.await;
assert!(!body.contains("<script>"));
assert!(body.contains("<script>alert(1)</script>"));
}
#[test]
fn escape_html_covers_every_delimiter() {
assert_eq!(
escape_html(r#"<a href="x" id='y'>&</a>"#),
"<a href="x" id='y'>&</a>"
);
assert_eq!(escape_html("plain"), "plain");
}
#[test]
fn an_admin_error_keeps_its_status_and_code() {
let error: PageError = AdminError::conflict("already_revoked", "already revoked").into();
assert_eq!(error.status(), StatusCode::CONFLICT);
assert_eq!(
error,
PageError::Rendered {
status: StatusCode::CONFLICT,
code: "already_revoked",
message: "already revoked".to_string(),
}
);
}
#[test]
fn internal_says_nothing_a_log_should_have_said() {
let error = PageError::internal();
assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(error.to_string(), "internal: internal error");
}
#[test]
fn display_names_the_redirect_target() {
assert_eq!(
PageError::login_required(true).to_string(),
"redirect: /ui/login"
);
}
}