pagetop/src/response/waypoint.rs

177 lines
7.2 KiB
Rust

//! Parámetro `waypoint`, URL de destino transportada entre pantallas.
use serde::Deserialize;
use crate::AutoDefault;
use crate::html::RoutePath;
/// URL de destino transportada por el parámetro `waypoint`.
///
/// Cualquier pantalla alcanzada desde un listado (alta, edición, confirmación, o una cadena de
/// varias de ellas) puede recibir en su *query string* un parámetro `waypoint` con la URL de la
/// página a la que ir para continuar.
///
/// `Waypoint` transporta ese valor a través de la cadena de pantallas. Se extrae de la petición
/// entrante con [`web::Query`](crate::web::Query), igual que cualquier otro parámetro, y se repone
/// en cada enlace o acción de formulario intermedia con [`append_to()`](Self::append_to) para que
/// sobreviva a la siguiente petición. Se resuelve con [`or()`](Self::or) al decidir el destino de
/// un enlace de vuelta o de una redirección.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// const ITEMS_PATH: &str = "/items";
///
/// /// GET /items/{id}/edit - Formulario de edición, alcanzado desde el listado.
/// async fn edit_get(
/// web::Path(id): web::Path<i32>,
/// web::Query(waypoint): web::Query<Waypoint>,
/// ) -> Markup {
/// // El listado de origen (si lo hay) viaja en la acción del formulario, para volver después.
/// let action = waypoint.append_to(format!("{ITEMS_PATH}/{id}/edit"));
/// html! { form action=(action) method="post" { /* ... */ } }
/// }
///
/// /// POST /items/{id}/edit - Guarda los cambios y vuelve al listado de origen, o a `ITEMS_PATH`.
/// async fn edit_post(
/// web::Path(id): web::Path<i32>,
/// web::Query(waypoint): web::Query<Waypoint>,
/// ) -> Response {
/// Redirect::see_other(waypoint.or(ITEMS_PATH))
/// }
/// ```
#[derive(AutoDefault, Clone, Debug, Deserialize)]
#[serde(from = "RawWaypoint")]
pub struct Waypoint {
waypoint: Option<String>,
}
impl Waypoint {
/// Crea un valor explícito.
///
/// Sólo acepta una ruta local que empiece por `/`, pero no por `//` ni `/\` que el navegador
/// interpretaría como una URL *protocol-relative*. Una cadena vacía, una URL absoluta
/// (`https://...`), un esquema arbitrario (`javascript:...`) o cualquier otro valor que no
/// cumpla esa forma se trata igual que `None`.
///
/// Este filtro protege contra *open redirect*; el `waypoint` viaja en la *query string* de la
/// petición, así que un cliente malicioso lo controla por completo. Por eso se aplica siempre,
/// también al deserializar con [`web::Query`](crate::web::Query), el punto de entrada habitual
/// donde el valor procede de fuera de la aplicación.
pub fn new(waypoint: impl Into<Option<String>>) -> Self {
Self {
waypoint: waypoint.into().filter(|d| {
// Sólo acepta `/algo`. Rechaza cadenas vacías, esquemas (`https:`, `javascript:`) y
// las URLs *protocol-relative* (`//evil.example`, `/\evil.example`).
let mut chars = d.chars();
match chars.next() {
Some('/') => !matches!(chars.next(), Some('/') | Some('\\')),
_ => false,
}
}),
}
}
/// Devuelve la URL de destino, si se proporcionó una y es una ruta local válida.
pub fn as_deref(&self) -> Option<&str> {
self.waypoint.as_deref()
}
/// Añade `?waypoint=<url codificada>` a `route`, si hay una URL de destino propia. Si no,
/// devuelve `route` sin modificar.
///
/// Es la operación habitual para que un enlace o la acción de un formulario transporten el
/// waypoint a la siguiente pantalla. Igual que [`or()`](Self::or), devuelve un [`RoutePath`]
/// convertible directamente en [`Route`](crate::core::component::Route) sin volver a
/// procesarse. A diferencia de [`or()`](Self::or), nunca sustituye `route`; úsalo para propagar
/// el waypoint a un enlace intermedio, no para resolver un destino final.
///
/// Aparece en dos momentos típicos: el `action` de un formulario, para que un envío no pierda
/// el waypoint (ver el ejemplo del módulo), y el `href` de los enlaces que un listado genera
/// hacia otras pantallas (ver el ejemplo).
///
/// Preserva cualquier *query string* ya presente en `route` sin romperla (como `lang=...` si
/// `route` se construyó con [`Context::route()`](crate::core::component::Context::route)).
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// const ITEMS_PATH: &str = "/items";
///
/// fn edit_href(cx: &Context, waypoint: &Waypoint, item_id: i32) -> RoutePath {
/// waypoint.append_to(cx.route(format!("{ITEMS_PATH}/{item_id}/edit")))
/// }
/// ```
pub fn append_to(&self, route: impl Into<RoutePath>) -> RoutePath {
let mut route = route.into();
if let Some(d) = self.as_deref() {
route.alter_param("waypoint", d);
}
route
}
/// Devuelve la URL de destino, o `fallback` si no se proporcionó ninguna.
///
/// Es la operación habitual al decidir el destino de un enlace de vuelta o de una redirección:
/// el waypoint transportado si lo hay, o la URL del listado por defecto si se llegó a esta
/// pantalla sin pasar por ninguno. A diferencia de [`append_to()`](Self::append_to), sustituye
/// por completo el destino. Úsalo cuando necesites un único `RoutePath` final, no para propagar
/// el waypoint a otro enlace intermedio.
///
/// Aparece en dos momentos típicos de una misma pantalla: el `href` del enlace "volver" al
/// renderizarla (ver el ejemplo) y el destino de `Redirect::see_other(...)` tras guardar con
/// éxito en un POST, como en el ejemplo del módulo.
///
/// Devuelve un [`RoutePath`], no una cadena ya renderizada, para que el resultado pueda
/// convertirse directamente en una [`Route`](crate::core::component::Route) (por ejemplo al
/// pasarlo a [`Form::with_action()`](crate::base::component::Form::with_action)) sin volver a
/// procesarse. `fallback` acepta cualquier tipo convertible a `RoutePath` (un literal, un
/// `String`, o un `RoutePath` ya construido); preservando los parámetros que correspondan.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// const ITEMS_PATH: &str = "/items";
///
/// fn back_href(cx: &Context, waypoint: &Waypoint) -> RoutePath {
/// waypoint.or(cx.route(ITEMS_PATH))
/// }
/// ```
pub fn or(&self, fallback: impl Into<RoutePath>) -> RoutePath {
match self.as_deref() {
Some(d) => RoutePath::new(d.to_owned()),
None => fallback.into(),
}
}
}
impl From<Option<String>> for Waypoint {
fn from(waypoint: Option<String>) -> Self {
Self::new(waypoint)
}
}
impl From<String> for Waypoint {
fn from(waypoint: String) -> Self {
Self::new(Some(waypoint))
}
}
// Valor tal y como llega en la deserialización (p. ej. desde `web::Query`), antes de ser filtrado.
#[derive(Deserialize)]
struct RawWaypoint {
#[serde(default)]
waypoint: Option<String>,
}
impl From<RawWaypoint> for Waypoint {
fn from(raw: RawWaypoint) -> Self {
Self::new(raw.waypoint)
}
}