web: add typed paths

grcov
Manos Pitsidianakis 2023-04-15 17:25:37 +03:00
parent 8fa4c910c1
commit adb057583f
Signed by: Manos Pitsidianakis
GPG Key ID: 7729C7707F7E09D0
19 changed files with 832 additions and 183 deletions

30
Cargo.lock generated
View File

@ -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"

View File

@ -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)?;

View File

@ -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);
}
}

View File

@ -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.

5
rustfmt.toml 100644
View File

@ -0,0 +1,5 @@
format_code_in_doc_comments = true
format_strings = true
imports_granularity = "Crate"
group_imports = "StdExternalCrate"
wrap_comments = true

View File

@ -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" }

View File

@ -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>>,

View File

@ -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.

View File

@ -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)]

View File

@ -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! {

View File

@ -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()
)))
}

View File

@ -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>

View File

@ -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">👤&nbsp;<span class="from">{{ post.address }}</span> 📆&nbsp;<span class="date">{{ post.datetime }}</span></span>
<span class="metadata">🪪 &nbsp;<span class="message-id">{{ post.message_id }}</span></span>
</div>

View File

@ -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>

View File

@ -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&nbsp;&amp; Documentation</a></li>
<li><a href="{{ root_url_prefix }}{{ help_path() }}">Help&nbsp;&amp; 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>

View File

@ -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>

View File

@ -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>

View File

@ -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`].
///