mailpot/web/src/typed_paths.rs

560 lines
19 KiB
Rust

/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use super::*;
// from https://github.com/servo/rust-url/blob/master/url/src/parser.rs
const FRAGMENT: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'<').add(b'>').add(b'`');
const PATH: &AsciiSet = &FRAGMENT.add(b'#').add(b'?').add(b'{').add(b'}');
pub const PATH_SEGMENT: &AsciiSet = &PATH.add(b'/').add(b'%');
pub trait IntoCrumb: TypedPath {
fn to_crumb(&self) -> Cow<'static, str> {
Cow::from(self.to_uri().to_string())
}
}
impl<TP: TypedPath> IntoCrumb for TP {}
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
#[serde(untagged)]
pub enum ListPathIdentifier {
Pk(#[serde(deserialize_with = "parse_int")] i64),
Id(String),
}
fn parse_int<'de, T, D>(de: D) -> Result<T, D::Error>
where
D: serde::Deserializer<'de>,
T: std::str::FromStr,
<T as std::str::FromStr>::Err: std::fmt::Display,
{
use serde::Deserialize;
String::deserialize(de)?
.parse()
.map_err(serde::de::Error::custom)
}
impl From<i64> for ListPathIdentifier {
fn from(val: i64) -> Self {
Self::Pk(val)
}
}
impl From<String> for ListPathIdentifier {
fn from(val: String) -> Self {
Self::Id(val)
}
}
impl std::fmt::Display for ListPathIdentifier {
#[allow(clippy::unnecessary_to_owned)]
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let id: Cow<'_, str> = match self {
Self::Pk(id) => id.to_string().into(),
Self::Id(id) => id.into(),
};
write!(f, "{}", utf8_percent_encode(&id, PATH_SEGMENT,))
}
}
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
#[typed_path("/list/:id/")]
pub struct ListPath(pub ListPathIdentifier);
impl From<&DbVal<mailpot::models::MailingList>> for ListPath {
fn from(val: &DbVal<mailpot::models::MailingList>) -> Self {
Self(ListPathIdentifier::Id(val.id.clone()))
}
}
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
#[typed_path("/list/:id/posts/:msgid/")]
pub struct ListPostPath(pub ListPathIdentifier, pub String);
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
#[typed_path("/list/:id/edit/")]
pub struct ListEditPath(pub ListPathIdentifier);
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
#[typed_path("/settings/list/:id/")]
pub struct ListSettingsPath(pub ListPathIdentifier);
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
#[typed_path("/login/")]
pub struct LoginPath;
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
#[typed_path("/logout/")]
pub struct LogoutPath;
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
#[typed_path("/settings/")]
pub struct SettingsPath;
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
#[typed_path("/help/")]
pub struct HelpPath;
macro_rules! unit_impl {
($ident:ident, $ty:expr) => {
pub fn $ident() -> Value {
$ty.to_crumb().into()
}
};
}
unit_impl!(login_path, LoginPath);
unit_impl!(logout_path, LogoutPath);
unit_impl!(settings_path, SettingsPath);
unit_impl!(help_path, HelpPath);
macro_rules! list_id_impl {
($ident:ident, $ty:tt) => {
pub fn $ident(id: Value) -> std::result::Result<Value, Error> {
if let Some(id) = id.as_str() {
return Ok($ty(ListPathIdentifier::Id(id.to_string()))
.to_crumb()
.into());
}
let pk = id.try_into()?;
Ok($ty(ListPathIdentifier::Pk(pk)).to_crumb().into())
}
};
}
list_id_impl!(list_path, ListPath);
list_id_impl!(list_settings_path, ListSettingsPath);
list_id_impl!(list_edit_path, ListEditPath);
pub fn list_post_path(id: Value, msg_id: Value) -> std::result::Result<Value, Error> {
let Some(msg_id) = msg_id.as_str().map(|s| if s.starts_with('<') && s.ends_with('>') { s.to_string() } else {
format!("<{s}>")
}) else {
return Err(Error::new(
minijinja::ErrorKind::UnknownMethod,
"Second argument of list_post_path must be a string."
));
};
if let Some(id) = id.as_str() {
return Ok(ListPostPath(ListPathIdentifier::Id(id.to_string()), msg_id)
.to_crumb()
.into());
}
let pk = id.try_into()?;
Ok(ListPostPath(ListPathIdentifier::Pk(pk), msg_id)
.to_crumb()
.into())
}
pub mod tsr {
use std::{borrow::Cow, convert::Infallible};
use axum::{
http::Request,
response::{IntoResponse, Redirect, Response},
routing::{any, MethodRouter},
Router,
};
use axum_extra::routing::{RouterExt as ExtraRouterExt, SecondElementIs, TypedPath};
use http::{uri::PathAndQuery, StatusCode, Uri};
use tower_service::Service;
/// Extension trait that adds additional methods to [`Router`].
pub trait RouterExt<S, B>: ExtraRouterExt<S, B> {
/// Add a typed `GET` route to the router.
///
/// The path will be inferred from the first argument to the handler
/// function which must implement [`TypedPath`].
///
/// See [`TypedPath`] for more details and examples.
fn typed_get<H, T, P>(self, handler: H) -> Self
where
H: axum::handler::Handler<T, S, B>,
T: SecondElementIs<P> + 'static,
P: TypedPath;
/// Add a typed `DELETE` route to the router.
///
/// The path will be inferred from the first argument to the handler
/// function which must implement [`TypedPath`].
///
/// See [`TypedPath`] for more details and examples.
fn typed_delete<H, T, P>(self, handler: H) -> Self
where
H: axum::handler::Handler<T, S, B>,
T: SecondElementIs<P> + 'static,
P: TypedPath;
/// Add a typed `HEAD` route to the router.
///
/// The path will be inferred from the first argument to the handler
/// function which must implement [`TypedPath`].
///
/// See [`TypedPath`] for more details and examples.
fn typed_head<H, T, P>(self, handler: H) -> Self
where
H: axum::handler::Handler<T, S, B>,
T: SecondElementIs<P> + 'static,
P: TypedPath;
/// Add a typed `OPTIONS` route to the router.
///
/// The path will be inferred from the first argument to the handler
/// function which must implement [`TypedPath`].
///
/// See [`TypedPath`] for more details and examples.
fn typed_options<H, T, P>(self, handler: H) -> Self
where
H: axum::handler::Handler<T, S, B>,
T: SecondElementIs<P> + 'static,
P: TypedPath;
/// Add a typed `PATCH` route to the router.
///
/// The path will be inferred from the first argument to the handler
/// function which must implement [`TypedPath`].
///
/// See [`TypedPath`] for more details and examples.
fn typed_patch<H, T, P>(self, handler: H) -> Self
where
H: axum::handler::Handler<T, S, B>,
T: SecondElementIs<P> + 'static,
P: TypedPath;
/// Add a typed `POST` route to the router.
///
/// The path will be inferred from the first argument to the handler
/// function which must implement [`TypedPath`].
///
/// See [`TypedPath`] for more details and examples.
fn typed_post<H, T, P>(self, handler: H) -> Self
where
H: axum::handler::Handler<T, S, B>,
T: SecondElementIs<P> + 'static,
P: TypedPath;
/// Add a typed `PUT` route to the router.
///
/// The path will be inferred from the first argument to the handler
/// function which must implement [`TypedPath`].
///
/// See [`TypedPath`] for more details and examples.
fn typed_put<H, T, P>(self, handler: H) -> Self
where
H: axum::handler::Handler<T, S, B>,
T: SecondElementIs<P> + 'static,
P: TypedPath;
/// Add a typed `TRACE` route to the router.
///
/// The path will be inferred from the first argument to the handler
/// function which must implement [`TypedPath`].
///
/// See [`TypedPath`] for more details and examples.
fn typed_trace<H, T, P>(self, handler: H) -> Self
where
H: axum::handler::Handler<T, S, B>,
T: SecondElementIs<P> + 'static,
P: TypedPath;
/// Add another route to the router with an additional "trailing slash
/// redirect" route.
///
/// If you add a route _without_ a trailing slash, such as `/foo`, this
/// method will also add a route for `/foo/` that redirects to
/// `/foo`.
///
/// If you add a route _with_ a trailing slash, such as `/bar/`, this
/// method will also add a route for `/bar` that redirects to
/// `/bar/`.
///
/// This is similar to what axum 0.5.x did by default, except this
/// explicitly adds another route, so trying to add a `/foo/`
/// route after calling `.route_with_tsr("/foo", /* ... */)`
/// will result in a panic due to route overlap.
///
/// # Example
///
/// ```
/// use axum::{routing::get, Router};
/// use axum_extra::routing::RouterExt;
///
/// let app = Router::new()
/// // `/foo/` will redirect to `/foo`
/// .route_with_tsr("/foo", get(|| async {}))
/// // `/bar` will redirect to `/bar/`
/// .route_with_tsr("/bar/", get(|| async {}));
/// # let _: Router = app;
/// ```
fn route_with_tsr(self, path: &str, method_router: MethodRouter<S, B>) -> Self
where
Self: Sized;
/// Add another route to the router with an additional "trailing slash
/// redirect" route.
///
/// This works like [`RouterExt::route_with_tsr`] but accepts any
/// [`Service`].
fn route_service_with_tsr<T>(self, path: &str, service: T) -> Self
where
T: Service<Request<B>, Error = Infallible> + Clone + Send + 'static,
T::Response: IntoResponse,
T::Future: Send + 'static,
Self: Sized;
}
impl<S, B> RouterExt<S, B> for Router<S, B>
where
B: axum::body::HttpBody + Send + 'static,
S: Clone + Send + Sync + 'static,
{
fn typed_get<H, T, P>(mut self, handler: H) -> Self
where
H: axum::handler::Handler<T, S, B>,
T: SecondElementIs<P> + 'static,
P: TypedPath,
{
let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
self = self.route(
tsr_path.as_ref(),
axum::routing::get(move |url| tsr_handler_into_async(url, tsr_handler)),
);
self = self.route(P::PATH, axum::routing::get(handler));
self
}
fn typed_delete<H, T, P>(mut self, handler: H) -> Self
where
H: axum::handler::Handler<T, S, B>,
T: SecondElementIs<P> + 'static,
P: TypedPath,
{
let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
self = self.route(
tsr_path.as_ref(),
axum::routing::delete(move |url| tsr_handler_into_async(url, tsr_handler)),
);
self = self.route(P::PATH, axum::routing::delete(handler));
self
}
fn typed_head<H, T, P>(mut self, handler: H) -> Self
where
H: axum::handler::Handler<T, S, B>,
T: SecondElementIs<P> + 'static,
P: TypedPath,
{
let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
self = self.route(
tsr_path.as_ref(),
axum::routing::head(move |url| tsr_handler_into_async(url, tsr_handler)),
);
self = self.route(P::PATH, axum::routing::head(handler));
self
}
fn typed_options<H, T, P>(mut self, handler: H) -> Self
where
H: axum::handler::Handler<T, S, B>,
T: SecondElementIs<P> + 'static,
P: TypedPath,
{
let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
self = self.route(
tsr_path.as_ref(),
axum::routing::options(move |url| tsr_handler_into_async(url, tsr_handler)),
);
self = self.route(P::PATH, axum::routing::options(handler));
self
}
fn typed_patch<H, T, P>(mut self, handler: H) -> Self
where
H: axum::handler::Handler<T, S, B>,
T: SecondElementIs<P> + 'static,
P: TypedPath,
{
let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
self = self.route(
tsr_path.as_ref(),
axum::routing::patch(move |url| tsr_handler_into_async(url, tsr_handler)),
);
self = self.route(P::PATH, axum::routing::patch(handler));
self
}
fn typed_post<H, T, P>(mut self, handler: H) -> Self
where
H: axum::handler::Handler<T, S, B>,
T: SecondElementIs<P> + 'static,
P: TypedPath,
{
let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
self = self.route(
tsr_path.as_ref(),
axum::routing::post(move |url| tsr_handler_into_async(url, tsr_handler)),
);
self = self.route(P::PATH, axum::routing::post(handler));
self
}
fn typed_put<H, T, P>(mut self, handler: H) -> Self
where
H: axum::handler::Handler<T, S, B>,
T: SecondElementIs<P> + 'static,
P: TypedPath,
{
let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
self = self.route(
tsr_path.as_ref(),
axum::routing::put(move |url| tsr_handler_into_async(url, tsr_handler)),
);
self = self.route(P::PATH, axum::routing::put(handler));
self
}
fn typed_trace<H, T, P>(mut self, handler: H) -> Self
where
H: axum::handler::Handler<T, S, B>,
T: SecondElementIs<P> + 'static,
P: TypedPath,
{
let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
self = self.route(
tsr_path.as_ref(),
axum::routing::trace(move |url| tsr_handler_into_async(url, tsr_handler)),
);
self = self.route(P::PATH, axum::routing::trace(handler));
self
}
#[track_caller]
fn route_with_tsr(mut self, path: &str, method_router: MethodRouter<S, B>) -> Self
where
Self: Sized,
{
validate_tsr_path(path);
self = self.route(path, method_router);
add_tsr_redirect_route(self, path)
}
#[track_caller]
fn route_service_with_tsr<T>(mut self, path: &str, service: T) -> Self
where
T: Service<Request<B>, Error = Infallible> + Clone + Send + 'static,
T::Response: IntoResponse,
T::Future: Send + 'static,
Self: Sized,
{
validate_tsr_path(path);
self = self.route_service(path, service);
add_tsr_redirect_route(self, path)
}
}
#[track_caller]
fn validate_tsr_path(path: &str) {
if path == "/" {
panic!("Cannot add a trailing slash redirect route for `/`")
}
}
#[inline]
fn add_tsr_redirect_route<S, B>(router: Router<S, B>, path: &str) -> Router<S, B>
where
B: axum::body::HttpBody + Send + 'static,
S: Clone + Send + Sync + 'static,
{
async fn redirect_handler(uri: Uri) -> Response {
let new_uri = map_path(uri, |path| {
path.strip_suffix('/')
.map(Cow::Borrowed)
.unwrap_or_else(|| Cow::Owned(format!("{path}/")))
});
if let Some(new_uri) = new_uri {
Redirect::permanent(&new_uri.to_string()).into_response()
} else {
StatusCode::BAD_REQUEST.into_response()
}
}
if let Some(path_without_trailing_slash) = path.strip_suffix('/') {
router.route(path_without_trailing_slash, any(redirect_handler))
} else {
router.route(&format!("{path}/"), any(redirect_handler))
}
}
#[inline]
fn tsr_redirect_route(path: &'_ str) -> (Cow<'_, str>, fn(Uri) -> Response) {
fn redirect_handler(uri: Uri) -> Response {
let new_uri = map_path(uri, |path| {
path.strip_suffix('/')
.map(Cow::Borrowed)
.unwrap_or_else(|| Cow::Owned(format!("{path}/")))
});
if let Some(new_uri) = new_uri {
Redirect::permanent(&new_uri.to_string()).into_response()
} else {
StatusCode::BAD_REQUEST.into_response()
}
}
if let Some(path_without_trailing_slash) = path.strip_suffix('/') {
(Cow::Borrowed(path_without_trailing_slash), redirect_handler)
} else {
(Cow::Owned(format!("{path}/")), redirect_handler)
}
}
#[inline]
async fn tsr_handler_into_async(u: Uri, h: fn(Uri) -> Response) -> Response {
h(u)
}
/// Map the path of a `Uri`.
///
/// Returns `None` if the `Uri` cannot be put back together with the new
/// path.
fn map_path<F>(original_uri: Uri, f: F) -> Option<Uri>
where
F: FnOnce(&str) -> Cow<'_, str>,
{
let mut parts = original_uri.into_parts();
let path_and_query = parts.path_and_query.as_ref()?;
let new_path = f(path_and_query.path());
let new_path_and_query = if let Some(query) = &path_and_query.query() {
format!("{new_path}?{query}").parse::<PathAndQuery>().ok()?
} else {
new_path.parse::<PathAndQuery>().ok()?
};
parts.path_and_query = Some(new_path_and_query);
Uri::from_parts(parts).ok()
}
}