From 747e64ce34c34a6352d8a8061a3930aa3dd10509 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Mon, 27 Jul 2026 22:46:41 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20(auth):=20A=C3=B1ade=20permisos=20t?= =?UTF-8?q?ipados?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sustituye las claves de permiso `&str` sueltas por el trait `Permission` (clave, etiqueta y grupo), y añade `require_permission()` para cortar un handler con `ErrorPage::AccessDenied` antes de construir nada. Para que `require_permission()` pueda fallar sin un `Context` previo, `ErrorPage` pasa a envolver `Option`, `Context::new()` recibe ahora `HttpRequest` sin `Option`, y se añade `Context::admin()` para la plantilla de administración. --- src/auth.rs | 187 ++++++++++++++++++++++++++++------ src/core/component/context.rs | 126 ++++++++++++++--------- src/core/component/route.rs | 2 +- src/html/assets/javascript.rs | 2 +- src/response/page.rs | 32 +++--- src/response/page/error.rs | 54 +++++++--- tests/auth.rs | 2 +- tests/component_html.rs | 2 +- tests/component_template.rs | 6 +- tests/route.rs | 4 +- 10 files changed, 297 insertions(+), 120 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 39a36fbc..1ace470a 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,31 +1,44 @@ //! Identidad del usuario y sistema de autorización extensible. //! -//! Define el tipo mínimo [`CurrentUser`] que PageTop inyecta en el [`Context`] de cada petición. -//! Incluye también la acción [`CheckPermission`] para que las extensiones puedan implementar sus -//! propios modelos de permisos, y la función auxiliar [`has_permission()`]. +//! Define el tipo [`CurrentUser`] que PageTop inyecta en el [`Context`] con la información mínima +//! sobre el usuario que ejecuta la petición actual ([`HttpRequest`]). +//! +//! Incluye la acción [`CheckPermission`] para que las extensiones puedan implementar sus propios +//! modelos de permisos. Y también las funciones auxiliares [`has_permission()`] y +//! [`require_permission()`] para validar en el comienzo de cada handler, antes de construir ni +//! ejecutar nada, si la petición está autorizada. //! //! La resolución concreta del usuario (sesión en BD, LDAP, OAuth, ...) y la lógica de permisos //! (RBAC, grupos LDAP, ...) son responsabilidad de las extensiones de autenticación. Un concepto -//! como "administrador" que tiene todos los permisos no es responsabilidad del core: cada extensión -//! decide si existe y, si es así, lo aplica dentro de su propio handler [`CheckPermission`]. +//! como "administrador" que tiene todos los permisos no es responsabilidad de PageTop: cada +//! extensión decide si existe y, si es así, lo aplica dentro de su propio handler +//! [`CheckPermission`]. +//! +//! [`Context`]: crate::core::component::Context use crate::core::action::{ActionDispatcher, ActionKey, try_dispatch_actions}; -use crate::core::component::Context; -use crate::{UniqueId, Weight}; +use crate::locale::L10n; +use crate::response::ErrorPage; +use crate::web::HttpRequest; +use crate::{CowStr, UniqueId, Weight}; // **< CurrentUser >******************************************************************************** /// Identidad mínima del usuario que ejecuta la petición actual. /// -/// Se almacena automáticamente en el [`Context`] a partir de la petición HTTP (ver -/// [`Context::new()`](crate::core::component::Context::new)). La identidad se extrae de las -/// extensiones de la petición, que una extensión de autenticación inyecta mediante su middleware. +/// Se almacena automáticamente en el [`Context`] a partir de la petición HTTP. La identidad se +/// extrae de las extensiones de la petición, que una extensión de autenticación inyecta mediante su +/// middleware. /// -/// Se accede con [`Contextual::current_user()`](crate::core::component::Contextual::current_user). +/// Se accede usando [`Contextual::current_user()`]. /// /// Los datos extendidos del usuario autenticado (roles, permisos, cuenta completa, ...) son /// responsabilidad de la extensión de autenticación y se obtienen a través de -/// [`HttpRequest::extension`](crate::web::HttpRequest::extension). +/// [`HttpRequest::extension`]. +/// +/// [`Context`]: crate::core::component::Context +/// [`Contextual::current_user()`]: crate::core::component::Contextual::current_user +/// [`HttpRequest::extension`]: crate::web::HttpRequest::extension #[derive(Clone, Debug)] pub enum CurrentUser { /// Usuario no autenticado. @@ -67,31 +80,93 @@ impl CurrentUser { } } +// **< Permission >********************************************************************************* + +/// Clave tipada de un permiso de acceso. +/// +/// Cada extensión que lo requiera puede definir su propio enum de permisos e implementar este trait +/// para obtener la clave textual que finalmente se compara contra su modelo de permisos (RBAC en +/// base de datos, grupos LDAP, ...). +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// # use pagetop::auth::Permission; +/// # use pagetop::CowStr; +/// #[derive(Clone, Copy, Debug)] +/// pub enum MyPermission { +/// EditPosts, +/// DeletePosts, +/// } +/// +/// impl Permission for MyPermission { +/// fn key(&self) -> CowStr { +/// match self { +/// Self::EditPosts => "my_extension.edit_posts".into(), +/// Self::DeletePosts => "my_extension.delete_posts".into(), +/// } +/// } +/// } +/// ``` +pub trait Permission: Send + Sync { + /// Clave única del permiso (p. ej. `"my_extension.edit_posts"`). + fn key(&self) -> CowStr; + + /// Descripción breve para humanos (p. ej. en una pantalla de asignación de permisos a roles). + /// + /// Por defecto devuelve la propia clave; una extensión que registre sus permisos en un catálogo + /// visible debería sobrescribirlo con un texto traducible. + fn label(&self) -> L10n { + L10n::n(self.key()) + } + + /// Identificador estable de la categoría del permiso, usado para agrupar en un catálogo (p. + /// ej. `"administration"`). Por defecto no pertenece a ningún grupo. + fn group(&self) -> &'static str { + "" + } + + /// Título traducible de [`group()`](Self::group), mostrado en la UI de administración. + /// + /// Por defecto reutiliza el propio identificador del grupo como texto fijo. + fn group_label(&self) -> L10n { + L10n::n(self.group()) + } +} + +/// Referencia estática a un permiso de acceso. +/// +/// Es el tipo que recorre toda la API de autorización ([`has_permission()`], +/// [`require_permission()`] o [`CheckPermission`]). +pub type PermissionRef = &'static dyn Permission; + // **< CheckPermission >**************************************************************************** /// Tipo de función para comprobar si el usuario actual tiene un permiso concreto. /// /// Se invoca con: /// -/// - `cx`: el contexto de renderizado desde el que se puede acceder a la petición HTTP y a -/// cualquier dato inyectado por el middleware de autenticación. -/// - `key`: clave del permiso a comprobar (p. ej. `"myapp.edit_posts"`). +/// - `request`: petición HTTP desde la que se accede a los datos inyectados por el middleware de +/// autenticación. +/// - `perm`: permiso a comprobar; el handler usará [`Permission::key()`] para identificarlo contra +/// su propio modelo de permisos. /// - `granted`: referencia mutable; el handler debe asignarla a `true` si concede el permiso. -pub type FnCheckPermission = fn(cx: &Context, key: &str, granted: &mut bool); +pub type FnActionCheckPerm = fn(request: &HttpRequest, perm: PermissionRef, granted: &mut bool); /// Acción para comprobar si el usuario actual tiene un permiso concreto. /// -/// Las extensiones de autenticación registran handlers de esta acción para implementar su modelo de -/// permisos. Los handlers son aditivos: si cualquiera de ellos asigna `granted = true`, el permiso -/// se concede. +/// Las extensiones de autenticación pueden registrar su handler sobre esta acción para implementar +/// su modelo de permisos. Los handlers son aditivos de tal forma que si cualquiera de ellos asigna +/// `granted = true`, el permiso se concede. /// /// # Ejemplo /// /// ```rust,no_run /// # use pagetop::prelude::*; -/// fn check_my_permissions(cx: &Context, key: &str, granted: &mut bool) { -/// // Leer datos extendidos de autenticación desde las extensiones de la petición. -/// // Si el usuario tiene el permiso, asignar `*granted = true`. +/// fn check_my_permissions(request: &HttpRequest, perm: PermissionRef, granted: &mut bool) { +/// // Leer los datos extendidos de autenticación inyectados en la petición. +/// // Comparar `perm.key()` contra el modelo propio. +/// // Si concede el permiso, asignar `*granted = true`. /// } /// /// pub struct MyAuth; @@ -104,7 +179,7 @@ pub type FnCheckPermission = fn(cx: &Context, key: &str, granted: &mut bool); /// } /// ``` pub struct CheckPermission { - f: FnCheckPermission, + f: FnActionCheckPerm, weight: Weight, } @@ -116,7 +191,7 @@ impl ActionDispatcher for CheckPermission { impl CheckPermission { /// Registra una nueva acción para la comprobación de permisos. - pub fn new(f: FnCheckPermission) -> Self { + pub fn new(f: FnActionCheckPerm) -> Self { CheckPermission { f, weight: 0 } } @@ -128,12 +203,12 @@ impl CheckPermission { // Despacha las acciones registradas con salida anticipada en cuanto una concede el permiso. #[inline] - pub(crate) fn check(cx: &Context, key: &str) -> bool { + pub(crate) fn check(request: &HttpRequest, perm: PermissionRef) -> bool { let mut granted = false; try_dispatch_actions( &ActionKey::new(UniqueId::of::(), None, None), |action: &Self| { - (action.f)(cx, key, &mut granted); + (action.f)(request, perm, &mut granted); if granted { std::ops::ControlFlow::Break(()) } else { @@ -160,14 +235,62 @@ impl CheckPermission { /// /// ```rust,no_run /// # use pagetop::prelude::*; +/// # #[derive(Clone, Copy, Debug)] +/// # enum MyPermission { Edit } +/// # impl Permission for MyPermission { +/// # fn key(&self) -> CowStr { "myapp.edit".into() } +/// # } /// async fn my_handler(request: HttpRequest) -> Result { -/// let mut page = Page::new(request.clone()); -/// if !has_permission(page.context(), "myapp.edit") { -/// return Err(ErrorPage::NotFound(request)); +/// if !has_permission(&request, &MyPermission::Edit) { +/// return Err(ErrorPage::NotFound(Some(request))); /// } -/// page.render().await +/// Page::new(request).render().await /// } /// ``` -pub fn has_permission(cx: &Context, key: &str) -> bool { - CheckPermission::check(cx, key) +pub fn has_permission(request: &HttpRequest, perm: PermissionRef) -> bool { + CheckPermission::check(request, perm) +} + +// **< require_permission >************************************************************************* + +/// Comprueba un permiso y devuelve `Err(ErrorPage::AccessDenied)` si se deniega. +/// +/// Ejecuta [`has_permission()`] para el caso más habitual: detener un handler con una respuesta 403 +/// en cuanto falta el permiso, sin repetir el `if`/`return` en cada punto de comprobación. Se hace +/// directamente sobre la petición, antes de construir ni ejecutar nada (`Context`, `Page`, +/// consultas a datos, etc.), para no hacer ningún trabajo si la petición no está autorizada. +/// +/// Si la aplicación necesita ocultar la existencia del recurso a quien no tiene permiso (devolver +/// un 404 en vez de un 403), no se puede reutilizar esta función: hay que llamar a +/// `has_permission()` directamente, como en su propio ejemplo. +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// # #[derive(Clone, Copy, Debug)] +/// # enum MyPermission { Edit } +/// # impl Permission for MyPermission { +/// # fn key(&self) -> CowStr { "myapp.edit".into() } +/// # } +/// async fn my_handler(request: HttpRequest) -> Result { +/// // Comprueba si la petición está autorizada. +/// require_permission(&request, &MyPermission::Edit)?; +/// +/// // Ejecuta las instrucciones propias de la petición. +/// Page::new(request) +/// .with_child(Html::with(|_| html! { p { "You have permission!" } })) +/// .render() +/// .await +/// } +/// ``` +// `ErrorPage` incluye `Option` en cada variante y es el tipo de error ya establecido +// para toda la respuesta HTTP; boxearlo aquí sólo para esta función no compensa. +#[allow(clippy::result_large_err)] +pub fn require_permission(request: &HttpRequest, perm: PermissionRef) -> Result<(), ErrorPage> { + if has_permission(request, perm) { + Ok(()) + } else { + Err(ErrorPage::AccessDenied(Some(request.clone()))) + } } diff --git a/src/core/component/context.rs b/src/core/component/context.rs index b6e95457..e988b6a3 100644 --- a/src/core/component/context.rs +++ b/src/core/component/context.rs @@ -62,7 +62,7 @@ pub enum ContextError { /// `Contextual` extiende [`LangId`] para establecer el idioma del documento y añade métodos para: /// /// - Almacenar la **petición HTTP** de origen. -/// - Seleccionar el **tema** y la **plantilla** de renderizado. +/// - Seleccionar la **plantilla** y el **tema** de renderizado. /// - Administrar **recursos** del documento como el icono [`Favicon`], las hojas de estilo /// [`StyleSheet`] o los scripts [`JavaScript`] mediante [`AssetsOp`]. /// - Leer y mantener **parámetros dinámicos tipados** de contexto. @@ -77,8 +77,8 @@ pub enum ContextError { /// # use pagetop_aliner::Aliner; /// fn prepare_context(cx: C) -> C { /// cx.with_langid(&Locale::resolve("es-ES")) -/// .with_theme(&Aliner) /// .with_template(&CoreTemplate::Standard) +/// .with_theme(&Aliner) /// .with_assets(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico")))) /// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/app.css"))) /// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/app.js"))) @@ -93,17 +93,23 @@ pub trait Contextual: LangId { fn with_langid(self, language: &impl LangId) -> Self; /// Almacena la petición HTTP de origen en el contexto. + /// + /// También recalcula el idioma ([`RequestLocale::from_request()`]) y + /// [`current_user()`](Self::current_user) a partir de la petición indicada, descartando + /// cualquier idioma forzado antes con [`with_langid()`](Self::with_langid) o el usuario ya + /// resuelto. Si necesitas forzar el idioma o el usuario, llama a `with_request()` primero en + /// la cadena de construcción, nunca después. #[builder_fn] fn with_request(self, request: Option) -> Self; - /// Especifica el tema para renderizar el documento. - #[builder_fn] - fn with_theme(self, theme: ThemeRef) -> Self; - /// Especifica la plantilla para renderizar el documento. #[builder_fn] fn with_template(self, template: TemplateRef) -> Self; + /// Especifica el tema para renderizar el documento. + #[builder_fn] + fn with_theme(self, theme: ThemeRef) -> Self; + /// Añade o modifica un parámetro dinámico del contexto. /// /// El valor se almacena junto con el nombre de su tipo, lo que permite generar mensajes de @@ -114,7 +120,7 @@ pub trait Contextual: LangId { /// /// ```rust,no_run /// # use pagetop::prelude::*; - /// let cx = Context::new(None) + /// let cx = Context::default() /// .with_param("user_id", 42_i32) /// .with_param("title", "Hello".to_string()) /// .with_param("flags", vec!["a", "b"]); @@ -165,12 +171,12 @@ pub trait Contextual: LangId { /// ``` fn current_user(&self) -> &CurrentUser; - /// Devuelve el tema que se usará para renderizar el documento. - fn theme(&self) -> ThemeRef; - /// Devuelve la plantilla configurada para renderizar el documento. fn template(&self) -> TemplateRef; + /// Devuelve el tema que se usará para renderizar el documento. + fn theme(&self) -> ThemeRef; + /// Recupera una *referencia tipada* al parámetro solicitado. /// /// Devuelve: @@ -183,7 +189,7 @@ pub trait Contextual: LangId { /// /// ```rust /// # use pagetop::prelude::*; - /// let cx = Context::new(None) + /// let cx = Context::default() /// .with_param("user_id", 42_i32) /// .with_param("title", "Hello".to_string()); /// @@ -230,7 +236,7 @@ pub trait Contextual: LangId { /// /// ```rust /// # use pagetop::prelude::*; - /// let mut cx = Context::new(None).with_param("temp", 1u8); + /// let mut cx = Context::default().with_param("temp", 1u8); /// assert!(cx.remove_param("temp")); /// assert!(!cx.remove_param("temp")); // ya no existe /// ``` @@ -243,7 +249,7 @@ pub trait Contextual: LangId { /// [`Page::new()`](crate::response::Page::new) o [`Page::admin()`](crate::response::Page::admin)), /// y es la única vía por la que un componente, una acción o el tema activo conocen: la petición /// HTTP de origen, el idioma negociado, el usuario autenticado -/// ([`current_user()`](Contextual::current_user)), el tema y la plantilla en uso, y los recursos +/// ([`current_user()`](Contextual::current_user)), la plantilla y el tema en uso, y los recursos /// (favicon, hojas de estilo, scripts) acumulados hasta ese momento. Otros datos que los /// componentes necesiten durante el renderizado pueden ser parámetros dinámicos tipados con /// [`with_param()`](Contextual::with_param)/[`param()`](Contextual::param). @@ -267,21 +273,21 @@ pub trait Contextual: LangId { /// ```rust,no_run /// # use pagetop::prelude::*; /// # use pagetop_aliner::Aliner; -/// fn new_context(request: HttpRequest) -> Context { -/// Context::new(Some(request)) -/// // Establece el idioma del documento a español. -/// .with_langid(&Locale::resolve("es-ES")) -/// // Establece el tema para renderizar. -/// .with_theme(&Aliner) -/// // Asigna un favicon. -/// .with_assets(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico")))) -/// // Añade una hoja de estilo externa. -/// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/style.css"))) -/// // Añade un script JavaScript. -/// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/main.js"))) -/// // Añade un parámetro dinámico al contexto. -/// .with_param("user_id", 42) -/// } +/// # fn new_context(request: HttpRequest) -> Context { +/// let cx = Context::new(request) +/// // Establece el idioma del documento a español. +/// .with_langid(&Locale::resolve("es-ES")) +/// // Establece el tema para renderizar. +/// .with_theme(&Aliner) +/// // Asigna un favicon. +/// .with_assets(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico")))) +/// // Añade una hoja de estilo externa. +/// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/style.css"))) +/// // Añade un script JavaScript. +/// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/main.js"))) +/// // Añade un parámetro dinámico al contexto. +/// .with_param("user_id", 42); +/// # cx } /// ``` /// /// Y hace operaciones con un contexto dado: @@ -309,8 +315,8 @@ pub struct Context { request : Option, // Petición HTTP de origen. locale : RequestLocale, // Idioma asociado a la petición. current_user: CurrentUser, // Identidad del usuario actual. - theme : ThemeRef, // Referencia al tema usado para renderizar. template : TemplateRef, // Plantilla usada para renderizar. + theme : ThemeRef, // Referencia al tema usado para renderizar. favicon : Option, // Favicon, si se ha definido. preloads : Assets, // Recursos para precarga. stylesheets : Assets, // Hojas de estilo CSS. @@ -324,25 +330,25 @@ pub struct Context { impl Default for Context { fn default() -> Self { - Context::new(None) + Self::base(None, &CoreTemplate::Standard) } } impl Context { - /// Crea un nuevo contexto asociado a una petición HTTP. - /// - /// El contexto inicializa el idioma, el tema y la plantilla por defecto, sin favicon ni otros - /// recursos cargados. + // Construye el `Context` compartido por `new()`, `admin()` y `Default::default()`, evitando + // duplicar la lista de campos entre ambos (y la recursión que tendría `new()` llamando a + // `Default::default()`, o viceversa). Recibe la plantilla para que `admin()` no tenga que + // construir con la plantilla estándar y sobrescribirla después. #[rustfmt::skip] - pub fn new(request: Option) -> Self { + fn base(request: Option, template: TemplateRef) -> Self { let locale = RequestLocale::from_request(request.as_ref()); let current_user = Self::resolve_current_user(request.as_ref()); Context { request, locale, current_user, + template, theme : *DEFAULT_THEME, - template : &CoreTemplate::Standard, favicon : None, preloads : Assets::::new(), stylesheets: Assets::::new(), @@ -355,6 +361,25 @@ impl Context { } } + /// Crea un nuevo contexto asociado a una petición HTTP. + /// + /// El contexto inicializa el idioma, el tema y la plantilla por defecto, sin favicon ni otros + /// recursos cargados. + /// + /// Para un contexto sin petición (renderizar un componente de forma aislada, en tests o fuera + /// del ciclo de una petición web), usa [`Context::default()`]. + pub fn new(request: HttpRequest) -> Self { + Self::base(Some(request), &CoreTemplate::Standard) + } + + /// Crea un nuevo contexto asociado a una petición HTTP, con la plantilla de administración. + /// + /// El contexto inicializa el idioma, el tema y la plantilla [`CoreTemplate::Admin`], sin + /// favicon ni otros recursos cargados. + pub fn admin(request: HttpRequest) -> Self { + Self::base(Some(request), &CoreTemplate::Admin) + } + // Extrae el `CurrentUser` inyectado por middleware en las extensiones de la petición, o // `CurrentUser::Anonymous` si no hay petición o ninguna extensión de autenticación está activa. fn resolve_current_user(request: Option<&HttpRequest>) -> CurrentUser { @@ -483,7 +508,7 @@ impl Context { /// /// ```rust,no_run /// # use pagetop::prelude::*; - /// # let mut cx = Context::new(None); + /// # let mut cx = Context::default(); /// cx.push_message(MessageLevel::Warning, L10n::n("Session is not valid")); /// ``` pub fn push_message(&mut self, level: MessageLevel, text: L10n) { @@ -501,14 +526,19 @@ impl Context { } } -/// Permite a [`Context`](crate::core::component::Context) actuar como proveedor de idioma. +/// Permite a [`Context`] actuar como proveedor de idioma. /// /// Internamente delega en [`RequestLocale`], que tiene en cuenta la petición HTTP, la configuración /// global de idioma de la aplicación, la cabecera `Accept-Language` y/o el idioma de respaldo. /// /// Todo ello según la negociación indicada en [`global::SETTINGS.app.lang_negotiation`]. Esto -/// permite que el [`Context`] se use como fuente de idioma coherente en -/// [`L10n::lookup()`](crate::locale::L10n::lookup) o [`L10n::using()`](crate::locale::L10n::using). +/// permite que el [`Context`] se use como fuente de idioma coherente en [`L10n::lookup()`] o +/// [`L10n::using()`]. +/// +/// [`Context`]: crate::core::component::Context +/// [`global::SETTINGS.app.lang_negotiation`]: crate::global::App::lang_negotiation +/// [`L10n::lookup()`]: crate::locale::L10n::lookup +/// [`L10n::using()`]: crate::locale::L10n::using impl LangId for Context { #[inline] fn langid(&self) -> &'static LanguageIdentifier { @@ -536,14 +566,14 @@ impl Contextual for Context { } #[builder_fn] - fn with_theme(mut self, theme: ThemeRef) -> Self { - self.theme = theme; + fn with_template(mut self, template: TemplateRef) -> Self { + self.template = template; self } #[builder_fn] - fn with_template(mut self, template: TemplateRef) -> Self { - self.template = template; + fn with_theme(mut self, theme: ThemeRef) -> Self { + self.theme = theme; self } @@ -619,14 +649,14 @@ impl Contextual for Context { &self.current_user } - fn theme(&self) -> ThemeRef { - self.theme - } - fn template(&self) -> TemplateRef { self.template } + fn theme(&self) -> ThemeRef { + self.theme + } + fn param(&self, key: &'static str) -> Result<&T, ContextError> { let (any, type_name) = self.params.get(key).ok_or(ContextError::ParamNotFound)?; any.downcast_ref::() diff --git a/src/core/component/route.rs b/src/core/component/route.rs index 6636c687..a9c468ce 100644 --- a/src/core/component/route.rs +++ b/src/core/component/route.rs @@ -96,7 +96,7 @@ use std::sync::Arc; /// /// ```rust,no_run /// # use pagetop::prelude::*; -/// # let cx = Context::new(None); +/// # let cx = Context::default(); /// # let waypoint = Waypoint::default(); /// let route: Route = waypoint.append_to(cx.route("/items")).into(); /// ``` diff --git a/src/html/assets/javascript.rs b/src/html/assets/javascript.rs index 26a9e028..7f35629b 100644 --- a/src/html/assets/javascript.rs +++ b/src/html/assets/javascript.rs @@ -59,7 +59,7 @@ enum Source { /// "#.to_string()); /// /// // Script embebido con handler asíncrono (`async`) que puede usar `await`. -/// let mut cx = Context::new(None).with_param("user_id", 7u32); +/// let mut cx = Context::default().with_param("user_id", 7u32); /// /// let js = JavaScript::on_load_async("hydrate", |cx| { /// // Ejemplo: lectura de un parámetro del contexto para inyectarlo en el código. diff --git a/src/response/page.rs b/src/response/page.rs index cec7c776..d57e8288 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -23,7 +23,7 @@ use crate::base::action; use crate::base::component::layout; use crate::core::component::{AssetsOp, ChildOp, ComponentRender}; use crate::core::component::{Context, ContextError, Contextual}; -use crate::core::theme::{CoreRegion, CoreTemplate, RegionName, RegionRef, TemplateRef, ThemeRef}; +use crate::core::theme::{CoreRegion, RegionName, RegionRef, TemplateRef, ThemeRef}; use crate::html::{Assets, Favicon, JavaScript, StyleSheet}; use crate::html::{Attr, Props, PropsOp}; use crate::html::{DOCTYPE, Markup, html}; @@ -104,7 +104,7 @@ impl Page { /// usuario actual desde el momento en que se crea la página, sin llamadas adicionales. pub fn new(request: HttpRequest) -> Self { Page { - context: Context::new(Some(request)), + context: Context::new(request), ..Default::default() } } @@ -114,9 +114,11 @@ impl Page { /// Cada tema puede maquetarla de forma distinta capturando /// [`Template`](crate::base::component::layout::Template) en `handle_component()`, pero la /// plantilla en sí es la misma constante para cualquier tema. + /// + /// [`CoreTemplate::Admin`]: crate::core::theme::CoreTemplate::Admin pub fn admin(request: HttpRequest) -> Self { Page { - context: Context::new(Some(request)).with_template(&CoreTemplate::Admin), + context: Context::admin(request), ..Default::default() } } @@ -154,22 +156,22 @@ impl Page { // **< Page GETTERS >*************************************************************************** /// Devuelve el título traducido para el idioma de la página, si existe. - pub fn title(&mut self) -> Option { + pub fn title(&self) -> Option { self.title.lookup(&self.context) } /// Devuelve la descripción traducida para el idioma de la página, si existe. - pub fn description(&mut self) -> Option { + pub fn description(&self) -> Option { self.description.lookup(&self.context) } /// Devuelve la lista de metadatos ``. - pub fn metadata(&self) -> &Vec<(&str, &str)> { + pub fn metadata(&self) -> &Vec<(&'static str, &'static str)> { &self.metadata } /// Devuelve la lista de propiedades ``. - pub fn properties(&self) -> &Vec<(&str, &str)> { + pub fn properties(&self) -> &Vec<(&'static str, &'static str)> { &self.properties } @@ -281,14 +283,14 @@ impl Contextual for Page { } #[builder_fn] - fn with_theme(mut self, theme: ThemeRef) -> Self { - self.context.alter_theme(theme); + fn with_template(mut self, template: TemplateRef) -> Self { + self.context.alter_template(template); self } #[builder_fn] - fn with_template(mut self, template: TemplateRef) -> Self { - self.context.alter_template(template); + fn with_theme(mut self, theme: ThemeRef) -> Self { + self.context.alter_theme(theme); self } @@ -332,14 +334,14 @@ impl Contextual for Page { self.context.current_user() } - fn theme(&self) -> ThemeRef { - self.context.theme() - } - fn template(&self) -> TemplateRef { self.context.template() } + fn theme(&self) -> ThemeRef { + self.context.theme() + } + fn param(&self, key: &'static str) -> Result<&T, ContextError> { self.context.param(key) } diff --git a/src/response/page/error.rs b/src/response/page/error.rs index 69d4c764..4be40f31 100644 --- a/src/response/page/error.rs +++ b/src/response/page/error.rs @@ -16,22 +16,41 @@ use std::any::Any; /// 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. -/// Cada variante encapsula la solicitud original ([`HttpRequest`]) y se corresponde con un código -/// de estado concreto. +/// Cada variante encapsula la petición original si está disponible ([`HttpRequest`]), y se asocia a +/// un código 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 (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)). +/// plantilla y el contenido del mensaje con los métodos específicos del tema, como +/// [`Theme::error_403()`], [`Theme::error_404()`] o [`Theme::error_fatal()`]. +/// +/// Sin `request` (`None`), la página se renderiza igualmente, pero sin el idioma negociado ni el +/// usuario actual, que dependen de la petición original. +/// +/// [`Theme::error_403()`]: crate::core::theme::Theme::error_403 +/// [`Theme::error_404()`]: crate::core::theme::Theme::error_404 +/// [`Theme::error_fatal()`]: crate::core::theme::Theme::error_fatal #[derive(Clone, Debug)] pub enum ErrorPage { - BadRequest(HttpRequest), - AccessDenied(HttpRequest), - NotFound(HttpRequest), - InternalError(HttpRequest), - ServiceUnavailable(HttpRequest), - GatewayTimeout(HttpRequest), + /// Petición incorrecta (400). El servidor no puede procesar la petición tal y como está + /// formulada (datos malformados, parámetros inválidos, etc.). + BadRequest(Option), + /// Acceso denegado (403). El usuario actual no tiene permiso para acceder al recurso. + /// + /// Se renderiza con [`Theme::error_403()`](crate::core::theme::Theme::error_403). + AccessDenied(Option), + /// Recurso no encontrado (404). La ruta solicitada no existe o no coincide con ningún handler. + /// + /// Se renderiza con [`Theme::error_404()`](crate::core::theme::Theme::error_404). + NotFound(Option), + /// Error interno del servidor (500). Un fallo controlado (no un `panic!`) impide completar la + /// petición. + InternalError(Option), + /// Servicio no disponible (503). El servidor no puede atender la petición temporalmente + /// (mantenimiento, sobrecarga, etc.). + ServiceUnavailable(Option), + /// Tiempo de espera agotado (504). Una dependencia externa (proxy, servicio remoto) no ha + /// respondido a tiempo. + GatewayTimeout(Option), } impl ErrorPage { @@ -53,12 +72,12 @@ impl ErrorPage { let status = self.status_code(); let mut page = match self { Self::AccessDenied(request) => { - let mut page = Page::new(request); + let mut page = Page::default().with_request(request); page.theme().error_403(&mut page); page } Self::NotFound(request) => { - let mut page = Page::new(request); + let mut page = Page::default().with_request(request); page.theme().error_404(&mut page); page } @@ -66,7 +85,7 @@ impl ErrorPage { | Self::InternalError(request) | Self::ServiceUnavailable(request) | Self::GatewayTimeout(request) => { - let mut page = Page::new(request); + let mut page = Page::default().with_request(request); page.theme().error_fatal( &mut page, status, @@ -84,6 +103,9 @@ impl ErrorPage { rendered.into_string(), ) .into_response(), + // Si renderizar la propia página de error falla, se descarta el `ErrorPage` resultante + // en vez de intentar renderizarlo de nuevo, para no arriesgarse a una recursión si el + // fallo persiste. Err(_) => status.into_response(), } } @@ -104,7 +126,7 @@ impl IntoResponse for ErrorPage { // // Se registra como `.fallback()` del router principal desde [`Application`](crate::Application). pub(crate) async fn route_not_found(request: HttpRequest) -> Result { - Err(ErrorPage::NotFound(request)) + Err(ErrorPage::NotFound(Some(request))) } // Intercepta respuestas con un [`ErrorPage`] pendiente y las convierte en páginas HTML. diff --git a/tests/auth.rs b/tests/auth.rs index 5ee33652..485cb68c 100644 --- a/tests/auth.rs +++ b/tests/auth.rs @@ -39,7 +39,7 @@ async fn current_user_propagates_from_request_extensions() { display_name: "Bob".to_owned(), }) .to_http_request(); - let cx = Context::new(Some(req)); + let cx = Context::new(req); let user = cx.current_user(); assert!(user.is_authenticated()); assert_eq!(user.id(), Some(7)); diff --git a/tests/component_html.rs b/tests/component_html.rs index 1eb6213e..a17267a2 100644 --- a/tests/component_html.rs +++ b/tests/component_html.rs @@ -50,7 +50,7 @@ async fn component_html_can_access_request_path() { let req = web::test::TestRequest::get() .uri("/hello/world") .to_http_request(); - let mut cx = Context::new(Some(req)); + let mut cx = Context::new(req); let mut component = Html::with(|cx| { let path = cx diff --git a/tests/component_template.rs b/tests/component_template.rs index cc61b8ce..725cd8a4 100644 --- a/tests/component_template.rs +++ b/tests/component_template.rs @@ -46,10 +46,10 @@ impl Theme for MarkerTheme { #[pagetop::test] async fn default_template_identity_is_independent_of_theme() { - let cx = Context::new(None); + let cx = Context::default(); assert_eq!(cx.template().name(), "standard"); - let cx = Context::new(None).with_theme(&MarkerTheme); + let cx = Context::default().with_theme(&MarkerTheme); assert_eq!(cx.template().name(), "standard"); } @@ -67,7 +67,7 @@ async fn admin_template_identity_is_independent_of_theme() { async fn explicit_template_is_not_overridden_by_a_later_with_theme() { // A template explicitly set with `with_template()` prevails even if `with_theme()` is called // afterwards. - let cx = Context::new(None) + let cx = Context::default() .with_template(&CoreTemplate::Admin) .with_theme(&pagetop::base::theme::Basic); diff --git a/tests/route.rs b/tests/route.rs index f1e64bb1..363e6bbf 100644 --- a/tests/route.rs +++ b/tests/route.rs @@ -3,7 +3,7 @@ use pagetop::prelude::*; // Forces an effective language different from the default negotiated one (en-US, with no `?lang` in // the request), so that `Context::route()` decides to propagate `?lang=...` in local routes. fn cx_with_lang(lang: &str) -> Context { - Context::new(None).with_langid(&Locale::resolve(lang)) + Context::default().with_langid(&Locale::resolve(lang)) } // **< Route - automatic detection of external URLs >*********************************************** @@ -107,7 +107,7 @@ async fn route_with_captures_dynamic_values_from_the_environment() { #[pagetop::test] async fn route_default_resolves_to_an_empty_path() { - let cx = Context::new(None); + let cx = Context::default(); assert_eq!(Route::default().resolve(&cx).to_string(), ""); }