From adb057583f613007de84571921f86e1af3c57240 Mon Sep 17 00:00:00 2001 From: Manos Pitsidianakis Date: Sat, 15 Apr 2023 17:25:37 +0300 Subject: [PATCH] web: add typed paths --- Cargo.lock | 30 + archive-http/src/gen.rs | 2 +- cli/src/main.rs | 3 +- core/src/db.rs | 7 +- rustfmt.toml | 5 + web/Cargo.toml | 3 +- web/src/auth.rs | 76 +-- web/src/cal.rs | 12 +- web/src/lib.rs | 29 +- web/src/main.rs | 160 +++--- web/src/settings.rs | 88 +-- web/src/templates/index.html | 2 +- web/src/templates/list.html | 6 +- web/src/templates/lists.html | 2 +- web/src/templates/menu.html | 6 +- web/src/templates/post.html | 4 +- web/src/templates/settings_subscription.html | 2 +- web/src/typed_paths.rs | 559 +++++++++++++++++++ web/src/utils.rs | 19 +- 19 files changed, 832 insertions(+), 183 deletions(-) create mode 100644 rustfmt.toml create mode 100644 web/src/typed_paths.rs diff --git a/Cargo.lock b/Cargo.lock index 03339cd..f73382d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -277,14 +277,18 @@ checksum = "fe82ff817f40f735cdfd7092f810d3de63bf875f50d1f0c17928e28cdab64437" dependencies = [ "axum", "axum-core", + "axum-macros", "bytes", "cookie", + "form_urlencoded", "futures-util", "http", "http-body", "mime", + "percent-encoding", "pin-project-lite", "serde", + "serde_html_form", "tokio", "tower", "tower-http 0.4.0", @@ -315,6 +319,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-macros" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bb524613be645939e280b7279f7b017f98cf7f5ef084ec374df373530e73277" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.14", +] + [[package]] name = "axum-sessions" version = "0.5.0" @@ -1536,6 +1552,7 @@ dependencies = [ "tempfile", "tokio", "tower-http 0.3.5", + "tower-service", ] [[package]] @@ -2263,6 +2280,19 @@ dependencies = [ "syn 2.0.14", ] +[[package]] +name = "serde_html_form" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53192e38d5c88564b924dbe9b60865ecbb71b81d38c4e61c817cffd3e36ef696" +dependencies = [ + "form_urlencoded", + "indexmap", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_json" version = "1.0.95" diff --git a/archive-http/src/gen.rs b/archive-http/src/gen.rs index 9f852fc..958b794 100644 --- a/archive-http/src/gen.rs +++ b/archive-http/src/gen.rs @@ -110,7 +110,7 @@ fn run_app() -> std::result::Result<(), Box> { std::fs::create_dir_all(&lists_path)?; lists_path.push("index.html"); - let list = db.list(list.pk)?; + let list = db.list(list.pk)?.unwrap(); let post_policy = db.list_policy(list.pk)?; let months = db.months(list.pk)?; let posts = db.list_posts(list.pk, None)?; diff --git a/cli/src/main.rs b/cli/src/main.rs index 875bdae..a59c5dc 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -37,6 +37,7 @@ macro_rules! list { .ok() .map(|pk| $db.list(pk).ok()) .flatten() + .flatten() }) }}; } @@ -619,7 +620,7 @@ fn run_app(opt: Opt) -> Result<()> { println!("No subscriptions found."); } else { for s in subs { - let list = db.list(s.list).unwrap_or_else(|err| panic!("Found subscription with list_pk = {} but no such list exists.\nListSubscription = {:?}\n\n{err}", s.list, s)); + let list = db.list(s.list).unwrap_or_else(|err| panic!("Found subscription with list_pk = {} but no such list exists.\nListSubscription = {:?}\n\n{err}", s.list, s)).unwrap_or_else(|| panic!("Found subscription with list_pk = {} but no such list exists.\nListSubscription = {:?}", s.list, s)); println!("- {:?} {}", s, list); } } diff --git a/core/src/db.rs b/core/src/db.rs index f07ea78..3a139fb 100644 --- a/core/src/db.rs +++ b/core/src/db.rs @@ -249,7 +249,7 @@ impl Connection { } /// Fetch a mailing list by primary key. - pub fn list(&self, pk: i64) -> Result> { + pub fn list(&self, pk: i64) -> Result>> { let mut stmt = self .connection .prepare("SELECT * FROM list WHERE pk = ?;")?; @@ -269,10 +269,7 @@ impl Connection { )) }) .optional()?; - ret.map_or_else( - || Err(Error::from(NotFound("list or list policy not found!"))), - Ok, - ) + Ok(ret) } /// Fetch a mailing list by id. diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..b0ba595 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,5 @@ +format_code_in_doc_comments = true +format_strings = true +imports_granularity = "Crate" +group_imports = "StdExternalCrate" +wrap_comments = true diff --git a/web/Cargo.toml b/web/Cargo.toml index c295bcb..76d195e 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -16,7 +16,7 @@ path = "src/main.rs" [dependencies] axum = { version = "^0.6" } -axum-extra = { version = "^0.7" } +axum-extra = { version = "^0.7", features = ["typed-routing"] } axum-login = { version = "^0.5" } axum-sessions = { version = "^0.5" } chrono = { version = "^0.4" } @@ -33,3 +33,4 @@ serde_json = "^1" tempfile = { version = "^3.5" } tokio = { version = "1", features = ["full"] } tower-http = { version = "^0.3" } +tower-service = { version = "^0.3" } diff --git a/web/src/auth.rs b/web/src/auth.rs index db1d83c..904af31 100644 --- a/web/src/auth.rs +++ b/web/src/auth.rs @@ -17,14 +17,12 @@ * along with this program. If not, see . */ -use super::*; -use std::borrow::Cow; -use tempfile::NamedTempFile; -use tokio::fs::File; -use tokio::io::AsyncWriteExt; -use tokio::process::Command; +use std::{borrow::Cow, process::Stdio}; -use std::process::Stdio; +use tempfile::NamedTempFile; +use tokio::{fs::File, io::AsyncWriteExt, process::Command}; + +use super::*; const TOKEN_KEY: &str = "ssh_challenge"; const EXPIRY_IN_SECS: i64 = 6 * 60; @@ -76,6 +74,7 @@ pub struct AuthFormPayload { } pub async fn ssh_signin( + _: LoginPath, mut session: WritableSession, Query(next): Query, auth: AuthContext, @@ -89,7 +88,7 @@ pub async fn ssh_signin( return err.into_response(); } return next - .or_else(|| format!("{}/settings/", state.root_url_prefix)) + .or_else(|| format!("{}{}", state.root_url_prefix, SettingsPath.to_uri())) .into_response(); } if next.next.is_some() { @@ -118,8 +117,7 @@ pub async fn ssh_signin( let (token, timestamp): (String, i64) = if let Some(tok) = prev_token { tok } else { - use rand::distributions::Alphanumeric; - use rand::{thread_rng, Rng}; + use rand::{distributions::Alphanumeric, thread_rng, Rng}; let mut rng = thread_rng(); let chars: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect(); @@ -132,12 +130,12 @@ pub async fn ssh_signin( let root_url_prefix = &state.root_url_prefix; let crumbs = vec![ Crumb { - label: "Lists".into(), + label: "Home".into(), url: "/".into(), }, Crumb { label: "Sign in".into(), - url: "/login/".into(), + url: LoginPath.to_crumb(), }, ]; @@ -164,6 +162,7 @@ pub async fn ssh_signin( } pub async fn ssh_signin_post( + _: LoginPath, mut session: WritableSession, Query(next): Query, mut auth: AuthContext, @@ -175,7 +174,7 @@ pub async fn ssh_signin_post( message: "You are already logged in.".into(), level: Level::Info, })?; - return Ok(next.or_else(|| format!("{}/settings/", state.root_url_prefix))); + return Ok(next.or_else(|| format!("{}{}", state.root_url_prefix, SettingsPath.to_uri()))); } let now: i64 = chrono::offset::Utc::now().timestamp(); @@ -188,8 +187,9 @@ pub async fn ssh_signin_post( level: Level::Error, })?; return Ok(Redirect::to(&format!( - "{}/login/{}", + "{}{}{}", state.root_url_prefix, + LoginPath.to_uri(), if let Some(ref next) = next.next { next.as_str() } else { @@ -205,8 +205,9 @@ pub async fn ssh_signin_post( level: Level::Error, })?; return Ok(Redirect::to(&format!( - "{}/login/{}", + "{}{}{}", state.root_url_prefix, + LoginPath.to_uri(), if let Some(ref next) = next.next { next.as_str() } else { @@ -254,7 +255,7 @@ pub async fn ssh_signin_post( auth.login(&user) .await .map_err(|err| ResponseError::new(err.to_string(), StatusCode::BAD_REQUEST))?; - Ok(next.or_else(|| format!("{}/settings/", state.root_url_prefix))) + Ok(next.or_else(|| format!("{}{}", state.root_url_prefix, SettingsPath.to_uri()))) } #[derive(Debug, Clone, Default)] @@ -377,21 +378,24 @@ pub async fn ssh_keygen(sig: SshSignature) -> Result<(), Box>) -> Redirect { +pub async fn logout_handler( + _: LogoutPath, + mut auth: AuthContext, + State(state): State>, +) -> Redirect { auth.logout().await; - Redirect::to(&format!("{}/settings/", state.root_url_prefix)) + Redirect::to(&format!("{}/", state.root_url_prefix)) } pub mod auth_request { - use super::*; - - use std::marker::PhantomData; - use std::ops::RangeBounds; + use std::{marker::PhantomData, ops::RangeBounds}; use axum::body::HttpBody; use dyn_clone::DynClone; use tower_http::auth::AuthorizeRequest; + use super::*; + trait RoleBounds: DynClone + Send + Sync { fn contains(&self, role: Option) -> bool; } @@ -503,8 +507,8 @@ pub mod auth_request { Role: PartialOrd + PartialEq + Clone + Send + Sync + 'static, User: AuthUser, { - /// Authorizes requests by requiring a logged in user, otherwise it rejects - /// with [`http::StatusCode::UNAUTHORIZED`]. + /// Authorizes requests by requiring a logged in user, otherwise it + /// rejects with [`http::StatusCode::UNAUTHORIZED`]. pub fn login( ) -> tower_http::auth::RequireAuthorizationLayer> where @@ -539,13 +543,15 @@ pub mod auth_request { }) } - /// Authorizes requests by requiring a logged in user, otherwise it redirects to the - /// provided login URL. + /// Authorizes requests by requiring a logged in user, otherwise it + /// redirects to the provided login URL. /// - /// If `redirect_field_name` is set to a value, the login page will receive the path it was - /// redirected from in the URI query part. For example, attempting to visit a protected path - /// `/protected` would redirect you to `/login?next=/protected` allowing you to know how to - /// return the visitor to their requested page. + /// If `redirect_field_name` is set to a value, the login page will + /// receive the path it was redirected from in the URI query + /// part. For example, attempting to visit a protected path + /// `/protected` would redirect you to `/login?next=/protected` allowing + /// you to know how to return the visitor to their requested + /// page. pub fn login_or_redirect( login_url: Arc>, redirect_field_name: Option>>, @@ -567,10 +573,12 @@ pub mod auth_request { /// range of roles, otherwise it redirects to the /// provided login URL. /// - /// If `redirect_field_name` is set to a value, the login page will receive the path it was - /// redirected from in the URI query part. For example, attempting to visit a protected path - /// `/protected` would redirect you to `/login?next=/protected` allowing you to know how to - /// return the visitor to their requested page. + /// If `redirect_field_name` is set to a value, the login page will + /// receive the path it was redirected from in the URI query + /// part. For example, attempting to visit a protected path + /// `/protected` would redirect you to `/login?next=/protected` allowing + /// you to know how to return the visitor to their requested + /// page. pub fn login_with_role_or_redirect( role_bounds: impl RangeBounds + Clone + Send + Sync + 'static, login_url: Arc>, diff --git a/web/src/cal.rs b/web/src/cal.rs index db98f7d..9830761 100644 --- a/web/src/cal.rs +++ b/web/src/cal.rs @@ -9,8 +9,8 @@ // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, @@ -25,8 +25,8 @@ use chrono::*; #[allow(dead_code)] /// Generate a calendar view of the given date's month. /// -/// Each vector element is an array of seven numbers representing weeks (starting on Sundays), -/// and each value is the numeric date. +/// Each vector element is an array of seven numbers representing weeks +/// (starting on Sundays), and each value is the numeric date. /// A value of zero means a date that not exists in the current month. /// /// # Examples @@ -50,8 +50,8 @@ pub fn calendarize(date: NaiveDate) -> Vec<[u32; 7]> { /// Generate a calendar view of the given date's month and offset. /// -/// Each vector element is an array of seven numbers representing weeks (starting on Sundays), -/// and each value is the numeric date. +/// Each vector element is an array of seven numbers representing weeks +/// (starting on Sundays), and each value is the numeric date. /// A value of zero means a date that not exists in the current month. /// /// Offset means the number of days from sunday. diff --git a/web/src/lib.rs b/web/src/lib.rs index bd75184..29a88fb 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -24,9 +24,7 @@ pub use axum::{ routing::{get, post}, Extension, Form, Router, }; - -pub use axum_extra::routing::RouterExt; - +pub use axum_extra::routing::TypedPath; pub use axum_login::{ memory_store::MemoryStore as AuthMemoryStore, secrecy::SecretVec, AuthLayer, AuthUser, RequireAuthorizationLayer, @@ -42,31 +40,28 @@ pub type AuthContext = pub type RequireAuth = auth::auth_request::RequireAuthorizationLayer; -pub use http::{Request, Response, StatusCode}; +pub use std::result::Result; +use std::{borrow::Cow, collections::HashMap, sync::Arc}; use chrono::Datelike; -use minijinja::value::{Object, Value}; -use minijinja::{Environment, Error, Source}; - -use std::borrow::Cow; - -use std::collections::HashMap; -use std::sync::Arc; +pub use http::{Request, Response, StatusCode}; +pub use mailpot::{models::DbVal, *}; +use minijinja::{ + value::{Object, Value}, + Environment, Error, Source, +}; use tokio::sync::RwLock; -pub use mailpot::models::DbVal; -pub use mailpot::*; -pub use std::result::Result; - pub mod auth; pub mod cal; pub mod settings; +pub mod typed_paths; pub mod utils; pub use auth::*; -pub use cal::calendarize; -pub use cal::*; +pub use cal::{calendarize, *}; pub use settings::*; +pub use typed_paths::{tsr::RouterExt, *}; pub use utils::*; #[derive(Debug)] diff --git a/web/src/main.rs b/web/src/main.rs index 05fb03b..d8af561 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -17,13 +17,11 @@ * along with this program. If not, see . */ +use std::{collections::HashMap, sync::Arc}; + use mailpot_web::*; -use rand::Rng; - use minijinja::value::Value; - -use std::collections::HashMap; -use std::sync::Arc; +use rand::Rng; use tokio::sync::RwLock; #[tokio::main] @@ -49,63 +47,64 @@ async fn main() { let auth_layer = AuthLayer::new(shared_state.clone(), &secret); - let login_url = Arc::new(format!("{}/login/", shared_state.root_url_prefix).into()); + let login_url = + Arc::new(format!("{}{}", shared_state.root_url_prefix, LoginPath.to_crumb()).into()); let app = Router::new() .route("/", get(root)) - .route_with_tsr("/lists/:pk/", get(list)) - .route_with_tsr("/lists/:pk/:msgid/", get(list_post)) - .route_with_tsr("/lists/:pk/edit/", get(list_edit)) - .route_with_tsr("/help/", get(help)) - .route_with_tsr( - "/login/", - get(auth::ssh_signin).post({ + .typed_get(list) + .typed_get(list_post) + .typed_get(list_edit) + .typed_get(help) + .typed_get(auth::ssh_signin) + .typed_post({ + let shared_state = Arc::clone(&shared_state); + move |path, session, query, auth, body| { + auth::ssh_signin_post(path, session, query, auth, body, shared_state) + } + }) + .typed_get(logout_handler) + .typed_post(logout_handler) + .typed_get( + { let shared_state = Arc::clone(&shared_state); - move |session, query, auth, body| { - auth::ssh_signin_post(session, query, auth, body, shared_state) - } - }), - ) - .route_with_tsr("/logout/", get(logout_handler)) - .route_with_tsr( - "/settings/", - get({ - let shared_state = Arc::clone(&shared_state); - move |session, user| settings(session, user, shared_state) + move |path, session, user| settings(path, session, user, shared_state) } .layer(RequireAuth::login_or_redirect( Arc::clone(&login_url), Some(Arc::new("next".into())), - ))) - .post( - { - let shared_state = Arc::clone(&shared_state); - move |session, auth, body| settings_post(session, auth, body, shared_state) - } - .layer(RequireAuth::login_or_redirect( - Arc::clone(&login_url), - Some(Arc::new("next".into())), - )), - ), + )), ) - .route_with_tsr( - "/settings/list/:pk/", - get(user_list_subscription) - .layer(RequireAuth::login_with_role_or_redirect( - Role::User.., - Arc::clone(&login_url), - Some(Arc::new("next".into())), - )) - .post({ - let shared_state = Arc::clone(&shared_state); - move |session, path, user, body| { - user_list_subscription_post(session, path, user, body, shared_state) - } - }) - .layer(RequireAuth::login_with_role_or_redirect( - Role::User.., - Arc::clone(&login_url), - Some(Arc::new("next".into())), - )), + .typed_post( + { + let shared_state = Arc::clone(&shared_state); + move |path, session, auth, body| { + settings_post(path, session, auth, body, shared_state) + } + } + .layer(RequireAuth::login_or_redirect( + Arc::clone(&login_url), + Some(Arc::new("next".into())), + )), + ) + .typed_get( + user_list_subscription.layer(RequireAuth::login_with_role_or_redirect( + Role::User.., + Arc::clone(&login_url), + Some(Arc::new("next".into())), + )), + ) + .typed_post( + { + let shared_state = Arc::clone(&shared_state); + move |session, path, user, body| { + user_list_subscription_post(session, path, user, body, shared_state) + } + } + .layer(RequireAuth::login_with_role_or_redirect( + Role::User.., + Arc::clone(&login_url), + Some(Arc::new("next".into())), + )), ) .layer(auth_layer) .layer(session_layer) @@ -142,7 +141,7 @@ async fn root( }) .collect::, mailpot::Error>>()?; let crumbs = vec![Crumb { - label: "Lists".into(), + label: "Home".into(), url: "/".into(), }]; @@ -160,20 +159,28 @@ async fn root( } async fn list( + ListPath(id): ListPath, mut session: WritableSession, - Path(id): Path, auth: AuthContext, State(state): State>, ) -> Result, ResponseError> { let db = Connection::open_db(state.conf.clone())?; - let list = db.list(id)?; + let Some(list) = (match id { + ListPathIdentifier::Pk(id) => db.list(id)?, + ListPathIdentifier::Id(id) => db.list_by_id(id)?, + }) else { + return Err(ResponseError::new( + "List not found".to_string(), + StatusCode::NOT_FOUND, + )); + }; let post_policy = db.list_policy(list.pk)?; let subscription_policy = db.list_subscription_policy(list.pk)?; let months = db.months(list.pk)?; let user_context = auth .current_user .as_ref() - .map(|user| db.list_subscription_by_address(id, &user.address).ok()); + .map(|user| db.list_subscription_by_address(list.pk, &user.address).ok()); let posts = db.list_posts(list.pk, None)?; let mut hist = months @@ -214,12 +221,12 @@ async fn list( .collect::>(); let crumbs = vec![ Crumb { - label: "Lists".into(), + label: "Home".into(), url: "/".into(), }, Crumb { label: list.name.clone().into(), - url: format!("/lists/{}/", list.pk).into(), + url: ListPath(list.pk().into()).to_crumb(), }, ]; let context = minijinja::context! { @@ -244,17 +251,25 @@ async fn list( } async fn list_post( + ListPostPath(id, msg_id): ListPostPath, mut session: WritableSession, - Path((id, msg_id)): Path<(i64, String)>, auth: AuthContext, State(state): State>, ) -> Result, ResponseError> { let db = Connection::open_db(state.conf.clone())?; - let list = db.list(id)?; - let user_context = auth - .current_user - .as_ref() - .map(|user| db.list_subscription_by_address(id, &user.address).ok()); + let Some(list) = (match id { + ListPathIdentifier::Pk(id) => db.list(id)?, + ListPathIdentifier::Id(id) => db.list_by_id(id)?, + }) else { + return Err(ResponseError::new( + "List not found".to_string(), + StatusCode::NOT_FOUND, + )); + }; + let user_context = auth.current_user.as_ref().map(|user| { + db.list_subscription_by_address(list.pk(), &user.address) + .ok() + }); let post = if let Some(post) = db.list_post_by_message_id(list.pk, &msg_id)? { post @@ -278,16 +293,16 @@ async fn list_post( } let crumbs = vec![ Crumb { - label: "Lists".into(), + label: "Home".into(), url: "/".into(), }, Crumb { label: list.name.clone().into(), - url: format!("/lists/{}/", list.pk).into(), + url: ListPath(list.pk().into()).to_crumb(), }, Crumb { label: format!("{} {msg_id}", subject_ref).into(), - url: format!("/lists/{}/{}/", list.pk, msg_id).into(), + url: ListPostPath(list.pk().into(), msg_id.to_string()).to_crumb(), }, ]; let context = minijinja::context! { @@ -317,21 +332,22 @@ async fn list_post( Ok(Html(TEMPLATES.get_template("post.html")?.render(context)?)) } -async fn list_edit(Path(_): Path, State(_): State>) {} +async fn list_edit(ListEditPath(_): ListEditPath, State(_): State>) {} async fn help( + _: HelpPath, mut session: WritableSession, auth: AuthContext, State(state): State>, ) -> Result, ResponseError> { let crumbs = vec![ Crumb { - label: "Lists".into(), + label: "Home".into(), url: "/".into(), }, Crumb { label: "Help".into(), - url: "/help/".into(), + url: HelpPath.to_crumb(), }, ]; let context = minijinja::context! { diff --git a/web/src/settings.rs b/web/src/settings.rs index 2d6ea0f..caca82e 100644 --- a/web/src/settings.rs +++ b/web/src/settings.rs @@ -17,13 +17,15 @@ * along with this program. If not, see . */ -use super::*; use mailpot::models::{ changesets::{AccountChangeset, ListSubscriptionChangeset}, ListSubscription, }; +use super::*; + pub async fn settings( + _: SettingsPath, mut session: WritableSession, Extension(user): Extension, state: Arc, @@ -31,12 +33,12 @@ pub async fn settings( let root_url_prefix = &state.root_url_prefix; let crumbs = vec![ Crumb { - label: "Lists".into(), + label: "Home".into(), url: "/".into(), }, Crumb { label: "Settings".into(), - url: "/settings/".into(), + url: SettingsPath.to_crumb(), }, ]; let db = Connection::open_db(state.conf.clone())?; @@ -50,10 +52,10 @@ pub async fn settings( .account_subscriptions(acc.pk()) .with_status(StatusCode::BAD_REQUEST)? .into_iter() - .map(|s| { - let list = db.list(s.list)?; - - Ok((s, list)) + .filter_map(|s| match db.list(s.list) { + Err(err) => Some(Err(err)), + Ok(Some(list)) => Some(Ok((s, list))), + Ok(None) => None, }) .collect::, Form(payload): Form, @@ -237,34 +240,29 @@ pub async fn settings_post( } Ok(Redirect::to(&format!( - "{}/settings/", - &state.root_url_prefix + "{}/{}", + &state.root_url_prefix, + SettingsPath.to_uri() ))) } pub async fn user_list_subscription( + ListSettingsPath(id): ListSettingsPath, mut session: WritableSession, Extension(user): Extension, - Path(id): Path, State(state): State>, ) -> Result, ResponseError> { let root_url_prefix = &state.root_url_prefix; let db = Connection::open_db(state.conf.clone())?; - let crumbs = vec![ - Crumb { - label: "Lists".into(), - url: "/".into(), - }, - Crumb { - label: "Settings".into(), - url: "/settings/".into(), - }, - Crumb { - label: "List Subscription".into(), - url: format!("/settings/list/{}/", id).into(), - }, - ]; - let list = db.list(id)?; + let Some(list) = (match id { + ListPathIdentifier::Pk(id) => db.list(id)?, + ListPathIdentifier::Id(id) => db.list_by_id(id)?, + }) else { + return Err(ResponseError::new( + "List not found".to_string(), + StatusCode::NOT_FOUND, + )); + }; let acc = match db.account_by_address(&user.address)? { Some(v) => v, None => { @@ -277,10 +275,10 @@ pub async fn user_list_subscription( let mut subscriptions = db .account_subscriptions(acc.pk()) .with_status(StatusCode::BAD_REQUEST)?; - subscriptions.retain(|s| s.list == id); + subscriptions.retain(|s| s.list == list.pk()); let subscription = db .list_subscription( - id, + list.pk(), subscriptions .get(0) .ok_or_else(|| { @@ -293,6 +291,21 @@ pub async fn user_list_subscription( ) .with_status(StatusCode::BAD_REQUEST)?; + let crumbs = vec![ + Crumb { + label: "Home".into(), + url: "/".into(), + }, + Crumb { + label: "Settings".into(), + url: SettingsPath.to_crumb(), + }, + Crumb { + label: "List Subscription".into(), + url: ListSettingsPath(list.pk().into()).to_crumb(), + }, + ]; + let context = minijinja::context! { title => state.site_title.as_ref(), page_title => "Subscription settings", @@ -327,15 +340,23 @@ pub struct SubscriptionFormPayload { } pub async fn user_list_subscription_post( + ListSettingsPath(id): ListSettingsPath, mut session: WritableSession, - Path(id): Path, Extension(user): Extension, Form(payload): Form, state: Arc, ) -> Result { let mut db = Connection::open_db(state.conf.clone())?; - let _list = db.list(id).with_status(StatusCode::NOT_FOUND)?; + let Some(list) = (match id { + ListPathIdentifier::Pk(id) => db.list(id as _)?, + ListPathIdentifier::Id(id) => db.list_by_id(id)?, + }) else { + return Err(ResponseError::new( + "List not found".to_string(), + StatusCode::NOT_FOUND, + )); + }; let acc = match db.account_by_address(&user.address)? { Some(v) => v, @@ -350,9 +371,9 @@ pub async fn user_list_subscription_post( .account_subscriptions(acc.pk()) .with_status(StatusCode::BAD_REQUEST)?; - subscriptions.retain(|s| s.list == id); + subscriptions.retain(|s| s.list == list.pk()); let mut s = db - .list_subscription(id, subscriptions[0].pk()) + .list_subscription(list.pk(), subscriptions[0].pk()) .with_status(StatusCode::BAD_REQUEST)?; let SubscriptionFormPayload { @@ -386,7 +407,8 @@ pub async fn user_list_subscription_post( })?; Ok(Redirect::to(&format!( - "{}/settings/list/{id}/", - &state.root_url_prefix + "{}{}", + &state.root_url_prefix, + ListSettingsPath(list.id.clone().into()).to_uri() ))) } diff --git a/web/src/templates/index.html b/web/src/templates/index.html index 608a9b8..e7b40db 100644 --- a/web/src/templates/index.html +++ b/web/src/templates/index.html @@ -3,7 +3,7 @@
diff --git a/web/src/templates/list.html b/web/src/templates/list.html index de6dbef..756b119 100644 --- a/web/src/templates/list.html +++ b/web/src/templates/list.html @@ -8,13 +8,13 @@
{% if current_user and not post_policy.no_subscriptions and subscription_policy.open %} {% if user_context %} -
+
{% else %} -
+ @@ -95,7 +95,7 @@

{{ posts | length }} post(s)

{% for post in posts %}
- {{ post.subject }} + {{ post.subject }}
diff --git a/web/src/templates/lists.html b/web/src/templates/lists.html index 1ad33af..05d9037 100644 --- a/web/src/templates/lists.html +++ b/web/src/templates/lists.html @@ -4,7 +4,7 @@
diff --git a/web/src/templates/menu.html b/web/src/templates/menu.html index 5ed6a59..6d01f3c 100644 --- a/web/src/templates/menu.html +++ b/web/src/templates/menu.html @@ -1,11 +1,11 @@ diff --git a/web/src/templates/post.html b/web/src/templates/post.html index 87f7f34..4b605b3 100644 --- a/web/src/templates/post.html +++ b/web/src/templates/post.html @@ -24,13 +24,13 @@ {% if in_reply_to %} In-Reply-To: - {{ in_reply_to }} + {{ in_reply_to }} {% endif %} {% if references %} References: - {% for r in references %}{{ r }}{% endfor %} + {% for r in references %}{{ r }}{% endfor %} {% endif %} diff --git a/web/src/templates/settings_subscription.html b/web/src/templates/settings_subscription.html index 476bc00..e6b5e35 100644 --- a/web/src/templates/settings_subscription.html +++ b/web/src/templates/settings_subscription.html @@ -1,6 +1,6 @@ {% include "header.html" %}
-

Your subscription to {{ list.id }}.

+

Your subscription to {{ list.id }}.

{{ list.name }} {{ list.address }}
diff --git a/web/src/typed_paths.rs b/web/src/typed_paths.rs new file mode 100644 index 0000000..23581c6 --- /dev/null +++ b/web/src/typed_paths.rs @@ -0,0 +1,559 @@ +/* + * 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 . + */ + +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 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 +where + D: serde::Deserializer<'de>, + T: std::str::FromStr, + ::Err: std::fmt::Display, +{ + use serde::Deserialize; + String::deserialize(de)? + .parse() + .map_err(serde::de::Error::custom) +} + +impl From for ListPathIdentifier { + fn from(val: i64) -> Self { + Self::Pk(val) + } +} + +impl From 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> for ListPath { + fn from(val: &DbVal) -> 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 { + 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 { + 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: ExtraRouterExt { + /// 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(self, handler: H) -> Self + where + H: axum::handler::Handler, + T: SecondElementIs

+ '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(self, handler: H) -> Self + where + H: axum::handler::Handler, + T: SecondElementIs

+ '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(self, handler: H) -> Self + where + H: axum::handler::Handler, + T: SecondElementIs

+ '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(self, handler: H) -> Self + where + H: axum::handler::Handler, + T: SecondElementIs

+ '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(self, handler: H) -> Self + where + H: axum::handler::Handler, + T: SecondElementIs

+ '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(self, handler: H) -> Self + where + H: axum::handler::Handler, + T: SecondElementIs

+ '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(self, handler: H) -> Self + where + H: axum::handler::Handler, + T: SecondElementIs

+ '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(self, handler: H) -> Self + where + H: axum::handler::Handler, + T: SecondElementIs

+ '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) -> 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(self, path: &str, service: T) -> Self + where + T: Service, Error = Infallible> + Clone + Send + 'static, + T::Response: IntoResponse, + T::Future: Send + 'static, + Self: Sized; + } + + impl RouterExt for Router + where + B: axum::body::HttpBody + Send + 'static, + S: Clone + Send + Sync + 'static, + { + fn typed_get(mut self, handler: H) -> Self + where + H: axum::handler::Handler, + T: SecondElementIs

+ '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(mut self, handler: H) -> Self + where + H: axum::handler::Handler, + T: SecondElementIs

+ '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(mut self, handler: H) -> Self + where + H: axum::handler::Handler, + T: SecondElementIs

+ '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(mut self, handler: H) -> Self + where + H: axum::handler::Handler, + T: SecondElementIs

+ '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(mut self, handler: H) -> Self + where + H: axum::handler::Handler, + T: SecondElementIs

+ '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(mut self, handler: H) -> Self + where + H: axum::handler::Handler, + T: SecondElementIs

+ '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(mut self, handler: H) -> Self + where + H: axum::handler::Handler, + T: SecondElementIs

+ '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(mut self, handler: H) -> Self + where + H: axum::handler::Handler, + T: SecondElementIs

+ '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) -> 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(mut self, path: &str, service: T) -> Self + where + T: Service, 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(router: Router, path: &str) -> Router + 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(original_uri: Uri, f: F) -> Option + 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::().ok()? + } else { + new_path.parse::().ok()? + }; + parts.path_and_query = Some(new_path_and_query); + + Uri::from_parts(parts).ok() + } +} diff --git a/web/src/utils.rs b/web/src/utils.rs index 16d57fd..66f97d1 100644 --- a/web/src/utils.rs +++ b/web/src/utils.rs @@ -22,7 +22,22 @@ use super::*; lazy_static::lazy_static! { pub static ref TEMPLATES: Environment<'static> = { let mut env = Environment::new(); - env.add_function("calendarize", calendarize); + macro_rules! add_function { + ($($id:ident),*$(,)?) => { + $(env.add_function(stringify!($id), $id);)* + } + } + add_function!( + calendarize, + login_path, + logout_path, + settings_path, + help_path, + list_path, + list_settings_path, + list_edit_path, + list_post_path + ); env.set_source(Source::from_path("web/src/templates/")); env @@ -106,7 +121,7 @@ impl Object for MailingList { )), _ => Err(Error::new( minijinja::ErrorKind::UnknownMethod, - format!("aaaobject has no method named {name}"), + format!("object has no method named {name}"), )), } }