✨ Añade gestión de errores catastróficos

- Los errores controlados (403, 404, fatales) se renderizan con el tema
  activo; un `panic!` no controlado se intercepta con un HTML mínimo y
  autónomo, sin depender del tema ni de los componentes.
- Corrige además que `Context::template()` no seguía los cambios de tema
  hechos con `with_theme()` después de crear la página.
- Renombra `DefaultRegion(s)`/`DefaultTemplate(s)`, añade
  `Theme::admin_template()` y `Page::admin()`, y documenta la creación
  de temas e `InRegion` en `core/theme.rs`.
This commit is contained in:
Manuel Cillero 2026-07-13 00:38:11 +02:00
parent 0e72d51276
commit 24bd13414c
12 changed files with 458 additions and 139 deletions

View file

@ -15,12 +15,12 @@
mod error;
pub use error::ErrorPage;
pub(crate) use error::render_error_pages;
pub(crate) use error::{render_error_pages, response_for_panic, route_not_found};
use crate::auth::CurrentUser;
use crate::base::action;
use crate::core::component::{AssetsOp, ChildOp, Context, ContextError, Contextual};
use crate::core::theme::{DefaultRegion, Region, RegionRef, TemplateRef, ThemeRef};
use crate::core::theme::{DefaultRegions, Region, RegionRef, TemplateRef, ThemeRef};
use crate::html::{Assets, Favicon, JavaScript, StyleSheet};
use crate::html::{Attr, Props, PropsOp};
use crate::html::{DOCTYPE, Markup, html};
@ -112,6 +112,13 @@ impl Page {
}
}
/// Crea una nueva instancia de página con la plantilla de administración del tema activo.
pub fn admin(request: HttpRequest) -> Self {
let mut page = Page::new(request);
page.context().use_admin_template();
page
}
// **< Page BUILDER >***************************************************************************
/// Establece el título de la página como un valor traducible.
@ -304,7 +311,7 @@ impl Contextual for Page {
#[builder_fn]
fn with_child(mut self, op: impl Into<ChildOp>) -> Self {
self.context
.alter_child_in(&DefaultRegion::Content, op.into());
.alter_child_in(&DefaultRegions::Content, op.into());
self
}

View file

@ -2,12 +2,17 @@ use axum::extract::Request;
use axum::middleware::Next;
use crate::core::component::Contextual;
use crate::html::Markup;
use crate::locale::L10n;
use crate::util;
use crate::web::{HttpRequest, IntoResponse, Response, http};
use crate::{trace, util};
use super::Page;
use std::any::Any;
// **< Errores controlados >************************************************************************
/// Página de error asociada a un código de estado HTTP.
///
/// Este enumerado agrupa tipos esenciales de error que pueden devolverse como página HTML completa.
@ -15,7 +20,7 @@ use super::Page;
/// de estado concreto.
///
/// Para cada error se construye una [`Page`] usando el tema activo, lo que permite personalizar la
/// plantilla y el contenido del mensaje mediante los métodos específicos del tema (por ejemplo,
/// plantilla y el contenido del mensaje mediante los métodos específicos del tema (como
/// [`Theme::error_403()`](crate::core::theme::Theme::error_403),
/// [`Theme::error_404()`](crate::core::theme::Theme::error_404) o
/// [`Theme::error_fatal()`](crate::core::theme::Theme::error_fatal)).
@ -95,14 +100,58 @@ impl IntoResponse for ErrorPage {
}
}
// Intercepta respuestas con un [`ErrorPage`] pendiente y las convierte en páginas HTML completas
// usando el tema activo.
// Gestión de las rutas sin coincidencia.
//
// Se registra globalmente sobre el router principal desde [`crate::app`].
pub(crate) async fn render_error_pages(req: Request, next: Next) -> Response {
let mut response = next.run(req).await;
// Se registra como `.fallback()` del router principal desde [`Application`](crate::Application).
pub(crate) async fn route_not_found(request: HttpRequest) -> Result<Markup, ErrorPage> {
Err(ErrorPage::NotFound(request))
}
// Intercepta respuestas con un [`ErrorPage`] pendiente y las convierte en páginas HTML.
//
// Se registra globalmente sobre el router principal desde [`Application`](crate::Application).
pub(crate) async fn render_error_pages(request: Request, next: Next) -> Response {
let mut response = next.run(request).await;
if let Some(error_page) = response.extensions_mut().remove::<ErrorPage>() {
return error_page.render_html().await;
}
response
}
// **< Fallo catastrófico >*************************************************************************
// HTML mínimo para un fallo catastrófico (`panic!`). No usa el tema, ni componentes, ni acceso a
// datos, porque el fallo podría estar precisamente ahí. Tampoco se traduce porque ni siquiera el
// sistema de localización es seguro; tampoco hay forma de elegir idioma vía `Accept-Language`.
const FATAL_ERROR_HTML: &str = concat!(
"<!DOCTYPE html>",
"<html lang=\"en\">",
"<head>",
"<meta charset=\"utf-8\">",
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
"<title>Unexpected error</title>",
"</head><body>",
"<h1>An unexpected error has occurred</h1>",
"<p>Sorry for the inconvenience. Please try again or contact your system administrator.</p>",
"</body>",
"</html>",
);
// Captura el fallo catastrófico (`panic!`) con el motivo para diagnóstico y responde un HTTP 500.
//
// Se registra sobre el router principal desde [`Application`](crate::Application).
pub(crate) fn response_for_panic(err: Box<dyn Any + Send + 'static>) -> Response {
let reason = err
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| err.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panic with no message".to_string());
trace::error!(panic = %reason, "Unhandled panic caught by CatchPanicLayer");
(
http::StatusCode::INTERNAL_SERVER_ERROR,
[(http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
FATAL_ERROR_HTML,
)
.into_response()
}