+829 -180 +/-19 browse
1 | diff --git a/Cargo.lock b/Cargo.lock |
2 | index 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 |
72 | index 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 |
85 | index 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 |
106 | index 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 |
131 | new file mode 100644 |
132 | index 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 |
142 | index 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 |
160 | index 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 |
354 | index 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 |
391 | index 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 |
449 | index 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 |
716 | index 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 |
907 | index 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 |
920 | index 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">👤 <span class="from">{{ post.address }}</span> 📆 <span class="date">{{ post.datetime }}</span></span> |
946 | <span class="metadata">🪪 <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 |
949 | index 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 |
962 | index 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 & Documentation</a></li> |
970 | + <li><a href="{{ root_url_prefix }}{{ help_path() }}">Help & 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 |
981 | index 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 |
1001 | index 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 |
1013 | new file mode 100644 |
1014 | index 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 |
1578 | index 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 | } |