web: add typed paths
parent
8fa4c910c1
commit
adb057583f
|
@ -277,14 +277,18 @@ checksum = "fe82ff817f40f735cdfd7092f810d3de63bf875f50d1f0c17928e28cdab64437"
|
|||
dependencies = [
|
||||
"axum",
|
||||
"axum-core",
|
||||
"axum-macros",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde",
|
||||
"serde_html_form",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http 0.4.0",
|
||||
|
@ -315,6 +319,18 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-macros"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bb524613be645939e280b7279f7b017f98cf7f5ef084ec374df373530e73277"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-sessions"
|
||||
version = "0.5.0"
|
||||
|
@ -1536,6 +1552,7 @@ dependencies = [
|
|||
"tempfile",
|
||||
"tokio",
|
||||
"tower-http 0.3.5",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -2263,6 +2280,19 @@ dependencies = [
|
|||
"syn 2.0.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_html_form"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53192e38d5c88564b924dbe9b60865ecbb71b81d38c4e61c817cffd3e36ef696"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.95"
|
||||
|
|
|
@ -110,7 +110,7 @@ fn run_app() -> std::result::Result<(), Box<dyn std::error::Error>> {
|
|||
std::fs::create_dir_all(&lists_path)?;
|
||||
lists_path.push("index.html");
|
||||
|
||||
let list = db.list(list.pk)?;
|
||||
let list = db.list(list.pk)?.unwrap();
|
||||
let post_policy = db.list_policy(list.pk)?;
|
||||
let months = db.months(list.pk)?;
|
||||
let posts = db.list_posts(list.pk, None)?;
|
||||
|
|
|
@ -37,6 +37,7 @@ macro_rules! list {
|
|||
.ok()
|
||||
.map(|pk| $db.list(pk).ok())
|
||||
.flatten()
|
||||
.flatten()
|
||||
})
|
||||
}};
|
||||
}
|
||||
|
@ -619,7 +620,7 @@ fn run_app(opt: Opt) -> Result<()> {
|
|||
println!("No subscriptions found.");
|
||||
} else {
|
||||
for s in subs {
|
||||
let list = db.list(s.list).unwrap_or_else(|err| panic!("Found subscription with list_pk = {} but no such list exists.\nListSubscription = {:?}\n\n{err}", s.list, s));
|
||||
let list = db.list(s.list).unwrap_or_else(|err| panic!("Found subscription with list_pk = {} but no such list exists.\nListSubscription = {:?}\n\n{err}", s.list, s)).unwrap_or_else(|| panic!("Found subscription with list_pk = {} but no such list exists.\nListSubscription = {:?}", s.list, s));
|
||||
println!("- {:?} {}", s, list);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -249,7 +249,7 @@ impl Connection {
|
|||
}
|
||||
|
||||
/// Fetch a mailing list by primary key.
|
||||
pub fn list(&self, pk: i64) -> Result<DbVal<MailingList>> {
|
||||
pub fn list(&self, pk: i64) -> Result<Option<DbVal<MailingList>>> {
|
||||
let mut stmt = self
|
||||
.connection
|
||||
.prepare("SELECT * FROM list WHERE pk = ?;")?;
|
||||
|
@ -269,10 +269,7 @@ impl Connection {
|
|||
))
|
||||
})
|
||||
.optional()?;
|
||||
ret.map_or_else(
|
||||
|| Err(Error::from(NotFound("list or list policy not found!"))),
|
||||
Ok,
|
||||
)
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
/// Fetch a mailing list by id.
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
format_code_in_doc_comments = true
|
||||
format_strings = true
|
||||
imports_granularity = "Crate"
|
||||
group_imports = "StdExternalCrate"
|
||||
wrap_comments = true
|
|
@ -16,7 +16,7 @@ path = "src/main.rs"
|
|||
|
||||
[dependencies]
|
||||
axum = { version = "^0.6" }
|
||||
axum-extra = { version = "^0.7" }
|
||||
axum-extra = { version = "^0.7", features = ["typed-routing"] }
|
||||
axum-login = { version = "^0.5" }
|
||||
axum-sessions = { version = "^0.5" }
|
||||
chrono = { version = "^0.4" }
|
||||
|
@ -33,3 +33,4 @@ serde_json = "^1"
|
|||
tempfile = { version = "^3.5" }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower-http = { version = "^0.3" }
|
||||
tower-service = { version = "^0.3" }
|
||||
|
|
|
@ -17,14 +17,12 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
use std::borrow::Cow;
|
||||
use tempfile::NamedTempFile;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
use std::{borrow::Cow, process::Stdio};
|
||||
|
||||
use std::process::Stdio;
|
||||
use tempfile::NamedTempFile;
|
||||
use tokio::{fs::File, io::AsyncWriteExt, process::Command};
|
||||
|
||||
use super::*;
|
||||
|
||||
const TOKEN_KEY: &str = "ssh_challenge";
|
||||
const EXPIRY_IN_SECS: i64 = 6 * 60;
|
||||
|
@ -76,6 +74,7 @@ pub struct AuthFormPayload {
|
|||
}
|
||||
|
||||
pub async fn ssh_signin(
|
||||
_: LoginPath,
|
||||
mut session: WritableSession,
|
||||
Query(next): Query<Next>,
|
||||
auth: AuthContext,
|
||||
|
@ -89,7 +88,7 @@ pub async fn ssh_signin(
|
|||
return err.into_response();
|
||||
}
|
||||
return next
|
||||
.or_else(|| format!("{}/settings/", state.root_url_prefix))
|
||||
.or_else(|| format!("{}{}", state.root_url_prefix, SettingsPath.to_uri()))
|
||||
.into_response();
|
||||
}
|
||||
if next.next.is_some() {
|
||||
|
@ -118,8 +117,7 @@ pub async fn ssh_signin(
|
|||
let (token, timestamp): (String, i64) = if let Some(tok) = prev_token {
|
||||
tok
|
||||
} else {
|
||||
use rand::distributions::Alphanumeric;
|
||||
use rand::{thread_rng, Rng};
|
||||
use rand::{distributions::Alphanumeric, thread_rng, Rng};
|
||||
|
||||
let mut rng = thread_rng();
|
||||
let chars: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
|
||||
|
@ -132,12 +130,12 @@ pub async fn ssh_signin(
|
|||
let root_url_prefix = &state.root_url_prefix;
|
||||
let crumbs = vec![
|
||||
Crumb {
|
||||
label: "Lists".into(),
|
||||
label: "Home".into(),
|
||||
url: "/".into(),
|
||||
},
|
||||
Crumb {
|
||||
label: "Sign in".into(),
|
||||
url: "/login/".into(),
|
||||
url: LoginPath.to_crumb(),
|
||||
},
|
||||
];
|
||||
|
||||
|
@ -164,6 +162,7 @@ pub async fn ssh_signin(
|
|||
}
|
||||
|
||||
pub async fn ssh_signin_post(
|
||||
_: LoginPath,
|
||||
mut session: WritableSession,
|
||||
Query(next): Query<Next>,
|
||||
mut auth: AuthContext,
|
||||
|
@ -175,7 +174,7 @@ pub async fn ssh_signin_post(
|
|||
message: "You are already logged in.".into(),
|
||||
level: Level::Info,
|
||||
})?;
|
||||
return Ok(next.or_else(|| format!("{}/settings/", state.root_url_prefix)));
|
||||
return Ok(next.or_else(|| format!("{}{}", state.root_url_prefix, SettingsPath.to_uri())));
|
||||
}
|
||||
|
||||
let now: i64 = chrono::offset::Utc::now().timestamp();
|
||||
|
@ -188,8 +187,9 @@ pub async fn ssh_signin_post(
|
|||
level: Level::Error,
|
||||
})?;
|
||||
return Ok(Redirect::to(&format!(
|
||||
"{}/login/{}",
|
||||
"{}{}{}",
|
||||
state.root_url_prefix,
|
||||
LoginPath.to_uri(),
|
||||
if let Some(ref next) = next.next {
|
||||
next.as_str()
|
||||
} else {
|
||||
|
@ -205,8 +205,9 @@ pub async fn ssh_signin_post(
|
|||
level: Level::Error,
|
||||
})?;
|
||||
return Ok(Redirect::to(&format!(
|
||||
"{}/login/{}",
|
||||
"{}{}{}",
|
||||
state.root_url_prefix,
|
||||
LoginPath.to_uri(),
|
||||
if let Some(ref next) = next.next {
|
||||
next.as_str()
|
||||
} else {
|
||||
|
@ -254,7 +255,7 @@ pub async fn ssh_signin_post(
|
|||
auth.login(&user)
|
||||
.await
|
||||
.map_err(|err| ResponseError::new(err.to_string(), StatusCode::BAD_REQUEST))?;
|
||||
Ok(next.or_else(|| format!("{}/settings/", state.root_url_prefix)))
|
||||
Ok(next.or_else(|| format!("{}{}", state.root_url_prefix, SettingsPath.to_uri())))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
|
@ -377,21 +378,24 @@ pub async fn ssh_keygen(sig: SshSignature) -> Result<(), Box<dyn std::error::Err
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn logout_handler(mut auth: AuthContext, State(state): State<Arc<AppState>>) -> Redirect {
|
||||
pub async fn logout_handler(
|
||||
_: LogoutPath,
|
||||
mut auth: AuthContext,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Redirect {
|
||||
auth.logout().await;
|
||||
Redirect::to(&format!("{}/settings/", state.root_url_prefix))
|
||||
Redirect::to(&format!("{}/", state.root_url_prefix))
|
||||
}
|
||||
|
||||
pub mod auth_request {
|
||||
use super::*;
|
||||
|
||||
use std::marker::PhantomData;
|
||||
use std::ops::RangeBounds;
|
||||
use std::{marker::PhantomData, ops::RangeBounds};
|
||||
|
||||
use axum::body::HttpBody;
|
||||
use dyn_clone::DynClone;
|
||||
use tower_http::auth::AuthorizeRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
trait RoleBounds<Role>: DynClone + Send + Sync {
|
||||
fn contains(&self, role: Option<Role>) -> bool;
|
||||
}
|
||||
|
@ -503,8 +507,8 @@ pub mod auth_request {
|
|||
Role: PartialOrd + PartialEq + Clone + Send + Sync + 'static,
|
||||
User: AuthUser<UserId, Role>,
|
||||
{
|
||||
/// Authorizes requests by requiring a logged in user, otherwise it rejects
|
||||
/// with [`http::StatusCode::UNAUTHORIZED`].
|
||||
/// Authorizes requests by requiring a logged in user, otherwise it
|
||||
/// rejects with [`http::StatusCode::UNAUTHORIZED`].
|
||||
pub fn login<ResBody>(
|
||||
) -> tower_http::auth::RequireAuthorizationLayer<Login<UserId, User, ResBody, Role>>
|
||||
where
|
||||
|
@ -539,13 +543,15 @@ pub mod auth_request {
|
|||
})
|
||||
}
|
||||
|
||||
/// Authorizes requests by requiring a logged in user, otherwise it redirects to the
|
||||
/// provided login URL.
|
||||
/// Authorizes requests by requiring a logged in user, otherwise it
|
||||
/// redirects to the provided login URL.
|
||||
///
|
||||
/// If `redirect_field_name` is set to a value, the login page will receive the path it was
|
||||
/// redirected from in the URI query part. For example, attempting to visit a protected path
|
||||
/// `/protected` would redirect you to `/login?next=/protected` allowing you to know how to
|
||||
/// return the visitor to their requested page.
|
||||
/// If `redirect_field_name` is set to a value, the login page will
|
||||
/// receive the path it was redirected from in the URI query
|
||||
/// part. For example, attempting to visit a protected path
|
||||
/// `/protected` would redirect you to `/login?next=/protected` allowing
|
||||
/// you to know how to return the visitor to their requested
|
||||
/// page.
|
||||
pub fn login_or_redirect<ResBody>(
|
||||
login_url: Arc<Cow<'static, str>>,
|
||||
redirect_field_name: Option<Arc<Cow<'static, str>>>,
|
||||
|
@ -567,10 +573,12 @@ pub mod auth_request {
|
|||
/// range of roles, otherwise it redirects to the
|
||||
/// provided login URL.
|
||||
///
|
||||
/// If `redirect_field_name` is set to a value, the login page will receive the path it was
|
||||
/// redirected from in the URI query part. For example, attempting to visit a protected path
|
||||
/// `/protected` would redirect you to `/login?next=/protected` allowing you to know how to
|
||||
/// return the visitor to their requested page.
|
||||
/// If `redirect_field_name` is set to a value, the login page will
|
||||
/// receive the path it was redirected from in the URI query
|
||||
/// part. For example, attempting to visit a protected path
|
||||
/// `/protected` would redirect you to `/login?next=/protected` allowing
|
||||
/// you to know how to return the visitor to their requested
|
||||
/// page.
|
||||
pub fn login_with_role_or_redirect<ResBody>(
|
||||
role_bounds: impl RangeBounds<Role> + Clone + Send + Sync + 'static,
|
||||
login_url: Arc<Cow<'static, str>>,
|
||||
|
|
|
@ -9,8 +9,8 @@
|
|||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
|
@ -25,8 +25,8 @@ use chrono::*;
|
|||
#[allow(dead_code)]
|
||||
/// Generate a calendar view of the given date's month.
|
||||
///
|
||||
/// Each vector element is an array of seven numbers representing weeks (starting on Sundays),
|
||||
/// and each value is the numeric date.
|
||||
/// Each vector element is an array of seven numbers representing weeks
|
||||
/// (starting on Sundays), and each value is the numeric date.
|
||||
/// A value of zero means a date that not exists in the current month.
|
||||
///
|
||||
/// # Examples
|
||||
|
@ -50,8 +50,8 @@ pub fn calendarize(date: NaiveDate) -> Vec<[u32; 7]> {
|
|||
|
||||
/// Generate a calendar view of the given date's month and offset.
|
||||
///
|
||||
/// Each vector element is an array of seven numbers representing weeks (starting on Sundays),
|
||||
/// and each value is the numeric date.
|
||||
/// Each vector element is an array of seven numbers representing weeks
|
||||
/// (starting on Sundays), and each value is the numeric date.
|
||||
/// A value of zero means a date that not exists in the current month.
|
||||
///
|
||||
/// Offset means the number of days from sunday.
|
||||
|
|
|
@ -24,9 +24,7 @@ pub use axum::{
|
|||
routing::{get, post},
|
||||
Extension, Form, Router,
|
||||
};
|
||||
|
||||
pub use axum_extra::routing::RouterExt;
|
||||
|
||||
pub use axum_extra::routing::TypedPath;
|
||||
pub use axum_login::{
|
||||
memory_store::MemoryStore as AuthMemoryStore, secrecy::SecretVec, AuthLayer, AuthUser,
|
||||
RequireAuthorizationLayer,
|
||||
|
@ -42,31 +40,28 @@ pub type AuthContext =
|
|||
|
||||
pub type RequireAuth = auth::auth_request::RequireAuthorizationLayer<i64, auth::User, auth::Role>;
|
||||
|
||||
pub use http::{Request, Response, StatusCode};
|
||||
pub use std::result::Result;
|
||||
use std::{borrow::Cow, collections::HashMap, sync::Arc};
|
||||
|
||||
use chrono::Datelike;
|
||||
use minijinja::value::{Object, Value};
|
||||
use minijinja::{Environment, Error, Source};
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
pub use http::{Request, Response, StatusCode};
|
||||
pub use mailpot::{models::DbVal, *};
|
||||
use minijinja::{
|
||||
value::{Object, Value},
|
||||
Environment, Error, Source,
|
||||
};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
pub use mailpot::models::DbVal;
|
||||
pub use mailpot::*;
|
||||
pub use std::result::Result;
|
||||
|
||||
pub mod auth;
|
||||
pub mod cal;
|
||||
pub mod settings;
|
||||
pub mod typed_paths;
|
||||
pub mod utils;
|
||||
|
||||
pub use auth::*;
|
||||
pub use cal::calendarize;
|
||||
pub use cal::*;
|
||||
pub use cal::{calendarize, *};
|
||||
pub use settings::*;
|
||||
pub use typed_paths::{tsr::RouterExt, *};
|
||||
pub use utils::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
160
web/src/main.rs
160
web/src/main.rs
|
@ -17,13 +17,11 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use mailpot_web::*;
|
||||
use rand::Rng;
|
||||
|
||||
use minijinja::value::Value;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use rand::Rng;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[tokio::main]
|
||||
|
@ -49,63 +47,64 @@ async fn main() {
|
|||
|
||||
let auth_layer = AuthLayer::new(shared_state.clone(), &secret);
|
||||
|
||||
let login_url = Arc::new(format!("{}/login/", shared_state.root_url_prefix).into());
|
||||
let login_url =
|
||||
Arc::new(format!("{}{}", shared_state.root_url_prefix, LoginPath.to_crumb()).into());
|
||||
let app = Router::new()
|
||||
.route("/", get(root))
|
||||
.route_with_tsr("/lists/:pk/", get(list))
|
||||
.route_with_tsr("/lists/:pk/:msgid/", get(list_post))
|
||||
.route_with_tsr("/lists/:pk/edit/", get(list_edit))
|
||||
.route_with_tsr("/help/", get(help))
|
||||
.route_with_tsr(
|
||||
"/login/",
|
||||
get(auth::ssh_signin).post({
|
||||
.typed_get(list)
|
||||
.typed_get(list_post)
|
||||
.typed_get(list_edit)
|
||||
.typed_get(help)
|
||||
.typed_get(auth::ssh_signin)
|
||||
.typed_post({
|
||||
let shared_state = Arc::clone(&shared_state);
|
||||
move |path, session, query, auth, body| {
|
||||
auth::ssh_signin_post(path, session, query, auth, body, shared_state)
|
||||
}
|
||||
})
|
||||
.typed_get(logout_handler)
|
||||
.typed_post(logout_handler)
|
||||
.typed_get(
|
||||
{
|
||||
let shared_state = Arc::clone(&shared_state);
|
||||
move |session, query, auth, body| {
|
||||
auth::ssh_signin_post(session, query, auth, body, shared_state)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route_with_tsr("/logout/", get(logout_handler))
|
||||
.route_with_tsr(
|
||||
"/settings/",
|
||||
get({
|
||||
let shared_state = Arc::clone(&shared_state);
|
||||
move |session, user| settings(session, user, shared_state)
|
||||
move |path, session, user| settings(path, session, user, shared_state)
|
||||
}
|
||||
.layer(RequireAuth::login_or_redirect(
|
||||
Arc::clone(&login_url),
|
||||
Some(Arc::new("next".into())),
|
||||
)))
|
||||
.post(
|
||||
{
|
||||
let shared_state = Arc::clone(&shared_state);
|
||||
move |session, auth, body| settings_post(session, auth, body, shared_state)
|
||||
}
|
||||
.layer(RequireAuth::login_or_redirect(
|
||||
Arc::clone(&login_url),
|
||||
Some(Arc::new("next".into())),
|
||||
)),
|
||||
),
|
||||
)),
|
||||
)
|
||||
.route_with_tsr(
|
||||
"/settings/list/:pk/",
|
||||
get(user_list_subscription)
|
||||
.layer(RequireAuth::login_with_role_or_redirect(
|
||||
Role::User..,
|
||||
Arc::clone(&login_url),
|
||||
Some(Arc::new("next".into())),
|
||||
))
|
||||
.post({
|
||||
let shared_state = Arc::clone(&shared_state);
|
||||
move |session, path, user, body| {
|
||||
user_list_subscription_post(session, path, user, body, shared_state)
|
||||
}
|
||||
})
|
||||
.layer(RequireAuth::login_with_role_or_redirect(
|
||||
Role::User..,
|
||||
Arc::clone(&login_url),
|
||||
Some(Arc::new("next".into())),
|
||||
)),
|
||||
.typed_post(
|
||||
{
|
||||
let shared_state = Arc::clone(&shared_state);
|
||||
move |path, session, auth, body| {
|
||||
settings_post(path, session, auth, body, shared_state)
|
||||
}
|
||||
}
|
||||
.layer(RequireAuth::login_or_redirect(
|
||||
Arc::clone(&login_url),
|
||||
Some(Arc::new("next".into())),
|
||||
)),
|
||||
)
|
||||
.typed_get(
|
||||
user_list_subscription.layer(RequireAuth::login_with_role_or_redirect(
|
||||
Role::User..,
|
||||
Arc::clone(&login_url),
|
||||
Some(Arc::new("next".into())),
|
||||
)),
|
||||
)
|
||||
.typed_post(
|
||||
{
|
||||
let shared_state = Arc::clone(&shared_state);
|
||||
move |session, path, user, body| {
|
||||
user_list_subscription_post(session, path, user, body, shared_state)
|
||||
}
|
||||
}
|
||||
.layer(RequireAuth::login_with_role_or_redirect(
|
||||
Role::User..,
|
||||
Arc::clone(&login_url),
|
||||
Some(Arc::new("next".into())),
|
||||
)),
|
||||
)
|
||||
.layer(auth_layer)
|
||||
.layer(session_layer)
|
||||
|
@ -142,7 +141,7 @@ async fn root(
|
|||
})
|
||||
.collect::<Result<Vec<_>, mailpot::Error>>()?;
|
||||
let crumbs = vec![Crumb {
|
||||
label: "Lists".into(),
|
||||
label: "Home".into(),
|
||||
url: "/".into(),
|
||||
}];
|
||||
|
||||
|
@ -160,20 +159,28 @@ async fn root(
|
|||
}
|
||||
|
||||
async fn list(
|
||||
ListPath(id): ListPath,
|
||||
mut session: WritableSession,
|
||||
Path(id): Path<i64>,
|
||||
auth: AuthContext,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Html<String>, ResponseError> {
|
||||
let db = Connection::open_db(state.conf.clone())?;
|
||||
let list = db.list(id)?;
|
||||
let Some(list) = (match id {
|
||||
ListPathIdentifier::Pk(id) => db.list(id)?,
|
||||
ListPathIdentifier::Id(id) => db.list_by_id(id)?,
|
||||
}) else {
|
||||
return Err(ResponseError::new(
|
||||
"List not found".to_string(),
|
||||
StatusCode::NOT_FOUND,
|
||||
));
|
||||
};
|
||||
let post_policy = db.list_policy(list.pk)?;
|
||||
let subscription_policy = db.list_subscription_policy(list.pk)?;
|
||||
let months = db.months(list.pk)?;
|
||||
let user_context = auth
|
||||
.current_user
|
||||
.as_ref()
|
||||
.map(|user| db.list_subscription_by_address(id, &user.address).ok());
|
||||
.map(|user| db.list_subscription_by_address(list.pk, &user.address).ok());
|
||||
|
||||
let posts = db.list_posts(list.pk, None)?;
|
||||
let mut hist = months
|
||||
|
@ -214,12 +221,12 @@ async fn list(
|
|||
.collect::<Vec<_>>();
|
||||
let crumbs = vec![
|
||||
Crumb {
|
||||
label: "Lists".into(),
|
||||
label: "Home".into(),
|
||||
url: "/".into(),
|
||||
},
|
||||
Crumb {
|
||||
label: list.name.clone().into(),
|
||||
url: format!("/lists/{}/", list.pk).into(),
|
||||
url: ListPath(list.pk().into()).to_crumb(),
|
||||
},
|
||||
];
|
||||
let context = minijinja::context! {
|
||||
|
@ -244,17 +251,25 @@ async fn list(
|
|||
}
|
||||
|
||||
async fn list_post(
|
||||
ListPostPath(id, msg_id): ListPostPath,
|
||||
mut session: WritableSession,
|
||||
Path((id, msg_id)): Path<(i64, String)>,
|
||||
auth: AuthContext,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Html<String>, ResponseError> {
|
||||
let db = Connection::open_db(state.conf.clone())?;
|
||||
let list = db.list(id)?;
|
||||
let user_context = auth
|
||||
.current_user
|
||||
.as_ref()
|
||||
.map(|user| db.list_subscription_by_address(id, &user.address).ok());
|
||||
let Some(list) = (match id {
|
||||
ListPathIdentifier::Pk(id) => db.list(id)?,
|
||||
ListPathIdentifier::Id(id) => db.list_by_id(id)?,
|
||||
}) else {
|
||||
return Err(ResponseError::new(
|
||||
"List not found".to_string(),
|
||||
StatusCode::NOT_FOUND,
|
||||
));
|
||||
};
|
||||
let user_context = auth.current_user.as_ref().map(|user| {
|
||||
db.list_subscription_by_address(list.pk(), &user.address)
|
||||
.ok()
|
||||
});
|
||||
|
||||
let post = if let Some(post) = db.list_post_by_message_id(list.pk, &msg_id)? {
|
||||
post
|
||||
|
@ -278,16 +293,16 @@ async fn list_post(
|
|||
}
|
||||
let crumbs = vec![
|
||||
Crumb {
|
||||
label: "Lists".into(),
|
||||
label: "Home".into(),
|
||||
url: "/".into(),
|
||||
},
|
||||
Crumb {
|
||||
label: list.name.clone().into(),
|
||||
url: format!("/lists/{}/", list.pk).into(),
|
||||
url: ListPath(list.pk().into()).to_crumb(),
|
||||
},
|
||||
Crumb {
|
||||
label: format!("{} {msg_id}", subject_ref).into(),
|
||||
url: format!("/lists/{}/{}/", list.pk, msg_id).into(),
|
||||
url: ListPostPath(list.pk().into(), msg_id.to_string()).to_crumb(),
|
||||
},
|
||||
];
|
||||
let context = minijinja::context! {
|
||||
|
@ -317,21 +332,22 @@ async fn list_post(
|
|||
Ok(Html(TEMPLATES.get_template("post.html")?.render(context)?))
|
||||
}
|
||||
|
||||
async fn list_edit(Path(_): Path<i64>, State(_): State<Arc<AppState>>) {}
|
||||
async fn list_edit(ListEditPath(_): ListEditPath, State(_): State<Arc<AppState>>) {}
|
||||
|
||||
async fn help(
|
||||
_: HelpPath,
|
||||
mut session: WritableSession,
|
||||
auth: AuthContext,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Html<String>, ResponseError> {
|
||||
let crumbs = vec![
|
||||
Crumb {
|
||||
label: "Lists".into(),
|
||||
label: "Home".into(),
|
||||
url: "/".into(),
|
||||
},
|
||||
Crumb {
|
||||
label: "Help".into(),
|
||||
url: "/help/".into(),
|
||||
url: HelpPath.to_crumb(),
|
||||
},
|
||||
];
|
||||
let context = minijinja::context! {
|
||||
|
|
|
@ -17,13 +17,15 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
use mailpot::models::{
|
||||
changesets::{AccountChangeset, ListSubscriptionChangeset},
|
||||
ListSubscription,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
pub async fn settings(
|
||||
_: SettingsPath,
|
||||
mut session: WritableSession,
|
||||
Extension(user): Extension<User>,
|
||||
state: Arc<AppState>,
|
||||
|
@ -31,12 +33,12 @@ pub async fn settings(
|
|||
let root_url_prefix = &state.root_url_prefix;
|
||||
let crumbs = vec![
|
||||
Crumb {
|
||||
label: "Lists".into(),
|
||||
label: "Home".into(),
|
||||
url: "/".into(),
|
||||
},
|
||||
Crumb {
|
||||
label: "Settings".into(),
|
||||
url: "/settings/".into(),
|
||||
url: SettingsPath.to_crumb(),
|
||||
},
|
||||
];
|
||||
let db = Connection::open_db(state.conf.clone())?;
|
||||
|
@ -50,10 +52,10 @@ pub async fn settings(
|
|||
.account_subscriptions(acc.pk())
|
||||
.with_status(StatusCode::BAD_REQUEST)?
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let list = db.list(s.list)?;
|
||||
|
||||
Ok((s, list))
|
||||
.filter_map(|s| match db.list(s.list) {
|
||||
Err(err) => Some(Err(err)),
|
||||
Ok(Some(list)) => Some(Ok((s, list))),
|
||||
Ok(None) => None,
|
||||
})
|
||||
.collect::<Result<
|
||||
Vec<(
|
||||
|
@ -92,6 +94,7 @@ pub enum ChangeSetting {
|
|||
}
|
||||
|
||||
pub async fn settings_post(
|
||||
_: SettingsPath,
|
||||
mut session: WritableSession,
|
||||
Extension(user): Extension<User>,
|
||||
Form(payload): Form<ChangeSetting>,
|
||||
|
@ -237,34 +240,29 @@ pub async fn settings_post(
|
|||
}
|
||||
|
||||
Ok(Redirect::to(&format!(
|
||||
"{}/settings/",
|
||||
&state.root_url_prefix
|
||||
"{}/{}",
|
||||
&state.root_url_prefix,
|
||||
SettingsPath.to_uri()
|
||||
)))
|
||||
}
|
||||
|
||||
pub async fn user_list_subscription(
|
||||
ListSettingsPath(id): ListSettingsPath,
|
||||
mut session: WritableSession,
|
||||
Extension(user): Extension<User>,
|
||||
Path(id): Path<i64>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Html<String>, ResponseError> {
|
||||
let root_url_prefix = &state.root_url_prefix;
|
||||
let db = Connection::open_db(state.conf.clone())?;
|
||||
let crumbs = vec![
|
||||
Crumb {
|
||||
label: "Lists".into(),
|
||||
url: "/".into(),
|
||||
},
|
||||
Crumb {
|
||||
label: "Settings".into(),
|
||||
url: "/settings/".into(),
|
||||
},
|
||||
Crumb {
|
||||
label: "List Subscription".into(),
|
||||
url: format!("/settings/list/{}/", id).into(),
|
||||
},
|
||||
];
|
||||
let list = db.list(id)?;
|
||||
let Some(list) = (match id {
|
||||
ListPathIdentifier::Pk(id) => db.list(id)?,
|
||||
ListPathIdentifier::Id(id) => db.list_by_id(id)?,
|
||||
}) else {
|
||||
return Err(ResponseError::new(
|
||||
"List not found".to_string(),
|
||||
StatusCode::NOT_FOUND,
|
||||
));
|
||||
};
|
||||
let acc = match db.account_by_address(&user.address)? {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
|
@ -277,10 +275,10 @@ pub async fn user_list_subscription(
|
|||
let mut subscriptions = db
|
||||
.account_subscriptions(acc.pk())
|
||||
.with_status(StatusCode::BAD_REQUEST)?;
|
||||
subscriptions.retain(|s| s.list == id);
|
||||
subscriptions.retain(|s| s.list == list.pk());
|
||||
let subscription = db
|
||||
.list_subscription(
|
||||
id,
|
||||
list.pk(),
|
||||
subscriptions
|
||||
.get(0)
|
||||
.ok_or_else(|| {
|
||||
|
@ -293,6 +291,21 @@ pub async fn user_list_subscription(
|
|||
)
|
||||
.with_status(StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let crumbs = vec![
|
||||
Crumb {
|
||||
label: "Home".into(),
|
||||
url: "/".into(),
|
||||
},
|
||||
Crumb {
|
||||
label: "Settings".into(),
|
||||
url: SettingsPath.to_crumb(),
|
||||
},
|
||||
Crumb {
|
||||
label: "List Subscription".into(),
|
||||
url: ListSettingsPath(list.pk().into()).to_crumb(),
|
||||
},
|
||||
];
|
||||
|
||||
let context = minijinja::context! {
|
||||
title => state.site_title.as_ref(),
|
||||
page_title => "Subscription settings",
|
||||
|
@ -327,15 +340,23 @@ pub struct SubscriptionFormPayload {
|
|||
}
|
||||
|
||||
pub async fn user_list_subscription_post(
|
||||
ListSettingsPath(id): ListSettingsPath,
|
||||
mut session: WritableSession,
|
||||
Path(id): Path<i64>,
|
||||
Extension(user): Extension<User>,
|
||||
Form(payload): Form<SubscriptionFormPayload>,
|
||||
state: Arc<AppState>,
|
||||
) -> Result<Redirect, ResponseError> {
|
||||
let mut db = Connection::open_db(state.conf.clone())?;
|
||||
|
||||
let _list = db.list(id).with_status(StatusCode::NOT_FOUND)?;
|
||||
let Some(list) = (match id {
|
||||
ListPathIdentifier::Pk(id) => db.list(id as _)?,
|
||||
ListPathIdentifier::Id(id) => db.list_by_id(id)?,
|
||||
}) else {
|
||||
return Err(ResponseError::new(
|
||||
"List not found".to_string(),
|
||||
StatusCode::NOT_FOUND,
|
||||
));
|
||||
};
|
||||
|
||||
let acc = match db.account_by_address(&user.address)? {
|
||||
Some(v) => v,
|
||||
|
@ -350,9 +371,9 @@ pub async fn user_list_subscription_post(
|
|||
.account_subscriptions(acc.pk())
|
||||
.with_status(StatusCode::BAD_REQUEST)?;
|
||||
|
||||
subscriptions.retain(|s| s.list == id);
|
||||
subscriptions.retain(|s| s.list == list.pk());
|
||||
let mut s = db
|
||||
.list_subscription(id, subscriptions[0].pk())
|
||||
.list_subscription(list.pk(), subscriptions[0].pk())
|
||||
.with_status(StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let SubscriptionFormPayload {
|
||||
|
@ -386,7 +407,8 @@ pub async fn user_list_subscription_post(
|
|||
})?;
|
||||
|
||||
Ok(Redirect::to(&format!(
|
||||
"{}/settings/list/{id}/",
|
||||
&state.root_url_prefix
|
||||
"{}{}",
|
||||
&state.root_url_prefix,
|
||||
ListSettingsPath(list.id.clone().into()).to_uri()
|
||||
)))
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<div class="body">
|
||||
<ul>
|
||||
{% for l in lists %}
|
||||
<li><a href="{{ root_url_prefix|safe }}/lists/{{ l.list.pk }}/">{{ l.list.name }}</a></li>
|
||||
<li><a href="{{ root_url_prefix|safe }}{{ list_path(l.list.pk) }}">{{ l.list.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
@ -8,13 +8,13 @@
|
|||
<br />
|
||||
{% if current_user and not post_policy.no_subscriptions and subscription_policy.open %}
|
||||
{% if user_context %}
|
||||
<form method="post" action="{{ root_url_prefix }}/settings/" class="settings-form">
|
||||
<form method="post" action="{{ root_url_prefix }}{{ settings_path() }}" class="settings-form">
|
||||
<input type="hidden" name="type", value="unsubscribe">
|
||||
<input type="hidden" name="list_pk", value="{{ list.pk }}">
|
||||
<input type="submit" name="unsubscribe" value="Unsubscribe as {{ current_user.address }}">
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{{ root_url_prefix }}/settings/" class="settings-form">
|
||||
<form method="post" action="{{ root_url_prefix }}{{ settings_path() }}" class="settings-form">
|
||||
<input type="hidden" name="type", value="subscribe">
|
||||
<input type="hidden" name="list_pk", value="{{ list.pk }}">
|
||||
<input type="submit" name="subscribe" value="Subscribe as {{ current_user.address }}">
|
||||
|
@ -95,7 +95,7 @@
|
|||
<p>{{ posts | length }} post(s)</p>
|
||||
{% for post in posts %}
|
||||
<div class="entry">
|
||||
<span class="subject"><a href="{{ root_url_prefix|safe }}/lists/{{post.list}}/<{{ post.message_id }}>/">{{ post.subject }}</a></span>
|
||||
<span class="subject"><a href="{{ root_url_prefix|safe }}{{ list_post_path(list.id, post.message_id )}}">{{ post.subject }}</a></span>
|
||||
<span class="metadata">👤 <span class="from">{{ post.address }}</span> 📆 <span class="date">{{ post.datetime }}</span></span>
|
||||
<span class="metadata">🪪 <span class="message-id">{{ post.message_id }}</span></span>
|
||||
</div>
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
<div class="entry">
|
||||
<ul class="lists">
|
||||
{% for l in lists %}
|
||||
<li><a href="{{ root_url_prefix|safe }}/lists/{{ l.list.pk }}/">{{ l.list.name }}</a></li>
|
||||
<li><a href="{{ root_url_prefix|safe }}{{ list_path(l.list.id) }}">{{ l.list.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<nav class="main-nav">
|
||||
<ul>
|
||||
<li><a href="{{ root_url_prefix }}/">Index</a></li>
|
||||
<li><a href="{{ root_url_prefix }}/help/">Help & Documentation</a></li>
|
||||
<li><a href="{{ root_url_prefix }}{{ help_path() }}">Help & Documentation</a></li>
|
||||
{% if current_user %}
|
||||
<li class="push">Settings: <a href="{{ root_url_prefix }}/settings/">{{ current_user.address }}</a></li>
|
||||
<li class="push">Settings: <a href="{{ root_url_prefix }}{{ settings_path() }}">{{ current_user.address }}</a></li>
|
||||
{% else %}
|
||||
<li class="push"><a href="{{ root_url_prefix }}/login/">Login with SSH OTP</a></li>
|
||||
<li class="push"><a href="{{ root_url_prefix }}{{ login_path() }}">Login with SSH OTP</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
|
|
|
@ -24,13 +24,13 @@
|
|||
{% if in_reply_to %}
|
||||
<tr>
|
||||
<th scope="row">In-Reply-To:</th>
|
||||
<td class="faded message-id"><a href="{{ root_url_prefix|safe }}/lists/{{ list.pk }}/<{{ in_reply_to }}>/">{{ in_reply_to }}</a></td>
|
||||
<td class="faded message-id"><a href="{{ root_url_prefix|safe }}{{ list_post_path(list.id, in_reply_to) }}">{{ in_reply_to }}</a></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if references %}
|
||||
<tr>
|
||||
<th scope="row">References:</th>
|
||||
<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>
|
||||
<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>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{% include "header.html" %}
|
||||
<div class="body body-grid">
|
||||
<h3>Your subscription to <a href="{{ root_url_prefix|safe }}/lists/{{ list.pk }}/">{{ list.id }}</a>.</h3>
|
||||
<h3>Your subscription to <a href="{{ root_url_prefix|safe }}{{ list_path(list.pk) }}">{{ list.id }}</a>.</h3>
|
||||
<address>
|
||||
{{ list.name }} <a href="mailto:{{ list.address | safe }}"><code>{{ list.address }}</code></a>
|
||||
</address>
|
||||
|
|
|
@ -0,0 +1,559 @@
|
|||
/*
|
||||
* This file is part of mailpot
|
||||
*
|
||||
* Copyright 2020 - Manos Pitsidianakis
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
|
||||
|
||||
use super::*;
|
||||
|
||||
// from https://github.com/servo/rust-url/blob/master/url/src/parser.rs
|
||||
const FRAGMENT: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'<').add(b'>').add(b'`');
|
||||
const PATH: &AsciiSet = &FRAGMENT.add(b'#').add(b'?').add(b'{').add(b'}');
|
||||
pub const PATH_SEGMENT: &AsciiSet = &PATH.add(b'/').add(b'%');
|
||||
|
||||
pub trait IntoCrumb: TypedPath {
|
||||
fn to_crumb(&self) -> Cow<'static, str> {
|
||||
Cow::from(self.to_uri().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl<TP: TypedPath> IntoCrumb for TP {}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ListPathIdentifier {
|
||||
Pk(#[serde(deserialize_with = "parse_int")] i64),
|
||||
Id(String),
|
||||
}
|
||||
|
||||
fn parse_int<'de, T, D>(de: D) -> Result<T, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
T: std::str::FromStr,
|
||||
<T as std::str::FromStr>::Err: std::fmt::Display,
|
||||
{
|
||||
use serde::Deserialize;
|
||||
String::deserialize(de)?
|
||||
.parse()
|
||||
.map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
impl From<i64> for ListPathIdentifier {
|
||||
fn from(val: i64) -> Self {
|
||||
Self::Pk(val)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ListPathIdentifier {
|
||||
fn from(val: String) -> Self {
|
||||
Self::Id(val)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ListPathIdentifier {
|
||||
#[allow(clippy::unnecessary_to_owned)]
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let id: Cow<'_, str> = match self {
|
||||
Self::Pk(id) => id.to_string().into(),
|
||||
Self::Id(id) => id.into(),
|
||||
};
|
||||
write!(f, "{}", utf8_percent_encode(&id, PATH_SEGMENT,))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
|
||||
#[typed_path("/list/:id/")]
|
||||
pub struct ListPath(pub ListPathIdentifier);
|
||||
|
||||
impl From<&DbVal<mailpot::models::MailingList>> for ListPath {
|
||||
fn from(val: &DbVal<mailpot::models::MailingList>) -> Self {
|
||||
Self(ListPathIdentifier::Id(val.id.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
|
||||
#[typed_path("/list/:id/posts/:msgid/")]
|
||||
pub struct ListPostPath(pub ListPathIdentifier, pub String);
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
|
||||
#[typed_path("/list/:id/edit/")]
|
||||
pub struct ListEditPath(pub ListPathIdentifier);
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
|
||||
#[typed_path("/settings/list/:id/")]
|
||||
pub struct ListSettingsPath(pub ListPathIdentifier);
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
|
||||
#[typed_path("/login/")]
|
||||
pub struct LoginPath;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
|
||||
#[typed_path("/logout/")]
|
||||
pub struct LogoutPath;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
|
||||
#[typed_path("/settings/")]
|
||||
pub struct SettingsPath;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
|
||||
#[typed_path("/help/")]
|
||||
pub struct HelpPath;
|
||||
|
||||
macro_rules! unit_impl {
|
||||
($ident:ident, $ty:expr) => {
|
||||
pub fn $ident() -> Value {
|
||||
$ty.to_crumb().into()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
unit_impl!(login_path, LoginPath);
|
||||
unit_impl!(logout_path, LogoutPath);
|
||||
unit_impl!(settings_path, SettingsPath);
|
||||
unit_impl!(help_path, HelpPath);
|
||||
|
||||
macro_rules! list_id_impl {
|
||||
($ident:ident, $ty:tt) => {
|
||||
pub fn $ident(id: Value) -> std::result::Result<Value, Error> {
|
||||
if let Some(id) = id.as_str() {
|
||||
return Ok($ty(ListPathIdentifier::Id(id.to_string()))
|
||||
.to_crumb()
|
||||
.into());
|
||||
}
|
||||
let pk = id.try_into()?;
|
||||
Ok($ty(ListPathIdentifier::Pk(pk)).to_crumb().into())
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
list_id_impl!(list_path, ListPath);
|
||||
list_id_impl!(list_settings_path, ListSettingsPath);
|
||||
list_id_impl!(list_edit_path, ListEditPath);
|
||||
|
||||
pub fn list_post_path(id: Value, msg_id: Value) -> std::result::Result<Value, Error> {
|
||||
let Some(msg_id) = msg_id.as_str().map(|s| if s.starts_with('<') && s.ends_with('>') { s.to_string() } else {
|
||||
format!("<{s}>")
|
||||
}) else {
|
||||
return Err(Error::new(
|
||||
minijinja::ErrorKind::UnknownMethod,
|
||||
"Second argument of list_post_path must be a string."
|
||||
));
|
||||
};
|
||||
|
||||
if let Some(id) = id.as_str() {
|
||||
return Ok(ListPostPath(ListPathIdentifier::Id(id.to_string()), msg_id)
|
||||
.to_crumb()
|
||||
.into());
|
||||
}
|
||||
let pk = id.try_into()?;
|
||||
Ok(ListPostPath(ListPathIdentifier::Pk(pk), msg_id)
|
||||
.to_crumb()
|
||||
.into())
|
||||
}
|
||||
|
||||
pub mod tsr {
|
||||
use std::{borrow::Cow, convert::Infallible};
|
||||
|
||||
use axum::{
|
||||
http::Request,
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
routing::{any, MethodRouter},
|
||||
Router,
|
||||
};
|
||||
use axum_extra::routing::{RouterExt as ExtraRouterExt, SecondElementIs, TypedPath};
|
||||
use http::{uri::PathAndQuery, StatusCode, Uri};
|
||||
use tower_service::Service;
|
||||
|
||||
/// Extension trait that adds additional methods to [`Router`].
|
||||
pub trait RouterExt<S, B>: ExtraRouterExt<S, B> {
|
||||
/// Add a typed `GET` route to the router.
|
||||
///
|
||||
/// The path will be inferred from the first argument to the handler
|
||||
/// function which must implement [`TypedPath`].
|
||||
///
|
||||
/// See [`TypedPath`] for more details and examples.
|
||||
fn typed_get<H, T, P>(self, handler: H) -> Self
|
||||
where
|
||||
H: axum::handler::Handler<T, S, B>,
|
||||
T: SecondElementIs<P> + 'static,
|
||||
P: TypedPath;
|
||||
|
||||
/// Add a typed `DELETE` route to the router.
|
||||
///
|
||||
/// The path will be inferred from the first argument to the handler
|
||||
/// function which must implement [`TypedPath`].
|
||||
///
|
||||
/// See [`TypedPath`] for more details and examples.
|
||||
fn typed_delete<H, T, P>(self, handler: H) -> Self
|
||||
where
|
||||
H: axum::handler::Handler<T, S, B>,
|
||||
T: SecondElementIs<P> + 'static,
|
||||
P: TypedPath;
|
||||
|
||||
/// Add a typed `HEAD` route to the router.
|
||||
///
|
||||
/// The path will be inferred from the first argument to the handler
|
||||
/// function which must implement [`TypedPath`].
|
||||
///
|
||||
/// See [`TypedPath`] for more details and examples.
|
||||
fn typed_head<H, T, P>(self, handler: H) -> Self
|
||||
where
|
||||
H: axum::handler::Handler<T, S, B>,
|
||||
T: SecondElementIs<P> + 'static,
|
||||
P: TypedPath;
|
||||
|
||||
/// Add a typed `OPTIONS` route to the router.
|
||||
///
|
||||
/// The path will be inferred from the first argument to the handler
|
||||
/// function which must implement [`TypedPath`].
|
||||
///
|
||||
/// See [`TypedPath`] for more details and examples.
|
||||
fn typed_options<H, T, P>(self, handler: H) -> Self
|
||||
where
|
||||
H: axum::handler::Handler<T, S, B>,
|
||||
T: SecondElementIs<P> + 'static,
|
||||
P: TypedPath;
|
||||
|
||||
/// Add a typed `PATCH` route to the router.
|
||||
///
|
||||
/// The path will be inferred from the first argument to the handler
|
||||
/// function which must implement [`TypedPath`].
|
||||
///
|
||||
/// See [`TypedPath`] for more details and examples.
|
||||
fn typed_patch<H, T, P>(self, handler: H) -> Self
|
||||
where
|
||||
H: axum::handler::Handler<T, S, B>,
|
||||
T: SecondElementIs<P> + 'static,
|
||||
P: TypedPath;
|
||||
|
||||
/// Add a typed `POST` route to the router.
|
||||
///
|
||||
/// The path will be inferred from the first argument to the handler
|
||||
/// function which must implement [`TypedPath`].
|
||||
///
|
||||
/// See [`TypedPath`] for more details and examples.
|
||||
fn typed_post<H, T, P>(self, handler: H) -> Self
|
||||
where
|
||||
H: axum::handler::Handler<T, S, B>,
|
||||
T: SecondElementIs<P> + 'static,
|
||||
P: TypedPath;
|
||||
|
||||
/// Add a typed `PUT` route to the router.
|
||||
///
|
||||
/// The path will be inferred from the first argument to the handler
|
||||
/// function which must implement [`TypedPath`].
|
||||
///
|
||||
/// See [`TypedPath`] for more details and examples.
|
||||
fn typed_put<H, T, P>(self, handler: H) -> Self
|
||||
where
|
||||
H: axum::handler::Handler<T, S, B>,
|
||||
T: SecondElementIs<P> + 'static,
|
||||
P: TypedPath;
|
||||
|
||||
/// Add a typed `TRACE` route to the router.
|
||||
///
|
||||
/// The path will be inferred from the first argument to the handler
|
||||
/// function which must implement [`TypedPath`].
|
||||
///
|
||||
/// See [`TypedPath`] for more details and examples.
|
||||
fn typed_trace<H, T, P>(self, handler: H) -> Self
|
||||
where
|
||||
H: axum::handler::Handler<T, S, B>,
|
||||
T: SecondElementIs<P> + 'static,
|
||||
P: TypedPath;
|
||||
|
||||
/// Add another route to the router with an additional "trailing slash
|
||||
/// redirect" route.
|
||||
///
|
||||
/// If you add a route _without_ a trailing slash, such as `/foo`, this
|
||||
/// method will also add a route for `/foo/` that redirects to
|
||||
/// `/foo`.
|
||||
///
|
||||
/// If you add a route _with_ a trailing slash, such as `/bar/`, this
|
||||
/// method will also add a route for `/bar` that redirects to
|
||||
/// `/bar/`.
|
||||
///
|
||||
/// This is similar to what axum 0.5.x did by default, except this
|
||||
/// explicitly adds another route, so trying to add a `/foo/`
|
||||
/// route after calling `.route_with_tsr("/foo", /* ... */)`
|
||||
/// will result in a panic due to route overlap.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use axum::{routing::get, Router};
|
||||
/// use axum_extra::routing::RouterExt;
|
||||
///
|
||||
/// let app = Router::new()
|
||||
/// // `/foo/` will redirect to `/foo`
|
||||
/// .route_with_tsr("/foo", get(|| async {}))
|
||||
/// // `/bar` will redirect to `/bar/`
|
||||
/// .route_with_tsr("/bar/", get(|| async {}));
|
||||
/// # let _: Router = app;
|
||||
/// ```
|
||||
fn route_with_tsr(self, path: &str, method_router: MethodRouter<S, B>) -> Self
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Add another route to the router with an additional "trailing slash
|
||||
/// redirect" route.
|
||||
///
|
||||
/// This works like [`RouterExt::route_with_tsr`] but accepts any
|
||||
/// [`Service`].
|
||||
fn route_service_with_tsr<T>(self, path: &str, service: T) -> Self
|
||||
where
|
||||
T: Service<Request<B>, Error = Infallible> + Clone + Send + 'static,
|
||||
T::Response: IntoResponse,
|
||||
T::Future: Send + 'static,
|
||||
Self: Sized;
|
||||
}
|
||||
|
||||
impl<S, B> RouterExt<S, B> for Router<S, B>
|
||||
where
|
||||
B: axum::body::HttpBody + Send + 'static,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
fn typed_get<H, T, P>(mut self, handler: H) -> Self
|
||||
where
|
||||
H: axum::handler::Handler<T, S, B>,
|
||||
T: SecondElementIs<P> + 'static,
|
||||
P: TypedPath,
|
||||
{
|
||||
let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
|
||||
self = self.route(
|
||||
tsr_path.as_ref(),
|
||||
axum::routing::get(move |url| tsr_handler_into_async(url, tsr_handler)),
|
||||
);
|
||||
self = self.route(P::PATH, axum::routing::get(handler));
|
||||
self
|
||||
}
|
||||
|
||||
fn typed_delete<H, T, P>(mut self, handler: H) -> Self
|
||||
where
|
||||
H: axum::handler::Handler<T, S, B>,
|
||||
T: SecondElementIs<P> + 'static,
|
||||
P: TypedPath,
|
||||
{
|
||||
let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
|
||||
self = self.route(
|
||||
tsr_path.as_ref(),
|
||||
axum::routing::delete(move |url| tsr_handler_into_async(url, tsr_handler)),
|
||||
);
|
||||
self = self.route(P::PATH, axum::routing::delete(handler));
|
||||
self
|
||||
}
|
||||
|
||||
fn typed_head<H, T, P>(mut self, handler: H) -> Self
|
||||
where
|
||||
H: axum::handler::Handler<T, S, B>,
|
||||
T: SecondElementIs<P> + 'static,
|
||||
P: TypedPath,
|
||||
{
|
||||
let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
|
||||
self = self.route(
|
||||
tsr_path.as_ref(),
|
||||
axum::routing::head(move |url| tsr_handler_into_async(url, tsr_handler)),
|
||||
);
|
||||
self = self.route(P::PATH, axum::routing::head(handler));
|
||||
self
|
||||
}
|
||||
|
||||
fn typed_options<H, T, P>(mut self, handler: H) -> Self
|
||||
where
|
||||
H: axum::handler::Handler<T, S, B>,
|
||||
T: SecondElementIs<P> + 'static,
|
||||
P: TypedPath,
|
||||
{
|
||||
let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
|
||||
self = self.route(
|
||||
tsr_path.as_ref(),
|
||||
axum::routing::options(move |url| tsr_handler_into_async(url, tsr_handler)),
|
||||
);
|
||||
self = self.route(P::PATH, axum::routing::options(handler));
|
||||
self
|
||||
}
|
||||
|
||||
fn typed_patch<H, T, P>(mut self, handler: H) -> Self
|
||||
where
|
||||
H: axum::handler::Handler<T, S, B>,
|
||||
T: SecondElementIs<P> + 'static,
|
||||
P: TypedPath,
|
||||
{
|
||||
let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
|
||||
self = self.route(
|
||||
tsr_path.as_ref(),
|
||||
axum::routing::patch(move |url| tsr_handler_into_async(url, tsr_handler)),
|
||||
);
|
||||
self = self.route(P::PATH, axum::routing::patch(handler));
|
||||
self
|
||||
}
|
||||
|
||||
fn typed_post<H, T, P>(mut self, handler: H) -> Self
|
||||
where
|
||||
H: axum::handler::Handler<T, S, B>,
|
||||
T: SecondElementIs<P> + 'static,
|
||||
P: TypedPath,
|
||||
{
|
||||
let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
|
||||
self = self.route(
|
||||
tsr_path.as_ref(),
|
||||
axum::routing::post(move |url| tsr_handler_into_async(url, tsr_handler)),
|
||||
);
|
||||
self = self.route(P::PATH, axum::routing::post(handler));
|
||||
self
|
||||
}
|
||||
|
||||
fn typed_put<H, T, P>(mut self, handler: H) -> Self
|
||||
where
|
||||
H: axum::handler::Handler<T, S, B>,
|
||||
T: SecondElementIs<P> + 'static,
|
||||
P: TypedPath,
|
||||
{
|
||||
let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
|
||||
self = self.route(
|
||||
tsr_path.as_ref(),
|
||||
axum::routing::put(move |url| tsr_handler_into_async(url, tsr_handler)),
|
||||
);
|
||||
self = self.route(P::PATH, axum::routing::put(handler));
|
||||
self
|
||||
}
|
||||
|
||||
fn typed_trace<H, T, P>(mut self, handler: H) -> Self
|
||||
where
|
||||
H: axum::handler::Handler<T, S, B>,
|
||||
T: SecondElementIs<P> + 'static,
|
||||
P: TypedPath,
|
||||
{
|
||||
let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
|
||||
self = self.route(
|
||||
tsr_path.as_ref(),
|
||||
axum::routing::trace(move |url| tsr_handler_into_async(url, tsr_handler)),
|
||||
);
|
||||
self = self.route(P::PATH, axum::routing::trace(handler));
|
||||
self
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn route_with_tsr(mut self, path: &str, method_router: MethodRouter<S, B>) -> Self
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
validate_tsr_path(path);
|
||||
self = self.route(path, method_router);
|
||||
add_tsr_redirect_route(self, path)
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn route_service_with_tsr<T>(mut self, path: &str, service: T) -> Self
|
||||
where
|
||||
T: Service<Request<B>, Error = Infallible> + Clone + Send + 'static,
|
||||
T::Response: IntoResponse,
|
||||
T::Future: Send + 'static,
|
||||
Self: Sized,
|
||||
{
|
||||
validate_tsr_path(path);
|
||||
self = self.route_service(path, service);
|
||||
add_tsr_redirect_route(self, path)
|
||||
}
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn validate_tsr_path(path: &str) {
|
||||
if path == "/" {
|
||||
panic!("Cannot add a trailing slash redirect route for `/`")
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn add_tsr_redirect_route<S, B>(router: Router<S, B>, path: &str) -> Router<S, B>
|
||||
where
|
||||
B: axum::body::HttpBody + Send + 'static,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
async fn redirect_handler(uri: Uri) -> Response {
|
||||
let new_uri = map_path(uri, |path| {
|
||||
path.strip_suffix('/')
|
||||
.map(Cow::Borrowed)
|
||||
.unwrap_or_else(|| Cow::Owned(format!("{path}/")))
|
||||
});
|
||||
|
||||
if let Some(new_uri) = new_uri {
|
||||
Redirect::permanent(&new_uri.to_string()).into_response()
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(path_without_trailing_slash) = path.strip_suffix('/') {
|
||||
router.route(path_without_trailing_slash, any(redirect_handler))
|
||||
} else {
|
||||
router.route(&format!("{path}/"), any(redirect_handler))
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn tsr_redirect_route(path: &'_ str) -> (Cow<'_, str>, fn(Uri) -> Response) {
|
||||
fn redirect_handler(uri: Uri) -> Response {
|
||||
let new_uri = map_path(uri, |path| {
|
||||
path.strip_suffix('/')
|
||||
.map(Cow::Borrowed)
|
||||
.unwrap_or_else(|| Cow::Owned(format!("{path}/")))
|
||||
});
|
||||
|
||||
if let Some(new_uri) = new_uri {
|
||||
Redirect::permanent(&new_uri.to_string()).into_response()
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(path_without_trailing_slash) = path.strip_suffix('/') {
|
||||
(Cow::Borrowed(path_without_trailing_slash), redirect_handler)
|
||||
} else {
|
||||
(Cow::Owned(format!("{path}/")), redirect_handler)
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
async fn tsr_handler_into_async(u: Uri, h: fn(Uri) -> Response) -> Response {
|
||||
h(u)
|
||||
}
|
||||
|
||||
/// Map the path of a `Uri`.
|
||||
///
|
||||
/// Returns `None` if the `Uri` cannot be put back together with the new
|
||||
/// path.
|
||||
fn map_path<F>(original_uri: Uri, f: F) -> Option<Uri>
|
||||
where
|
||||
F: FnOnce(&str) -> Cow<'_, str>,
|
||||
{
|
||||
let mut parts = original_uri.into_parts();
|
||||
let path_and_query = parts.path_and_query.as_ref()?;
|
||||
|
||||
let new_path = f(path_and_query.path());
|
||||
|
||||
let new_path_and_query = if let Some(query) = &path_and_query.query() {
|
||||
format!("{new_path}?{query}").parse::<PathAndQuery>().ok()?
|
||||
} else {
|
||||
new_path.parse::<PathAndQuery>().ok()?
|
||||
};
|
||||
parts.path_and_query = Some(new_path_and_query);
|
||||
|
||||
Uri::from_parts(parts).ok()
|
||||
}
|
||||
}
|
|
@ -22,7 +22,22 @@ use super::*;
|
|||
lazy_static::lazy_static! {
|
||||
pub static ref TEMPLATES: Environment<'static> = {
|
||||
let mut env = Environment::new();
|
||||
env.add_function("calendarize", calendarize);
|
||||
macro_rules! add_function {
|
||||
($($id:ident),*$(,)?) => {
|
||||
$(env.add_function(stringify!($id), $id);)*
|
||||
}
|
||||
}
|
||||
add_function!(
|
||||
calendarize,
|
||||
login_path,
|
||||
logout_path,
|
||||
settings_path,
|
||||
help_path,
|
||||
list_path,
|
||||
list_settings_path,
|
||||
list_edit_path,
|
||||
list_post_path
|
||||
);
|
||||
env.set_source(Source::from_path("web/src/templates/"));
|
||||
|
||||
env
|
||||
|
@ -106,7 +121,7 @@ impl Object for MailingList {
|
|||
)),
|
||||
_ => Err(Error::new(
|
||||
minijinja::ErrorKind::UnknownMethod,
|
||||
format!("aaaobject has no method named {name}"),
|
||||
format!("object has no method named {name}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue