Author: Manos Pitsidianakis [manos@pitsidianak.is]
Hash: adb057583f613007de84571921f86e1af3c57240
Timestamp: Sat, 15 Apr 2023 14:25:37 +0000 (1 year ago)

+829 -180 +/-19 browse
web: add typed paths
1diff --git a/Cargo.lock b/Cargo.lock
2index 03339cd..f73382d 100644
3--- a/Cargo.lock
4+++ b/Cargo.lock
5 @@ -277,14 +277,18 @@ checksum = "fe82ff817f40f735cdfd7092f810d3de63bf875f50d1f0c17928e28cdab64437"
6 dependencies = [
7 "axum",
8 "axum-core",
9+ "axum-macros",
10 "bytes",
11 "cookie",
12+ "form_urlencoded",
13 "futures-util",
14 "http",
15 "http-body",
16 "mime",
17+ "percent-encoding",
18 "pin-project-lite",
19 "serde",
20+ "serde_html_form",
21 "tokio",
22 "tower",
23 "tower-http 0.4.0",
24 @@ -316,6 +320,18 @@ dependencies = [
25 ]
26
27 [[package]]
28+ name = "axum-macros"
29+ version = "0.3.7"
30+ source = "registry+https://github.com/rust-lang/crates.io-index"
31+ checksum = "2bb524613be645939e280b7279f7b017f98cf7f5ef084ec374df373530e73277"
32+ dependencies = [
33+ "heck",
34+ "proc-macro2",
35+ "quote",
36+ "syn 2.0.14",
37+ ]
38+
39+ [[package]]
40 name = "axum-sessions"
41 version = "0.5.0"
42 source = "registry+https://github.com/rust-lang/crates.io-index"
43 @@ -1536,6 +1552,7 @@ dependencies = [
44 "tempfile",
45 "tokio",
46 "tower-http 0.3.5",
47+ "tower-service",
48 ]
49
50 [[package]]
51 @@ -2264,6 +2281,19 @@ dependencies = [
52 ]
53
54 [[package]]
55+ name = "serde_html_form"
56+ version = "0.2.0"
57+ source = "registry+https://github.com/rust-lang/crates.io-index"
58+ checksum = "53192e38d5c88564b924dbe9b60865ecbb71b81d38c4e61c817cffd3e36ef696"
59+ dependencies = [
60+ "form_urlencoded",
61+ "indexmap",
62+ "itoa",
63+ "ryu",
64+ "serde",
65+ ]
66+
67+ [[package]]
68 name = "serde_json"
69 version = "1.0.95"
70 source = "registry+https://github.com/rust-lang/crates.io-index"
71 diff --git a/archive-http/src/gen.rs b/archive-http/src/gen.rs
72index 9f852fc..958b794 100644
73--- a/archive-http/src/gen.rs
74+++ b/archive-http/src/gen.rs
75 @@ -110,7 +110,7 @@ fn run_app() -> std::result::Result<(), Box<dyn std::error::Error>> {
76 std::fs::create_dir_all(&lists_path)?;
77 lists_path.push("index.html");
78
79- let list = db.list(list.pk)?;
80+ let list = db.list(list.pk)?.unwrap();
81 let post_policy = db.list_policy(list.pk)?;
82 let months = db.months(list.pk)?;
83 let posts = db.list_posts(list.pk, None)?;
84 diff --git a/cli/src/main.rs b/cli/src/main.rs
85index 875bdae..a59c5dc 100644
86--- a/cli/src/main.rs
87+++ b/cli/src/main.rs
88 @@ -37,6 +37,7 @@ macro_rules! list {
89 .ok()
90 .map(|pk| $db.list(pk).ok())
91 .flatten()
92+ .flatten()
93 })
94 }};
95 }
96 @@ -619,7 +620,7 @@ fn run_app(opt: Opt) -> Result<()> {
97 println!("No subscriptions found.");
98 } else {
99 for s in subs {
100- 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));
101+ 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));
102 println!("- {:?} {}", s, list);
103 }
104 }
105 diff --git a/core/src/db.rs b/core/src/db.rs
106index f07ea78..3a139fb 100644
107--- a/core/src/db.rs
108+++ b/core/src/db.rs
109 @@ -249,7 +249,7 @@ impl Connection {
110 }
111
112 /// Fetch a mailing list by primary key.
113- pub fn list(&self, pk: i64) -> Result<DbVal<MailingList>> {
114+ pub fn list(&self, pk: i64) -> Result<Option<DbVal<MailingList>>> {
115 let mut stmt = self
116 .connection
117 .prepare("SELECT * FROM list WHERE pk = ?;")?;
118 @@ -269,10 +269,7 @@ impl Connection {
119 ))
120 })
121 .optional()?;
122- ret.map_or_else(
123- || Err(Error::from(NotFound("list or list policy not found!"))),
124- Ok,
125- )
126+ Ok(ret)
127 }
128
129 /// Fetch a mailing list by id.
130 diff --git a/rustfmt.toml b/rustfmt.toml
131new file mode 100644
132index 0000000..b0ba595
133--- /dev/null
134+++ b/rustfmt.toml
135 @@ -0,0 +1,5 @@
136+ format_code_in_doc_comments = true
137+ format_strings = true
138+ imports_granularity = "Crate"
139+ group_imports = "StdExternalCrate"
140+ wrap_comments = true
141 diff --git a/web/Cargo.toml b/web/Cargo.toml
142index c295bcb..76d195e 100644
143--- a/web/Cargo.toml
144+++ b/web/Cargo.toml
145 @@ -16,7 +16,7 @@ path = "src/main.rs"
146
147 [dependencies]
148 axum = { version = "^0.6" }
149- axum-extra = { version = "^0.7" }
150+ axum-extra = { version = "^0.7", features = ["typed-routing"] }
151 axum-login = { version = "^0.5" }
152 axum-sessions = { version = "^0.5" }
153 chrono = { version = "^0.4" }
154 @@ -33,3 +33,4 @@ serde_json = "^1"
155 tempfile = { version = "^3.5" }
156 tokio = { version = "1", features = ["full"] }
157 tower-http = { version = "^0.3" }
158+ tower-service = { version = "^0.3" }
159 diff --git a/web/src/auth.rs b/web/src/auth.rs
160index db1d83c..904af31 100644
161--- a/web/src/auth.rs
162+++ b/web/src/auth.rs
163 @@ -17,14 +17,12 @@
164 * along with this program. If not, see <https://www.gnu.org/licenses/>.
165 */
166
167- use super::*;
168- use std::borrow::Cow;
169+ use std::{borrow::Cow, process::Stdio};
170+
171 use tempfile::NamedTempFile;
172- use tokio::fs::File;
173- use tokio::io::AsyncWriteExt;
174- use tokio::process::Command;
175+ use tokio::{fs::File, io::AsyncWriteExt, process::Command};
176
177- use std::process::Stdio;
178+ use super::*;
179
180 const TOKEN_KEY: &str = "ssh_challenge";
181 const EXPIRY_IN_SECS: i64 = 6 * 60;
182 @@ -76,6 +74,7 @@ pub struct AuthFormPayload {
183 }
184
185 pub async fn ssh_signin(
186+ _: LoginPath,
187 mut session: WritableSession,
188 Query(next): Query<Next>,
189 auth: AuthContext,
190 @@ -89,7 +88,7 @@ pub async fn ssh_signin(
191 return err.into_response();
192 }
193 return next
194- .or_else(|| format!("{}/settings/", state.root_url_prefix))
195+ .or_else(|| format!("{}{}", state.root_url_prefix, SettingsPath.to_uri()))
196 .into_response();
197 }
198 if next.next.is_some() {
199 @@ -118,8 +117,7 @@ pub async fn ssh_signin(
200 let (token, timestamp): (String, i64) = if let Some(tok) = prev_token {
201 tok
202 } else {
203- use rand::distributions::Alphanumeric;
204- use rand::{thread_rng, Rng};
205+ use rand::{distributions::Alphanumeric, thread_rng, Rng};
206
207 let mut rng = thread_rng();
208 let chars: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
209 @@ -132,12 +130,12 @@ pub async fn ssh_signin(
210 let root_url_prefix = &state.root_url_prefix;
211 let crumbs = vec![
212 Crumb {
213- label: "Lists".into(),
214+ label: "Home".into(),
215 url: "/".into(),
216 },
217 Crumb {
218 label: "Sign in".into(),
219- url: "/login/".into(),
220+ url: LoginPath.to_crumb(),
221 },
222 ];
223
224 @@ -164,6 +162,7 @@ pub async fn ssh_signin(
225 }
226
227 pub async fn ssh_signin_post(
228+ _: LoginPath,
229 mut session: WritableSession,
230 Query(next): Query<Next>,
231 mut auth: AuthContext,
232 @@ -175,7 +174,7 @@ pub async fn ssh_signin_post(
233 message: "You are already logged in.".into(),
234 level: Level::Info,
235 })?;
236- return Ok(next.or_else(|| format!("{}/settings/", state.root_url_prefix)));
237+ return Ok(next.or_else(|| format!("{}{}", state.root_url_prefix, SettingsPath.to_uri())));
238 }
239
240 let now: i64 = chrono::offset::Utc::now().timestamp();
241 @@ -188,8 +187,9 @@ pub async fn ssh_signin_post(
242 level: Level::Error,
243 })?;
244 return Ok(Redirect::to(&format!(
245- "{}/login/{}",
246+ "{}{}{}",
247 state.root_url_prefix,
248+ LoginPath.to_uri(),
249 if let Some(ref next) = next.next {
250 next.as_str()
251 } else {
252 @@ -205,8 +205,9 @@ pub async fn ssh_signin_post(
253 level: Level::Error,
254 })?;
255 return Ok(Redirect::to(&format!(
256- "{}/login/{}",
257+ "{}{}{}",
258 state.root_url_prefix,
259+ LoginPath.to_uri(),
260 if let Some(ref next) = next.next {
261 next.as_str()
262 } else {
263 @@ -254,7 +255,7 @@ pub async fn ssh_signin_post(
264 auth.login(&user)
265 .await
266 .map_err(|err| ResponseError::new(err.to_string(), StatusCode::BAD_REQUEST))?;
267- Ok(next.or_else(|| format!("{}/settings/", state.root_url_prefix)))
268+ Ok(next.or_else(|| format!("{}{}", state.root_url_prefix, SettingsPath.to_uri())))
269 }
270
271 #[derive(Debug, Clone, Default)]
272 @@ -377,21 +378,24 @@ pub async fn ssh_keygen(sig: SshSignature) -> Result<(), Box<dyn std::error::Err
273 Ok(())
274 }
275
276- pub async fn logout_handler(mut auth: AuthContext, State(state): State<Arc<AppState>>) -> Redirect {
277+ pub async fn logout_handler(
278+ _: LogoutPath,
279+ mut auth: AuthContext,
280+ State(state): State<Arc<AppState>>,
281+ ) -> Redirect {
282 auth.logout().await;
283- Redirect::to(&format!("{}/settings/", state.root_url_prefix))
284+ Redirect::to(&format!("{}/", state.root_url_prefix))
285 }
286
287 pub mod auth_request {
288- use super::*;
289-
290- use std::marker::PhantomData;
291- use std::ops::RangeBounds;
292+ use std::{marker::PhantomData, ops::RangeBounds};
293
294 use axum::body::HttpBody;
295 use dyn_clone::DynClone;
296 use tower_http::auth::AuthorizeRequest;
297
298+ use super::*;
299+
300 trait RoleBounds<Role>: DynClone + Send + Sync {
301 fn contains(&self, role: Option<Role>) -> bool;
302 }
303 @@ -503,8 +507,8 @@ pub mod auth_request {
304 Role: PartialOrd + PartialEq + Clone + Send + Sync + 'static,
305 User: AuthUser<UserId, Role>,
306 {
307- /// Authorizes requests by requiring a logged in user, otherwise it rejects
308- /// with [`http::StatusCode::UNAUTHORIZED`].
309+ /// Authorizes requests by requiring a logged in user, otherwise it
310+ /// rejects with [`http::StatusCode::UNAUTHORIZED`].
311 pub fn login<ResBody>(
312 ) -> tower_http::auth::RequireAuthorizationLayer<Login<UserId, User, ResBody, Role>>
313 where
314 @@ -539,13 +543,15 @@ pub mod auth_request {
315 })
316 }
317
318- /// Authorizes requests by requiring a logged in user, otherwise it redirects to the
319- /// provided login URL.
320+ /// Authorizes requests by requiring a logged in user, otherwise it
321+ /// redirects to the provided login URL.
322 ///
323- /// If `redirect_field_name` is set to a value, the login page will receive the path it was
324- /// redirected from in the URI query part. For example, attempting to visit a protected path
325- /// `/protected` would redirect you to `/login?next=/protected` allowing you to know how to
326- /// return the visitor to their requested page.
327+ /// If `redirect_field_name` is set to a value, the login page will
328+ /// receive the path it was redirected from in the URI query
329+ /// part. For example, attempting to visit a protected path
330+ /// `/protected` would redirect you to `/login?next=/protected` allowing
331+ /// you to know how to return the visitor to their requested
332+ /// page.
333 pub fn login_or_redirect<ResBody>(
334 login_url: Arc<Cow<'static, str>>,
335 redirect_field_name: Option<Arc<Cow<'static, str>>>,
336 @@ -567,10 +573,12 @@ pub mod auth_request {
337 /// range of roles, otherwise it redirects to the
338 /// provided login URL.
339 ///
340- /// If `redirect_field_name` is set to a value, the login page will receive the path it was
341- /// redirected from in the URI query part. For example, attempting to visit a protected path
342- /// `/protected` would redirect you to `/login?next=/protected` allowing you to know how to
343- /// return the visitor to their requested page.
344+ /// If `redirect_field_name` is set to a value, the login page will
345+ /// receive the path it was redirected from in the URI query
346+ /// part. For example, attempting to visit a protected path
347+ /// `/protected` would redirect you to `/login?next=/protected` allowing
348+ /// you to know how to return the visitor to their requested
349+ /// page.
350 pub fn login_with_role_or_redirect<ResBody>(
351 role_bounds: impl RangeBounds<Role> + Clone + Send + Sync + 'static,
352 login_url: Arc<Cow<'static, str>>,
353 diff --git a/web/src/cal.rs b/web/src/cal.rs
354index db98f7d..9830761 100644
355--- a/web/src/cal.rs
356+++ b/web/src/cal.rs
357 @@ -9,8 +9,8 @@
358 // copies of the Software, and to permit persons to whom the Software is
359 // furnished to do so, subject to the following conditions:
360 //
361- // The above copyright notice and this permission notice shall be included in all
362- // copies or substantial portions of the Software.
363+ // The above copyright notice and this permission notice shall be included in
364+ // all copies or substantial portions of the Software.
365 //
366 // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
367 // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
368 @@ -25,8 +25,8 @@ use chrono::*;
369 #[allow(dead_code)]
370 /// Generate a calendar view of the given date's month.
371 ///
372- /// Each vector element is an array of seven numbers representing weeks (starting on Sundays),
373- /// and each value is the numeric date.
374+ /// Each vector element is an array of seven numbers representing weeks
375+ /// (starting on Sundays), and each value is the numeric date.
376 /// A value of zero means a date that not exists in the current month.
377 ///
378 /// # Examples
379 @@ -50,8 +50,8 @@ pub fn calendarize(date: NaiveDate) -> Vec<[u32; 7]> {
380
381 /// Generate a calendar view of the given date's month and offset.
382 ///
383- /// Each vector element is an array of seven numbers representing weeks (starting on Sundays),
384- /// and each value is the numeric date.
385+ /// Each vector element is an array of seven numbers representing weeks
386+ /// (starting on Sundays), and each value is the numeric date.
387 /// A value of zero means a date that not exists in the current month.
388 ///
389 /// Offset means the number of days from sunday.
390 diff --git a/web/src/lib.rs b/web/src/lib.rs
391index bd75184..29a88fb 100644
392--- a/web/src/lib.rs
393+++ b/web/src/lib.rs
394 @@ -24,9 +24,7 @@ pub use axum::{
395 routing::{get, post},
396 Extension, Form, Router,
397 };
398-
399- pub use axum_extra::routing::RouterExt;
400-
401+ pub use axum_extra::routing::TypedPath;
402 pub use axum_login::{
403 memory_store::MemoryStore as AuthMemoryStore, secrecy::SecretVec, AuthLayer, AuthUser,
404 RequireAuthorizationLayer,
405 @@ -42,31 +40,28 @@ pub type AuthContext =
406
407 pub type RequireAuth = auth::auth_request::RequireAuthorizationLayer<i64, auth::User, auth::Role>;
408
409- pub use http::{Request, Response, StatusCode};
410+ pub use std::result::Result;
411+ use std::{borrow::Cow, collections::HashMap, sync::Arc};
412
413 use chrono::Datelike;
414- use minijinja::value::{Object, Value};
415- use minijinja::{Environment, Error, Source};
416-
417- use std::borrow::Cow;
418-
419- use std::collections::HashMap;
420- use std::sync::Arc;
421+ pub use http::{Request, Response, StatusCode};
422+ pub use mailpot::{models::DbVal, *};
423+ use minijinja::{
424+ value::{Object, Value},
425+ Environment, Error, Source,
426+ };
427 use tokio::sync::RwLock;
428
429- pub use mailpot::models::DbVal;
430- pub use mailpot::*;
431- pub use std::result::Result;
432-
433 pub mod auth;
434 pub mod cal;
435 pub mod settings;
436+ pub mod typed_paths;
437 pub mod utils;
438
439 pub use auth::*;
440- pub use cal::calendarize;
441- pub use cal::*;
442+ pub use cal::{calendarize, *};
443 pub use settings::*;
444+ pub use typed_paths::{tsr::RouterExt, *};
445 pub use utils::*;
446
447 #[derive(Debug)]
448 diff --git a/web/src/main.rs b/web/src/main.rs
449index 05fb03b..d8af561 100644
450--- a/web/src/main.rs
451+++ b/web/src/main.rs
452 @@ -17,13 +17,11 @@
453 * along with this program. If not, see <https://www.gnu.org/licenses/>.
454 */
455
456- use mailpot_web::*;
457- use rand::Rng;
458+ use std::{collections::HashMap, sync::Arc};
459
460+ use mailpot_web::*;
461 use minijinja::value::Value;
462-
463- use std::collections::HashMap;
464- use std::sync::Arc;
465+ use rand::Rng;
466 use tokio::sync::RwLock;
467
468 #[tokio::main]
469 @@ -49,63 +47,64 @@ async fn main() {
470
471 let auth_layer = AuthLayer::new(shared_state.clone(), &secret);
472
473- let login_url = Arc::new(format!("{}/login/", shared_state.root_url_prefix).into());
474+ let login_url =
475+ Arc::new(format!("{}{}", shared_state.root_url_prefix, LoginPath.to_crumb()).into());
476 let app = Router::new()
477 .route("/", get(root))
478- .route_with_tsr("/lists/:pk/", get(list))
479- .route_with_tsr("/lists/:pk/:msgid/", get(list_post))
480- .route_with_tsr("/lists/:pk/edit/", get(list_edit))
481- .route_with_tsr("/help/", get(help))
482- .route_with_tsr(
483- "/login/",
484- get(auth::ssh_signin).post({
485+ .typed_get(list)
486+ .typed_get(list_post)
487+ .typed_get(list_edit)
488+ .typed_get(help)
489+ .typed_get(auth::ssh_signin)
490+ .typed_post({
491+ let shared_state = Arc::clone(&shared_state);
492+ move |path, session, query, auth, body| {
493+ auth::ssh_signin_post(path, session, query, auth, body, shared_state)
494+ }
495+ })
496+ .typed_get(logout_handler)
497+ .typed_post(logout_handler)
498+ .typed_get(
499+ {
500 let shared_state = Arc::clone(&shared_state);
501- move |session, query, auth, body| {
502- auth::ssh_signin_post(session, query, auth, body, shared_state)
503- }
504- }),
505+ move |path, session, user| settings(path, session, user, shared_state)
506+ }
507+ .layer(RequireAuth::login_or_redirect(
508+ Arc::clone(&login_url),
509+ Some(Arc::new("next".into())),
510+ )),
511 )
512- .route_with_tsr("/logout/", get(logout_handler))
513- .route_with_tsr(
514- "/settings/",
515- get({
516+ .typed_post(
517+ {
518 let shared_state = Arc::clone(&shared_state);
519- move |session, user| settings(session, user, shared_state)
520+ move |path, session, auth, body| {
521+ settings_post(path, session, auth, body, shared_state)
522+ }
523 }
524 .layer(RequireAuth::login_or_redirect(
525 Arc::clone(&login_url),
526 Some(Arc::new("next".into())),
527- )))
528- .post(
529- {
530- let shared_state = Arc::clone(&shared_state);
531- move |session, auth, body| settings_post(session, auth, body, shared_state)
532- }
533- .layer(RequireAuth::login_or_redirect(
534- Arc::clone(&login_url),
535- Some(Arc::new("next".into())),
536- )),
537- ),
538+ )),
539 )
540- .route_with_tsr(
541- "/settings/list/:pk/",
542- get(user_list_subscription)
543- .layer(RequireAuth::login_with_role_or_redirect(
544- Role::User..,
545- Arc::clone(&login_url),
546- Some(Arc::new("next".into())),
547- ))
548- .post({
549- let shared_state = Arc::clone(&shared_state);
550- move |session, path, user, body| {
551- user_list_subscription_post(session, path, user, body, shared_state)
552- }
553- })
554- .layer(RequireAuth::login_with_role_or_redirect(
555- Role::User..,
556- Arc::clone(&login_url),
557- Some(Arc::new("next".into())),
558- )),
559+ .typed_get(
560+ user_list_subscription.layer(RequireAuth::login_with_role_or_redirect(
561+ Role::User..,
562+ Arc::clone(&login_url),
563+ Some(Arc::new("next".into())),
564+ )),
565+ )
566+ .typed_post(
567+ {
568+ let shared_state = Arc::clone(&shared_state);
569+ move |session, path, user, body| {
570+ user_list_subscription_post(session, path, user, body, shared_state)
571+ }
572+ }
573+ .layer(RequireAuth::login_with_role_or_redirect(
574+ Role::User..,
575+ Arc::clone(&login_url),
576+ Some(Arc::new("next".into())),
577+ )),
578 )
579 .layer(auth_layer)
580 .layer(session_layer)
581 @@ -142,7 +141,7 @@ async fn root(
582 })
583 .collect::<Result<Vec<_>, mailpot::Error>>()?;
584 let crumbs = vec![Crumb {
585- label: "Lists".into(),
586+ label: "Home".into(),
587 url: "/".into(),
588 }];
589
590 @@ -160,20 +159,28 @@ async fn root(
591 }
592
593 async fn list(
594+ ListPath(id): ListPath,
595 mut session: WritableSession,
596- Path(id): Path<i64>,
597 auth: AuthContext,
598 State(state): State<Arc<AppState>>,
599 ) -> Result<Html<String>, ResponseError> {
600 let db = Connection::open_db(state.conf.clone())?;
601- let list = db.list(id)?;
602+ let Some(list) = (match id {
603+ ListPathIdentifier::Pk(id) => db.list(id)?,
604+ ListPathIdentifier::Id(id) => db.list_by_id(id)?,
605+ }) else {
606+ return Err(ResponseError::new(
607+ "List not found".to_string(),
608+ StatusCode::NOT_FOUND,
609+ ));
610+ };
611 let post_policy = db.list_policy(list.pk)?;
612 let subscription_policy = db.list_subscription_policy(list.pk)?;
613 let months = db.months(list.pk)?;
614 let user_context = auth
615 .current_user
616 .as_ref()
617- .map(|user| db.list_subscription_by_address(id, &user.address).ok());
618+ .map(|user| db.list_subscription_by_address(list.pk, &user.address).ok());
619
620 let posts = db.list_posts(list.pk, None)?;
621 let mut hist = months
622 @@ -214,12 +221,12 @@ async fn list(
623 .collect::<Vec<_>>();
624 let crumbs = vec![
625 Crumb {
626- label: "Lists".into(),
627+ label: "Home".into(),
628 url: "/".into(),
629 },
630 Crumb {
631 label: list.name.clone().into(),
632- url: format!("/lists/{}/", list.pk).into(),
633+ url: ListPath(list.pk().into()).to_crumb(),
634 },
635 ];
636 let context = minijinja::context! {
637 @@ -244,17 +251,25 @@ async fn list(
638 }
639
640 async fn list_post(
641+ ListPostPath(id, msg_id): ListPostPath,
642 mut session: WritableSession,
643- Path((id, msg_id)): Path<(i64, String)>,
644 auth: AuthContext,
645 State(state): State<Arc<AppState>>,
646 ) -> Result<Html<String>, ResponseError> {
647 let db = Connection::open_db(state.conf.clone())?;
648- let list = db.list(id)?;
649- let user_context = auth
650- .current_user
651- .as_ref()
652- .map(|user| db.list_subscription_by_address(id, &user.address).ok());
653+ let Some(list) = (match id {
654+ ListPathIdentifier::Pk(id) => db.list(id)?,
655+ ListPathIdentifier::Id(id) => db.list_by_id(id)?,
656+ }) else {
657+ return Err(ResponseError::new(
658+ "List not found".to_string(),
659+ StatusCode::NOT_FOUND,
660+ ));
661+ };
662+ let user_context = auth.current_user.as_ref().map(|user| {
663+ db.list_subscription_by_address(list.pk(), &user.address)
664+ .ok()
665+ });
666
667 let post = if let Some(post) = db.list_post_by_message_id(list.pk, &msg_id)? {
668 post
669 @@ -278,16 +293,16 @@ async fn list_post(
670 }
671 let crumbs = vec![
672 Crumb {
673- label: "Lists".into(),
674+ label: "Home".into(),
675 url: "/".into(),
676 },
677 Crumb {
678 label: list.name.clone().into(),
679- url: format!("/lists/{}/", list.pk).into(),
680+ url: ListPath(list.pk().into()).to_crumb(),
681 },
682 Crumb {
683 label: format!("{} {msg_id}", subject_ref).into(),
684- url: format!("/lists/{}/{}/", list.pk, msg_id).into(),
685+ url: ListPostPath(list.pk().into(), msg_id.to_string()).to_crumb(),
686 },
687 ];
688 let context = minijinja::context! {
689 @@ -317,21 +332,22 @@ async fn list_post(
690 Ok(Html(TEMPLATES.get_template("post.html")?.render(context)?))
691 }
692
693- async fn list_edit(Path(_): Path<i64>, State(_): State<Arc<AppState>>) {}
694+ async fn list_edit(ListEditPath(_): ListEditPath, State(_): State<Arc<AppState>>) {}
695
696 async fn help(
697+ _: HelpPath,
698 mut session: WritableSession,
699 auth: AuthContext,
700 State(state): State<Arc<AppState>>,
701 ) -> Result<Html<String>, ResponseError> {
702 let crumbs = vec![
703 Crumb {
704- label: "Lists".into(),
705+ label: "Home".into(),
706 url: "/".into(),
707 },
708 Crumb {
709 label: "Help".into(),
710- url: "/help/".into(),
711+ url: HelpPath.to_crumb(),
712 },
713 ];
714 let context = minijinja::context! {
715 diff --git a/web/src/settings.rs b/web/src/settings.rs
716index 2d6ea0f..caca82e 100644
717--- a/web/src/settings.rs
718+++ b/web/src/settings.rs
719 @@ -17,13 +17,15 @@
720 * along with this program. If not, see <https://www.gnu.org/licenses/>.
721 */
722
723- use super::*;
724 use mailpot::models::{
725 changesets::{AccountChangeset, ListSubscriptionChangeset},
726 ListSubscription,
727 };
728
729+ use super::*;
730+
731 pub async fn settings(
732+ _: SettingsPath,
733 mut session: WritableSession,
734 Extension(user): Extension<User>,
735 state: Arc<AppState>,
736 @@ -31,12 +33,12 @@ pub async fn settings(
737 let root_url_prefix = &state.root_url_prefix;
738 let crumbs = vec![
739 Crumb {
740- label: "Lists".into(),
741+ label: "Home".into(),
742 url: "/".into(),
743 },
744 Crumb {
745 label: "Settings".into(),
746- url: "/settings/".into(),
747+ url: SettingsPath.to_crumb(),
748 },
749 ];
750 let db = Connection::open_db(state.conf.clone())?;
751 @@ -50,10 +52,10 @@ pub async fn settings(
752 .account_subscriptions(acc.pk())
753 .with_status(StatusCode::BAD_REQUEST)?
754 .into_iter()
755- .map(|s| {
756- let list = db.list(s.list)?;
757-
758- Ok((s, list))
759+ .filter_map(|s| match db.list(s.list) {
760+ Err(err) => Some(Err(err)),
761+ Ok(Some(list)) => Some(Ok((s, list))),
762+ Ok(None) => None,
763 })
764 .collect::<Result<
765 Vec<(
766 @@ -92,6 +94,7 @@ pub enum ChangeSetting {
767 }
768
769 pub async fn settings_post(
770+ _: SettingsPath,
771 mut session: WritableSession,
772 Extension(user): Extension<User>,
773 Form(payload): Form<ChangeSetting>,
774 @@ -237,34 +240,29 @@ pub async fn settings_post(
775 }
776
777 Ok(Redirect::to(&format!(
778- "{}/settings/",
779- &state.root_url_prefix
780+ "{}/{}",
781+ &state.root_url_prefix,
782+ SettingsPath.to_uri()
783 )))
784 }
785
786 pub async fn user_list_subscription(
787+ ListSettingsPath(id): ListSettingsPath,
788 mut session: WritableSession,
789 Extension(user): Extension<User>,
790- Path(id): Path<i64>,
791 State(state): State<Arc<AppState>>,
792 ) -> Result<Html<String>, ResponseError> {
793 let root_url_prefix = &state.root_url_prefix;
794 let db = Connection::open_db(state.conf.clone())?;
795- let crumbs = vec![
796- Crumb {
797- label: "Lists".into(),
798- url: "/".into(),
799- },
800- Crumb {
801- label: "Settings".into(),
802- url: "/settings/".into(),
803- },
804- Crumb {
805- label: "List Subscription".into(),
806- url: format!("/settings/list/{}/", id).into(),
807- },
808- ];
809- let list = db.list(id)?;
810+ let Some(list) = (match id {
811+ ListPathIdentifier::Pk(id) => db.list(id)?,
812+ ListPathIdentifier::Id(id) => db.list_by_id(id)?,
813+ }) else {
814+ return Err(ResponseError::new(
815+ "List not found".to_string(),
816+ StatusCode::NOT_FOUND,
817+ ));
818+ };
819 let acc = match db.account_by_address(&user.address)? {
820 Some(v) => v,
821 None => {
822 @@ -277,10 +275,10 @@ pub async fn user_list_subscription(
823 let mut subscriptions = db
824 .account_subscriptions(acc.pk())
825 .with_status(StatusCode::BAD_REQUEST)?;
826- subscriptions.retain(|s| s.list == id);
827+ subscriptions.retain(|s| s.list == list.pk());
828 let subscription = db
829 .list_subscription(
830- id,
831+ list.pk(),
832 subscriptions
833 .get(0)
834 .ok_or_else(|| {
835 @@ -293,6 +291,21 @@ pub async fn user_list_subscription(
836 )
837 .with_status(StatusCode::BAD_REQUEST)?;
838
839+ let crumbs = vec![
840+ Crumb {
841+ label: "Home".into(),
842+ url: "/".into(),
843+ },
844+ Crumb {
845+ label: "Settings".into(),
846+ url: SettingsPath.to_crumb(),
847+ },
848+ Crumb {
849+ label: "List Subscription".into(),
850+ url: ListSettingsPath(list.pk().into()).to_crumb(),
851+ },
852+ ];
853+
854 let context = minijinja::context! {
855 title => state.site_title.as_ref(),
856 page_title => "Subscription settings",
857 @@ -327,15 +340,23 @@ pub struct SubscriptionFormPayload {
858 }
859
860 pub async fn user_list_subscription_post(
861+ ListSettingsPath(id): ListSettingsPath,
862 mut session: WritableSession,
863- Path(id): Path<i64>,
864 Extension(user): Extension<User>,
865 Form(payload): Form<SubscriptionFormPayload>,
866 state: Arc<AppState>,
867 ) -> Result<Redirect, ResponseError> {
868 let mut db = Connection::open_db(state.conf.clone())?;
869
870- let _list = db.list(id).with_status(StatusCode::NOT_FOUND)?;
871+ let Some(list) = (match id {
872+ ListPathIdentifier::Pk(id) => db.list(id as _)?,
873+ ListPathIdentifier::Id(id) => db.list_by_id(id)?,
874+ }) else {
875+ return Err(ResponseError::new(
876+ "List not found".to_string(),
877+ StatusCode::NOT_FOUND,
878+ ));
879+ };
880
881 let acc = match db.account_by_address(&user.address)? {
882 Some(v) => v,
883 @@ -350,9 +371,9 @@ pub async fn user_list_subscription_post(
884 .account_subscriptions(acc.pk())
885 .with_status(StatusCode::BAD_REQUEST)?;
886
887- subscriptions.retain(|s| s.list == id);
888+ subscriptions.retain(|s| s.list == list.pk());
889 let mut s = db
890- .list_subscription(id, subscriptions[0].pk())
891+ .list_subscription(list.pk(), subscriptions[0].pk())
892 .with_status(StatusCode::BAD_REQUEST)?;
893
894 let SubscriptionFormPayload {
895 @@ -386,7 +407,8 @@ pub async fn user_list_subscription_post(
896 })?;
897
898 Ok(Redirect::to(&format!(
899- "{}/settings/list/{id}/",
900- &state.root_url_prefix
901+ "{}{}",
902+ &state.root_url_prefix,
903+ ListSettingsPath(list.id.clone().into()).to_uri()
904 )))
905 }
906 diff --git a/web/src/templates/index.html b/web/src/templates/index.html
907index 608a9b8..e7b40db 100644
908--- a/web/src/templates/index.html
909+++ b/web/src/templates/index.html
910 @@ -3,7 +3,7 @@
911 <div class="body">
912 <ul>
913 {% for l in lists %}
914- <li><a href="{{ root_url_prefix|safe }}/lists/{{ l.list.pk }}/">{{ l.list.name }}</a></li>
915+ <li><a href="{{ root_url_prefix|safe }}{{ list_path(l.list.pk) }}">{{ l.list.name }}</a></li>
916 {% endfor %}
917 </ul>
918 </div>
919 diff --git a/web/src/templates/list.html b/web/src/templates/list.html
920index de6dbef..756b119 100644
921--- a/web/src/templates/list.html
922+++ b/web/src/templates/list.html
923 @@ -8,13 +8,13 @@
924 <br />
925 {% if current_user and not post_policy.no_subscriptions and subscription_policy.open %}
926 {% if user_context %}
927- <form method="post" action="{{ root_url_prefix }}/settings/" class="settings-form">
928+ <form method="post" action="{{ root_url_prefix }}{{ settings_path() }}" class="settings-form">
929 <input type="hidden" name="type", value="unsubscribe">
930 <input type="hidden" name="list_pk", value="{{ list.pk }}">
931 <input type="submit" name="unsubscribe" value="Unsubscribe as {{ current_user.address }}">
932 </form>
933 {% else %}
934- <form method="post" action="{{ root_url_prefix }}/settings/" class="settings-form">
935+ <form method="post" action="{{ root_url_prefix }}{{ settings_path() }}" class="settings-form">
936 <input type="hidden" name="type", value="subscribe">
937 <input type="hidden" name="list_pk", value="{{ list.pk }}">
938 <input type="submit" name="subscribe" value="Subscribe as {{ current_user.address }}">
939 @@ -95,7 +95,7 @@
940 <p>{{ posts | length }} post(s)</p>
941 {% for post in posts %}
942 <div class="entry">
943- <span class="subject"><a href="{{ root_url_prefix|safe }}/lists/{{post.list}}/<{{ post.message_id }}>/">{{ post.subject }}</a></span>
944+ <span class="subject"><a href="{{ root_url_prefix|safe }}{{ list_post_path(list.id, post.message_id )}}">{{ post.subject }}</a></span>
945 <span class="metadata">👤&nbsp;<span class="from">{{ post.address }}</span> 📆&nbsp;<span class="date">{{ post.datetime }}</span></span>
946 <span class="metadata">🪪 &nbsp;<span class="message-id">{{ post.message_id }}</span></span>
947 </div>
948 diff --git a/web/src/templates/lists.html b/web/src/templates/lists.html
949index 1ad33af..05d9037 100644
950--- a/web/src/templates/lists.html
951+++ b/web/src/templates/lists.html
952 @@ -4,7 +4,7 @@
953 <div class="entry">
954 <ul class="lists">
955 {% for l in lists %}
956- <li><a href="{{ root_url_prefix|safe }}/lists/{{ l.list.pk }}/">{{ l.list.name }}</a></li>
957+ <li><a href="{{ root_url_prefix|safe }}{{ list_path(l.list.id) }}">{{ l.list.name }}</a></li>
958 {% endfor %}
959 </ul>
960 </div>
961 diff --git a/web/src/templates/menu.html b/web/src/templates/menu.html
962index 5ed6a59..6d01f3c 100644
963--- a/web/src/templates/menu.html
964+++ b/web/src/templates/menu.html
965 @@ -1,11 +1,11 @@
966 <nav class="main-nav">
967 <ul>
968 <li><a href="{{ root_url_prefix }}/">Index</a></li>
969- <li><a href="{{ root_url_prefix }}/help/">Help&nbsp;&amp; Documentation</a></li>
970+ <li><a href="{{ root_url_prefix }}{{ help_path() }}">Help&nbsp;&amp; Documentation</a></li>
971 {% if current_user %}
972- <li class="push">Settings: <a href="{{ root_url_prefix }}/settings/">{{ current_user.address }}</a></li>
973+ <li class="push">Settings: <a href="{{ root_url_prefix }}{{ settings_path() }}">{{ current_user.address }}</a></li>
974 {% else %}
975- <li class="push"><a href="{{ root_url_prefix }}/login/">Login with SSH OTP</a></li>
976+ <li class="push"><a href="{{ root_url_prefix }}{{ login_path() }}">Login with SSH OTP</a></li>
977 {% endif %}
978 </ul>
979 </nav>
980 diff --git a/web/src/templates/post.html b/web/src/templates/post.html
981index 87f7f34..4b605b3 100644
982--- a/web/src/templates/post.html
983+++ b/web/src/templates/post.html
984 @@ -24,13 +24,13 @@
985 {% if in_reply_to %}
986 <tr>
987 <th scope="row">In-Reply-To:</th>
988- <td class="faded message-id"><a href="{{ root_url_prefix|safe }}/lists/{{ list.pk }}/<{{ in_reply_to }}>/">{{ in_reply_to }}</a></td>
989+ <td class="faded message-id"><a href="{{ root_url_prefix|safe }}{{ list_post_path(list.id, in_reply_to) }}">{{ in_reply_to }}</a></td>
990 </tr>
991 {% endif %}
992 {% if references %}
993 <tr>
994 <th scope="row">References:</th>
995- <td>{% for r in references %}<span class="faded message-id"><a href="{{ root_url_prefix|safe }}/lists/{{ list.pk }}/<{{ r }}>/">{{ r }}</a></span>{% endfor %}</td>
996+ <td>{% for r in references %}<span class="faded message-id"><a href="{{ root_url_prefix|safe }}{{ list_post_path(list.id, r) }}">{{ r }}</a></span>{% endfor %}</td>
997 </tr>
998 {% endif %}
999 </table>
1000 diff --git a/web/src/templates/settings_subscription.html b/web/src/templates/settings_subscription.html
1001index 476bc00..e6b5e35 100644
1002--- a/web/src/templates/settings_subscription.html
1003+++ b/web/src/templates/settings_subscription.html
1004 @@ -1,6 +1,6 @@
1005 {% include "header.html" %}
1006 <div class="body body-grid">
1007- <h3>Your subscription to <a href="{{ root_url_prefix|safe }}/lists/{{ list.pk }}/">{{ list.id }}</a>.</h3>
1008+ <h3>Your subscription to <a href="{{ root_url_prefix|safe }}{{ list_path(list.pk) }}">{{ list.id }}</a>.</h3>
1009 <address>
1010 {{ list.name }} <a href="mailto:{{ list.address | safe }}"><code>{{ list.address }}</code></a>
1011 </address>
1012 diff --git a/web/src/typed_paths.rs b/web/src/typed_paths.rs
1013new file mode 100644
1014index 0000000..23581c6
1015--- /dev/null
1016+++ b/web/src/typed_paths.rs
1017 @@ -0,0 +1,559 @@
1018+ /*
1019+ * This file is part of mailpot
1020+ *
1021+ * Copyright 2020 - Manos Pitsidianakis
1022+ *
1023+ * This program is free software: you can redistribute it and/or modify
1024+ * it under the terms of the GNU Affero General Public License as
1025+ * published by the Free Software Foundation, either version 3 of the
1026+ * License, or (at your option) any later version.
1027+ *
1028+ * This program is distributed in the hope that it will be useful,
1029+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
1030+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1031+ * GNU Affero General Public License for more details.
1032+ *
1033+ * You should have received a copy of the GNU Affero General Public License
1034+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
1035+ */
1036+
1037+ use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
1038+
1039+ use super::*;
1040+
1041+ // from https://github.com/servo/rust-url/blob/master/url/src/parser.rs
1042+ const FRAGMENT: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'<').add(b'>').add(b'`');
1043+ const PATH: &AsciiSet = &FRAGMENT.add(b'#').add(b'?').add(b'{').add(b'}');
1044+ pub const PATH_SEGMENT: &AsciiSet = &PATH.add(b'/').add(b'%');
1045+
1046+ pub trait IntoCrumb: TypedPath {
1047+ fn to_crumb(&self) -> Cow<'static, str> {
1048+ Cow::from(self.to_uri().to_string())
1049+ }
1050+ }
1051+
1052+ impl<TP: TypedPath> IntoCrumb for TP {}
1053+
1054+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
1055+ #[serde(untagged)]
1056+ pub enum ListPathIdentifier {
1057+ Pk(#[serde(deserialize_with = "parse_int")] i64),
1058+ Id(String),
1059+ }
1060+
1061+ fn parse_int<'de, T, D>(de: D) -> Result<T, D::Error>
1062+ where
1063+ D: serde::Deserializer<'de>,
1064+ T: std::str::FromStr,
1065+ <T as std::str::FromStr>::Err: std::fmt::Display,
1066+ {
1067+ use serde::Deserialize;
1068+ String::deserialize(de)?
1069+ .parse()
1070+ .map_err(serde::de::Error::custom)
1071+ }
1072+
1073+ impl From<i64> for ListPathIdentifier {
1074+ fn from(val: i64) -> Self {
1075+ Self::Pk(val)
1076+ }
1077+ }
1078+
1079+ impl From<String> for ListPathIdentifier {
1080+ fn from(val: String) -> Self {
1081+ Self::Id(val)
1082+ }
1083+ }
1084+
1085+ impl std::fmt::Display for ListPathIdentifier {
1086+ #[allow(clippy::unnecessary_to_owned)]
1087+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1088+ let id: Cow<'_, str> = match self {
1089+ Self::Pk(id) => id.to_string().into(),
1090+ Self::Id(id) => id.into(),
1091+ };
1092+ write!(f, "{}", utf8_percent_encode(&id, PATH_SEGMENT,))
1093+ }
1094+ }
1095+
1096+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
1097+ #[typed_path("/list/:id/")]
1098+ pub struct ListPath(pub ListPathIdentifier);
1099+
1100+ impl From<&DbVal<mailpot::models::MailingList>> for ListPath {
1101+ fn from(val: &DbVal<mailpot::models::MailingList>) -> Self {
1102+ Self(ListPathIdentifier::Id(val.id.clone()))
1103+ }
1104+ }
1105+
1106+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
1107+ #[typed_path("/list/:id/posts/:msgid/")]
1108+ pub struct ListPostPath(pub ListPathIdentifier, pub String);
1109+
1110+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
1111+ #[typed_path("/list/:id/edit/")]
1112+ pub struct ListEditPath(pub ListPathIdentifier);
1113+
1114+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
1115+ #[typed_path("/settings/list/:id/")]
1116+ pub struct ListSettingsPath(pub ListPathIdentifier);
1117+
1118+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
1119+ #[typed_path("/login/")]
1120+ pub struct LoginPath;
1121+
1122+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
1123+ #[typed_path("/logout/")]
1124+ pub struct LogoutPath;
1125+
1126+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
1127+ #[typed_path("/settings/")]
1128+ pub struct SettingsPath;
1129+
1130+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
1131+ #[typed_path("/help/")]
1132+ pub struct HelpPath;
1133+
1134+ macro_rules! unit_impl {
1135+ ($ident:ident, $ty:expr) => {
1136+ pub fn $ident() -> Value {
1137+ $ty.to_crumb().into()
1138+ }
1139+ };
1140+ }
1141+
1142+ unit_impl!(login_path, LoginPath);
1143+ unit_impl!(logout_path, LogoutPath);
1144+ unit_impl!(settings_path, SettingsPath);
1145+ unit_impl!(help_path, HelpPath);
1146+
1147+ macro_rules! list_id_impl {
1148+ ($ident:ident, $ty:tt) => {
1149+ pub fn $ident(id: Value) -> std::result::Result<Value, Error> {
1150+ if let Some(id) = id.as_str() {
1151+ return Ok($ty(ListPathIdentifier::Id(id.to_string()))
1152+ .to_crumb()
1153+ .into());
1154+ }
1155+ let pk = id.try_into()?;
1156+ Ok($ty(ListPathIdentifier::Pk(pk)).to_crumb().into())
1157+ }
1158+ };
1159+ }
1160+
1161+ list_id_impl!(list_path, ListPath);
1162+ list_id_impl!(list_settings_path, ListSettingsPath);
1163+ list_id_impl!(list_edit_path, ListEditPath);
1164+
1165+ pub fn list_post_path(id: Value, msg_id: Value) -> std::result::Result<Value, Error> {
1166+ let Some(msg_id) = msg_id.as_str().map(|s| if s.starts_with('<') && s.ends_with('>') { s.to_string() } else {
1167+ format!("<{s}>")
1168+ }) else {
1169+ return Err(Error::new(
1170+ minijinja::ErrorKind::UnknownMethod,
1171+ "Second argument of list_post_path must be a string."
1172+ ));
1173+ };
1174+
1175+ if let Some(id) = id.as_str() {
1176+ return Ok(ListPostPath(ListPathIdentifier::Id(id.to_string()), msg_id)
1177+ .to_crumb()
1178+ .into());
1179+ }
1180+ let pk = id.try_into()?;
1181+ Ok(ListPostPath(ListPathIdentifier::Pk(pk), msg_id)
1182+ .to_crumb()
1183+ .into())
1184+ }
1185+
1186+ pub mod tsr {
1187+ use std::{borrow::Cow, convert::Infallible};
1188+
1189+ use axum::{
1190+ http::Request,
1191+ response::{IntoResponse, Redirect, Response},
1192+ routing::{any, MethodRouter},
1193+ Router,
1194+ };
1195+ use axum_extra::routing::{RouterExt as ExtraRouterExt, SecondElementIs, TypedPath};
1196+ use http::{uri::PathAndQuery, StatusCode, Uri};
1197+ use tower_service::Service;
1198+
1199+ /// Extension trait that adds additional methods to [`Router`].
1200+ pub trait RouterExt<S, B>: ExtraRouterExt<S, B> {
1201+ /// Add a typed `GET` route to the router.
1202+ ///
1203+ /// The path will be inferred from the first argument to the handler
1204+ /// function which must implement [`TypedPath`].
1205+ ///
1206+ /// See [`TypedPath`] for more details and examples.
1207+ fn typed_get<H, T, P>(self, handler: H) -> Self
1208+ where
1209+ H: axum::handler::Handler<T, S, B>,
1210+ T: SecondElementIs<P> + 'static,
1211+ P: TypedPath;
1212+
1213+ /// Add a typed `DELETE` route to the router.
1214+ ///
1215+ /// The path will be inferred from the first argument to the handler
1216+ /// function which must implement [`TypedPath`].
1217+ ///
1218+ /// See [`TypedPath`] for more details and examples.
1219+ fn typed_delete<H, T, P>(self, handler: H) -> Self
1220+ where
1221+ H: axum::handler::Handler<T, S, B>,
1222+ T: SecondElementIs<P> + 'static,
1223+ P: TypedPath;
1224+
1225+ /// Add a typed `HEAD` route to the router.
1226+ ///
1227+ /// The path will be inferred from the first argument to the handler
1228+ /// function which must implement [`TypedPath`].
1229+ ///
1230+ /// See [`TypedPath`] for more details and examples.
1231+ fn typed_head<H, T, P>(self, handler: H) -> Self
1232+ where
1233+ H: axum::handler::Handler<T, S, B>,
1234+ T: SecondElementIs<P> + 'static,
1235+ P: TypedPath;
1236+
1237+ /// Add a typed `OPTIONS` route to the router.
1238+ ///
1239+ /// The path will be inferred from the first argument to the handler
1240+ /// function which must implement [`TypedPath`].
1241+ ///
1242+ /// See [`TypedPath`] for more details and examples.
1243+ fn typed_options<H, T, P>(self, handler: H) -> Self
1244+ where
1245+ H: axum::handler::Handler<T, S, B>,
1246+ T: SecondElementIs<P> + 'static,
1247+ P: TypedPath;
1248+
1249+ /// Add a typed `PATCH` route to the router.
1250+ ///
1251+ /// The path will be inferred from the first argument to the handler
1252+ /// function which must implement [`TypedPath`].
1253+ ///
1254+ /// See [`TypedPath`] for more details and examples.
1255+ fn typed_patch<H, T, P>(self, handler: H) -> Self
1256+ where
1257+ H: axum::handler::Handler<T, S, B>,
1258+ T: SecondElementIs<P> + 'static,
1259+ P: TypedPath;
1260+
1261+ /// Add a typed `POST` route to the router.
1262+ ///
1263+ /// The path will be inferred from the first argument to the handler
1264+ /// function which must implement [`TypedPath`].
1265+ ///
1266+ /// See [`TypedPath`] for more details and examples.
1267+ fn typed_post<H, T, P>(self, handler: H) -> Self
1268+ where
1269+ H: axum::handler::Handler<T, S, B>,
1270+ T: SecondElementIs<P> + 'static,
1271+ P: TypedPath;
1272+
1273+ /// Add a typed `PUT` route to the router.
1274+ ///
1275+ /// The path will be inferred from the first argument to the handler
1276+ /// function which must implement [`TypedPath`].
1277+ ///
1278+ /// See [`TypedPath`] for more details and examples.
1279+ fn typed_put<H, T, P>(self, handler: H) -> Self
1280+ where
1281+ H: axum::handler::Handler<T, S, B>,
1282+ T: SecondElementIs<P> + 'static,
1283+ P: TypedPath;
1284+
1285+ /// Add a typed `TRACE` route to the router.
1286+ ///
1287+ /// The path will be inferred from the first argument to the handler
1288+ /// function which must implement [`TypedPath`].
1289+ ///
1290+ /// See [`TypedPath`] for more details and examples.
1291+ fn typed_trace<H, T, P>(self, handler: H) -> Self
1292+ where
1293+ H: axum::handler::Handler<T, S, B>,
1294+ T: SecondElementIs<P> + 'static,
1295+ P: TypedPath;
1296+
1297+ /// Add another route to the router with an additional "trailing slash
1298+ /// redirect" route.
1299+ ///
1300+ /// If you add a route _without_ a trailing slash, such as `/foo`, this
1301+ /// method will also add a route for `/foo/` that redirects to
1302+ /// `/foo`.
1303+ ///
1304+ /// If you add a route _with_ a trailing slash, such as `/bar/`, this
1305+ /// method will also add a route for `/bar` that redirects to
1306+ /// `/bar/`.
1307+ ///
1308+ /// This is similar to what axum 0.5.x did by default, except this
1309+ /// explicitly adds another route, so trying to add a `/foo/`
1310+ /// route after calling `.route_with_tsr("/foo", /* ... */)`
1311+ /// will result in a panic due to route overlap.
1312+ ///
1313+ /// # Example
1314+ ///
1315+ /// ```
1316+ /// use axum::{routing::get, Router};
1317+ /// use axum_extra::routing::RouterExt;
1318+ ///
1319+ /// let app = Router::new()
1320+ /// // `/foo/` will redirect to `/foo`
1321+ /// .route_with_tsr("/foo", get(|| async {}))
1322+ /// // `/bar` will redirect to `/bar/`
1323+ /// .route_with_tsr("/bar/", get(|| async {}));
1324+ /// # let _: Router = app;
1325+ /// ```
1326+ fn route_with_tsr(self, path: &str, method_router: MethodRouter<S, B>) -> Self
1327+ where
1328+ Self: Sized;
1329+
1330+ /// Add another route to the router with an additional "trailing slash
1331+ /// redirect" route.
1332+ ///
1333+ /// This works like [`RouterExt::route_with_tsr`] but accepts any
1334+ /// [`Service`].
1335+ fn route_service_with_tsr<T>(self, path: &str, service: T) -> Self
1336+ where
1337+ T: Service<Request<B>, Error = Infallible> + Clone + Send + 'static,
1338+ T::Response: IntoResponse,
1339+ T::Future: Send + 'static,
1340+ Self: Sized;
1341+ }
1342+
1343+ impl<S, B> RouterExt<S, B> for Router<S, B>
1344+ where
1345+ B: axum::body::HttpBody + Send + 'static,
1346+ S: Clone + Send + Sync + 'static,
1347+ {
1348+ fn typed_get<H, T, P>(mut self, handler: H) -> Self
1349+ where
1350+ H: axum::handler::Handler<T, S, B>,
1351+ T: SecondElementIs<P> + 'static,
1352+ P: TypedPath,
1353+ {
1354+ let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
1355+ self = self.route(
1356+ tsr_path.as_ref(),
1357+ axum::routing::get(move |url| tsr_handler_into_async(url, tsr_handler)),
1358+ );
1359+ self = self.route(P::PATH, axum::routing::get(handler));
1360+ self
1361+ }
1362+
1363+ fn typed_delete<H, T, P>(mut self, handler: H) -> Self
1364+ where
1365+ H: axum::handler::Handler<T, S, B>,
1366+ T: SecondElementIs<P> + 'static,
1367+ P: TypedPath,
1368+ {
1369+ let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
1370+ self = self.route(
1371+ tsr_path.as_ref(),
1372+ axum::routing::delete(move |url| tsr_handler_into_async(url, tsr_handler)),
1373+ );
1374+ self = self.route(P::PATH, axum::routing::delete(handler));
1375+ self
1376+ }
1377+
1378+ fn typed_head<H, T, P>(mut self, handler: H) -> Self
1379+ where
1380+ H: axum::handler::Handler<T, S, B>,
1381+ T: SecondElementIs<P> + 'static,
1382+ P: TypedPath,
1383+ {
1384+ let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
1385+ self = self.route(
1386+ tsr_path.as_ref(),
1387+ axum::routing::head(move |url| tsr_handler_into_async(url, tsr_handler)),
1388+ );
1389+ self = self.route(P::PATH, axum::routing::head(handler));
1390+ self
1391+ }
1392+
1393+ fn typed_options<H, T, P>(mut self, handler: H) -> Self
1394+ where
1395+ H: axum::handler::Handler<T, S, B>,
1396+ T: SecondElementIs<P> + 'static,
1397+ P: TypedPath,
1398+ {
1399+ let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
1400+ self = self.route(
1401+ tsr_path.as_ref(),
1402+ axum::routing::options(move |url| tsr_handler_into_async(url, tsr_handler)),
1403+ );
1404+ self = self.route(P::PATH, axum::routing::options(handler));
1405+ self
1406+ }
1407+
1408+ fn typed_patch<H, T, P>(mut self, handler: H) -> Self
1409+ where
1410+ H: axum::handler::Handler<T, S, B>,
1411+ T: SecondElementIs<P> + 'static,
1412+ P: TypedPath,
1413+ {
1414+ let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
1415+ self = self.route(
1416+ tsr_path.as_ref(),
1417+ axum::routing::patch(move |url| tsr_handler_into_async(url, tsr_handler)),
1418+ );
1419+ self = self.route(P::PATH, axum::routing::patch(handler));
1420+ self
1421+ }
1422+
1423+ fn typed_post<H, T, P>(mut self, handler: H) -> Self
1424+ where
1425+ H: axum::handler::Handler<T, S, B>,
1426+ T: SecondElementIs<P> + 'static,
1427+ P: TypedPath,
1428+ {
1429+ let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
1430+ self = self.route(
1431+ tsr_path.as_ref(),
1432+ axum::routing::post(move |url| tsr_handler_into_async(url, tsr_handler)),
1433+ );
1434+ self = self.route(P::PATH, axum::routing::post(handler));
1435+ self
1436+ }
1437+
1438+ fn typed_put<H, T, P>(mut self, handler: H) -> Self
1439+ where
1440+ H: axum::handler::Handler<T, S, B>,
1441+ T: SecondElementIs<P> + 'static,
1442+ P: TypedPath,
1443+ {
1444+ let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
1445+ self = self.route(
1446+ tsr_path.as_ref(),
1447+ axum::routing::put(move |url| tsr_handler_into_async(url, tsr_handler)),
1448+ );
1449+ self = self.route(P::PATH, axum::routing::put(handler));
1450+ self
1451+ }
1452+
1453+ fn typed_trace<H, T, P>(mut self, handler: H) -> Self
1454+ where
1455+ H: axum::handler::Handler<T, S, B>,
1456+ T: SecondElementIs<P> + 'static,
1457+ P: TypedPath,
1458+ {
1459+ let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
1460+ self = self.route(
1461+ tsr_path.as_ref(),
1462+ axum::routing::trace(move |url| tsr_handler_into_async(url, tsr_handler)),
1463+ );
1464+ self = self.route(P::PATH, axum::routing::trace(handler));
1465+ self
1466+ }
1467+
1468+ #[track_caller]
1469+ fn route_with_tsr(mut self, path: &str, method_router: MethodRouter<S, B>) -> Self
1470+ where
1471+ Self: Sized,
1472+ {
1473+ validate_tsr_path(path);
1474+ self = self.route(path, method_router);
1475+ add_tsr_redirect_route(self, path)
1476+ }
1477+
1478+ #[track_caller]
1479+ fn route_service_with_tsr<T>(mut self, path: &str, service: T) -> Self
1480+ where
1481+ T: Service<Request<B>, Error = Infallible> + Clone + Send + 'static,
1482+ T::Response: IntoResponse,
1483+ T::Future: Send + 'static,
1484+ Self: Sized,
1485+ {
1486+ validate_tsr_path(path);
1487+ self = self.route_service(path, service);
1488+ add_tsr_redirect_route(self, path)
1489+ }
1490+ }
1491+
1492+ #[track_caller]
1493+ fn validate_tsr_path(path: &str) {
1494+ if path == "/" {
1495+ panic!("Cannot add a trailing slash redirect route for `/`")
1496+ }
1497+ }
1498+
1499+ #[inline]
1500+ fn add_tsr_redirect_route<S, B>(router: Router<S, B>, path: &str) -> Router<S, B>
1501+ where
1502+ B: axum::body::HttpBody + Send + 'static,
1503+ S: Clone + Send + Sync + 'static,
1504+ {
1505+ async fn redirect_handler(uri: Uri) -> Response {
1506+ let new_uri = map_path(uri, |path| {
1507+ path.strip_suffix('/')
1508+ .map(Cow::Borrowed)
1509+ .unwrap_or_else(|| Cow::Owned(format!("{path}/")))
1510+ });
1511+
1512+ if let Some(new_uri) = new_uri {
1513+ Redirect::permanent(&new_uri.to_string()).into_response()
1514+ } else {
1515+ StatusCode::BAD_REQUEST.into_response()
1516+ }
1517+ }
1518+
1519+ if let Some(path_without_trailing_slash) = path.strip_suffix('/') {
1520+ router.route(path_without_trailing_slash, any(redirect_handler))
1521+ } else {
1522+ router.route(&format!("{path}/"), any(redirect_handler))
1523+ }
1524+ }
1525+
1526+ #[inline]
1527+ fn tsr_redirect_route(path: &'_ str) -> (Cow<'_, str>, fn(Uri) -> Response) {
1528+ fn redirect_handler(uri: Uri) -> Response {
1529+ let new_uri = map_path(uri, |path| {
1530+ path.strip_suffix('/')
1531+ .map(Cow::Borrowed)
1532+ .unwrap_or_else(|| Cow::Owned(format!("{path}/")))
1533+ });
1534+
1535+ if let Some(new_uri) = new_uri {
1536+ Redirect::permanent(&new_uri.to_string()).into_response()
1537+ } else {
1538+ StatusCode::BAD_REQUEST.into_response()
1539+ }
1540+ }
1541+
1542+ if let Some(path_without_trailing_slash) = path.strip_suffix('/') {
1543+ (Cow::Borrowed(path_without_trailing_slash), redirect_handler)
1544+ } else {
1545+ (Cow::Owned(format!("{path}/")), redirect_handler)
1546+ }
1547+ }
1548+
1549+ #[inline]
1550+ async fn tsr_handler_into_async(u: Uri, h: fn(Uri) -> Response) -> Response {
1551+ h(u)
1552+ }
1553+
1554+ /// Map the path of a `Uri`.
1555+ ///
1556+ /// Returns `None` if the `Uri` cannot be put back together with the new
1557+ /// path.
1558+ fn map_path<F>(original_uri: Uri, f: F) -> Option<Uri>
1559+ where
1560+ F: FnOnce(&str) -> Cow<'_, str>,
1561+ {
1562+ let mut parts = original_uri.into_parts();
1563+ let path_and_query = parts.path_and_query.as_ref()?;
1564+
1565+ let new_path = f(path_and_query.path());
1566+
1567+ let new_path_and_query = if let Some(query) = &path_and_query.query() {
1568+ format!("{new_path}?{query}").parse::<PathAndQuery>().ok()?
1569+ } else {
1570+ new_path.parse::<PathAndQuery>().ok()?
1571+ };
1572+ parts.path_and_query = Some(new_path_and_query);
1573+
1574+ Uri::from_parts(parts).ok()
1575+ }
1576+ }
1577 diff --git a/web/src/utils.rs b/web/src/utils.rs
1578index 16d57fd..66f97d1 100644
1579--- a/web/src/utils.rs
1580+++ b/web/src/utils.rs
1581 @@ -22,7 +22,22 @@ use super::*;
1582 lazy_static::lazy_static! {
1583 pub static ref TEMPLATES: Environment<'static> = {
1584 let mut env = Environment::new();
1585- env.add_function("calendarize", calendarize);
1586+ macro_rules! add_function {
1587+ ($($id:ident),*$(,)?) => {
1588+ $(env.add_function(stringify!($id), $id);)*
1589+ }
1590+ }
1591+ add_function!(
1592+ calendarize,
1593+ login_path,
1594+ logout_path,
1595+ settings_path,
1596+ help_path,
1597+ list_path,
1598+ list_settings_path,
1599+ list_edit_path,
1600+ list_post_path
1601+ );
1602 env.set_source(Source::from_path("web/src/templates/"));
1603
1604 env
1605 @@ -106,7 +121,7 @@ impl Object for MailingList {
1606 )),
1607 _ => Err(Error::new(
1608 minijinja::ErrorKind::UnknownMethod,
1609- format!("aaaobject has no method named {name}"),
1610+ format!("object has no method named {name}"),
1611 )),
1612 }
1613 }