1use askama::Template;
10use axum::http::StatusCode;
11use axum::response::{Html, IntoResponse, Response};
12use laterite_auth::AuthError;
13use laterite_core::{t, CoreError, Text, Translator};
14
15#[derive(Debug)]
17pub enum AdminError {
18 NotFound,
20 Forbidden,
22 Internal(anyhow::Error),
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct ErrorMeta {
31 pub kind: ErrorKind,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum ErrorKind {
37 NotFound,
38 Forbidden,
39 Internal,
40}
41
42impl IntoResponse for AdminError {
43 fn into_response(self) -> Response {
44 let (kind, detail) = match self {
48 AdminError::NotFound => (ErrorKind::NotFound, None),
49 AdminError::Forbidden => (ErrorKind::Forbidden, None),
50 AdminError::Internal(err) => {
51 tracing::error!(error = format!("{err:#}"), "admin request failed");
53 let detail = laterite_core::config::debug().then(|| format!("{err:#}"));
54 (ErrorKind::Internal, detail)
55 }
56 };
57 let (title, message) = kind_texts(kind);
58 page(
59 kind,
60 kind_status(kind),
61 title.source(),
62 message.source(),
63 detail,
64 )
65 }
66}
67
68fn kind_status(kind: ErrorKind) -> StatusCode {
70 match kind {
71 ErrorKind::NotFound => StatusCode::NOT_FOUND,
72 ErrorKind::Forbidden => StatusCode::FORBIDDEN,
73 ErrorKind::Internal => StatusCode::INTERNAL_SERVER_ERROR,
74 }
75}
76
77fn kind_texts(kind: ErrorKind) -> (Text, Text) {
80 match kind {
81 ErrorKind::NotFound => (
82 t!("Not found"),
83 t!("The page or record you asked for does not exist."),
84 ),
85 ErrorKind::Forbidden => (
86 t!("Forbidden"),
87 t!("You do not have permission to view this."),
88 ),
89 ErrorKind::Internal => (
90 t!("Something went wrong"),
91 t!("The server hit an unexpected error. It has been logged."),
92 ),
93 }
94}
95
96pub(crate) fn localized_error(kind: ErrorKind, tr: &Translator) -> Response {
100 let (title, message) = kind_texts(kind);
101 page(
102 kind,
103 kind_status(kind),
104 &tr.t(&title),
105 &tr.t(&message),
106 None,
107 )
108}
109
110fn page(
113 kind: ErrorKind,
114 status: StatusCode,
115 title: &str,
116 message: &str,
117 detail: Option<String>,
118) -> Response {
119 let body = ErrorPage {
120 title: title.to_string(),
121 message: message.to_string(),
122 detail,
123 }
124 .render()
125 .unwrap_or_else(|_| title.to_string());
126 let mut response = (status, Html(body)).into_response();
127 response.extensions_mut().insert(ErrorMeta { kind });
128 response
129}
130
131pub(crate) fn csrf_rejected() -> Response {
136 page(
137 ErrorKind::Forbidden,
138 StatusCode::FORBIDDEN,
139 t!("Request blocked").source(),
140 t!("This form expired or its origin was not recognised. Go back, reload the page, and try again.")
141 .source(),
142 None,
143 )
144}
145
146pub(crate) fn masked_500() -> Response {
150 page(
151 ErrorKind::Internal,
152 StatusCode::INTERNAL_SERVER_ERROR,
153 t!("Something went wrong").source(),
154 t!("The server hit an unexpected error.").source(),
155 None,
156 )
157}
158
159#[derive(Template)]
163#[template(path = "error.html")]
164struct ErrorPage {
165 title: String,
166 message: String,
167 detail: Option<String>,
169}
170
171impl From<CoreError> for AdminError {
172 fn from(err: CoreError) -> Self {
173 match err {
174 CoreError::NotFound(_) => AdminError::NotFound,
175 CoreError::Unauthorized | CoreError::Forbidden(_) => AdminError::Forbidden,
176 other => AdminError::Internal(other.into()),
177 }
178 }
179}
180
181impl From<AuthError> for AdminError {
182 fn from(err: AuthError) -> Self {
185 AdminError::from(CoreError::from(err))
186 }
187}
188
189macro_rules! into_internal {
192 ($($ty:ty),* $(,)?) => {$(
193 impl From<$ty> for AdminError {
194 fn from(err: $ty) -> Self {
195 AdminError::Internal(err.into())
196 }
197 }
198 )*};
199}
200into_internal!(anyhow::Error, sqlx::Error, askama::Error);
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn client_errors_map_to_their_status() {
208 assert_eq!(
209 AdminError::NotFound.into_response().status(),
210 StatusCode::NOT_FOUND
211 );
212 assert_eq!(
213 AdminError::Forbidden.into_response().status(),
214 StatusCode::FORBIDDEN
215 );
216 }
217
218 #[test]
219 fn core_errors_keep_their_meaning() {
220 assert!(matches!(
221 AdminError::from(CoreError::NotFound("x".into())),
222 AdminError::NotFound
223 ));
224 assert!(matches!(
225 AdminError::from(CoreError::Forbidden("x".into())),
226 AdminError::Forbidden
227 ));
228 assert!(matches!(
229 AdminError::from(CoreError::Unauthorized),
230 AdminError::Forbidden
231 ));
232 assert!(matches!(
234 AdminError::from(CoreError::Conflict("x".into())),
235 AdminError::Internal(_)
236 ));
237 }
238
239 #[tokio::test]
240 async fn internal_masks_the_cause_and_stamps_meta() {
241 let resp =
242 AdminError::Internal(anyhow::anyhow!("secret connection string")).into_response();
243 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
244 assert_eq!(
245 resp.extensions().get::<ErrorMeta>().map(|m| m.kind),
246 Some(ErrorKind::Internal)
247 );
248 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
249 .await
250 .unwrap();
251 let body = String::from_utf8(bytes.to_vec()).unwrap();
252 assert!(!body.contains("secret connection string"));
254 }
255}