acme_proxy_admin/webadmin/pages/
error.rs1use axum::http::{StatusCode, header};
29use axum::response::{Html, IntoResponse, Response};
30
31use crate::webadmin::error::AdminError;
32
33#[derive(Debug, PartialEq, Eq, thiserror::Error)]
35pub enum PageError {
36 #[error("redirect: {location}")]
39 Redirect { location: String, hx: bool },
40 #[error("{code}: {message}")]
42 Rendered {
43 status: StatusCode,
44 code: &'static str,
48 message: String,
49 },
50}
51
52pub const LOGIN_PATH: &str = "/ui/login";
54
55impl PageError {
56 #[must_use]
58 pub fn login_required(hx: bool) -> Self {
59 Self::Redirect {
60 location: LOGIN_PATH.to_string(),
61 hx,
62 }
63 }
64
65 #[must_use]
67 pub fn not_found(message: impl Into<String>) -> Self {
68 Self::Rendered {
69 status: StatusCode::NOT_FOUND,
70 code: "not_found",
71 message: message.into(),
72 }
73 }
74
75 #[must_use]
78 pub fn bad_request(message: impl Into<String>) -> Self {
79 Self::Rendered {
80 status: StatusCode::BAD_REQUEST,
81 code: "bad_request",
82 message: message.into(),
83 }
84 }
85
86 #[must_use]
88 pub fn internal() -> Self {
89 Self::Rendered {
90 status: StatusCode::INTERNAL_SERVER_ERROR,
91 code: "internal",
92 message: "internal error".to_string(),
93 }
94 }
95
96 #[must_use]
99 pub fn status(&self) -> StatusCode {
100 match self {
101 Self::Redirect { hx: true, .. } => StatusCode::NO_CONTENT,
104 Self::Redirect { hx: false, .. } => StatusCode::SEE_OTHER,
105 Self::Rendered { status, .. } => *status,
106 }
107 }
108}
109
110impl From<AdminError> for PageError {
117 fn from(error: AdminError) -> Self {
118 Self::Rendered {
119 status: error.status,
120 code: error.code,
121 message: error.message,
122 }
123 }
124}
125
126impl From<sqlx::Error> for PageError {
130 fn from(error: sqlx::Error) -> Self {
131 AdminError::from(error).into()
132 }
133}
134
135impl IntoResponse for PageError {
136 fn into_response(self) -> Response {
137 let status = self.status();
138 let mut response = match self {
139 Self::Redirect { location, hx } => redirect(&location, hx),
140 Self::Rendered { code, message, .. } => {
141 (status, Html(document(status, code, &message))).into_response()
142 }
143 };
144
145 response.headers_mut().insert(
148 header::CACHE_CONTROL,
149 header::HeaderValue::from_static("no-store"),
150 );
151 response
152 }
153}
154
155pub(crate) fn redirect(location: &str, hx: bool) -> Response {
162 let Ok(value) = header::HeaderValue::from_str(location) else {
163 tracing::error!(
166 event = "admin_redirect_unencodable",
167 outcome = "failure",
168 location = location
169 );
170 return StatusCode::INTERNAL_SERVER_ERROR.into_response();
171 };
172
173 if hx {
174 (StatusCode::NO_CONTENT, [("hx-redirect", value)]).into_response()
176 } else {
177 (StatusCode::SEE_OTHER, [(header::LOCATION, value)]).into_response()
178 }
179}
180
181fn document(status: StatusCode, code: &str, message: &str) -> String {
183 format!(
184 "<!doctype html>\n\
185 <html lang=\"en\">\n\
186 <head><meta charset=\"utf-8\"><title>{status} — acme-proxy admin</title>\
187 <link rel=\"stylesheet\" href=\"/ui/static/admin.css\"></head>\n\
188 <body><main><h1>{status}</h1>\
189 <div class=\"panel\"><p>{}</p>\
190 <p class=\"muted small\">Error code: <code>{}</code></p></div>\
191 <p class=\"small\"><a href=\"/ui/\">← Back to the panel</a></p>\
192 </main></body></html>\n",
193 escape_html(message),
194 escape_html(code),
195 )
196}
197
198fn escape_html(raw: &str) -> String {
204 let mut out = String::with_capacity(raw.len());
205 for character in raw.chars() {
206 match character {
207 '&' => out.push_str("&"),
208 '<' => out.push_str("<"),
209 '>' => out.push_str(">"),
210 '"' => out.push_str("""),
211 '\'' => out.push_str("'"),
212 other => out.push(other),
213 }
214 }
215 out
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use axum::body::to_bytes;
222
223 async fn body_of(error: PageError) -> String {
224 let response = error.into_response();
225 let bytes = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
226 String::from_utf8(bytes.to_vec()).unwrap()
227 }
228
229 #[test]
230 fn a_browser_redirect_is_a_see_other_with_a_location() {
231 let response = PageError::login_required(false).into_response();
232 assert_eq!(response.status(), StatusCode::SEE_OTHER);
233 assert_eq!(response.headers()["location"], LOGIN_PATH);
234 assert!(!response.headers().contains_key("hx-redirect"));
235 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
236 }
237
238 #[test]
244 fn an_htmx_redirect_is_a_header_and_no_body() {
245 let response = PageError::login_required(true).into_response();
246 assert_eq!(response.status(), StatusCode::NO_CONTENT);
247 assert_eq!(response.headers()["hx-redirect"], LOGIN_PATH);
248 assert!(!response.headers().contains_key("location"));
249 }
250
251 #[tokio::test]
252 async fn a_rendered_error_is_an_html_document_carrying_its_code() {
253 let error = PageError::not_found("no such account: acct-1");
254 assert_eq!(error.status(), StatusCode::NOT_FOUND);
255
256 let body = body_of(error).await;
257 assert!(body.starts_with("<!doctype html>"));
258 assert!(body.contains("no such account: acct-1"));
259 assert!(body.contains("<code>not_found</code>"));
260 }
261
262 #[tokio::test]
263 async fn a_message_carrying_markup_is_escaped() {
264 let body = body_of(PageError::not_found(
267 "no such account: <script>alert(1)</script>",
268 ))
269 .await;
270 assert!(!body.contains("<script>"));
271 assert!(body.contains("<script>alert(1)</script>"));
272 }
273
274 #[test]
275 fn escape_html_covers_every_delimiter() {
276 assert_eq!(
277 escape_html(r#"<a href="x" id='y'>&</a>"#),
278 "<a href="x" id='y'>&</a>"
279 );
280 assert_eq!(escape_html("plain"), "plain");
281 }
282
283 #[test]
284 fn an_admin_error_keeps_its_status_and_code() {
285 let error: PageError = AdminError::conflict("already_revoked", "already revoked").into();
286 assert_eq!(error.status(), StatusCode::CONFLICT);
287 assert_eq!(
288 error,
289 PageError::Rendered {
290 status: StatusCode::CONFLICT,
291 code: "already_revoked",
292 message: "already revoked".to_string(),
293 }
294 );
295 }
296
297 #[test]
298 fn internal_says_nothing_a_log_should_have_said() {
299 let error = PageError::internal();
300 assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR);
301 assert_eq!(error.to_string(), "internal: internal error");
302 }
303
304 #[test]
305 fn display_names_the_redirect_target() {
306 assert_eq!(
307 PageError::login_required(true).to_string(),
308 "redirect: /ui/login"
309 );
310 }
311}