mailpot/web/src/main.rs

549 lines
19 KiB
Rust

/*
* 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 std::{collections::HashMap, sync::Arc};
use chrono::TimeZone;
use mailpot::{log, Configuration, Connection};
use mailpot_web::*;
use minijinja::value::Value;
use rand::Rng;
use tokio::sync::RwLock;
fn new_state(conf: Configuration) -> Arc<AppState> {
Arc::new(AppState {
conf,
root_url_prefix: Value::from_safe_string(
std::env::var("ROOT_URL_PREFIX").unwrap_or_default(),
),
public_url: std::env::var("PUBLIC_URL").unwrap_or_else(|_| "lists.mailpot.rs".to_string()),
site_title: std::env::var("SITE_TITLE")
.unwrap_or_else(|_| "mailing list archive".to_string())
.into(),
site_subtitle: std::env::var("SITE_SUBTITLE").ok().map(Into::into),
user_store: Arc::new(RwLock::new(HashMap::default())),
})
}
fn create_app(shared_state: Arc<AppState>) -> Router {
let store = MemoryStore::new();
let secret = rand::thread_rng().gen::<[u8; 128]>();
let session_layer = SessionLayer::new(store, &secret).with_secure(false);
let auth_layer = AuthLayer::new(shared_state.clone(), &secret);
let login_url =
Arc::new(format!("{}{}", shared_state.root_url_prefix, LoginPath.to_crumb()).into());
Router::new()
.route("/", get(root))
.typed_get(list)
.typed_get(list_post)
.typed_get(list_post_raw)
.typed_get(list_post_eml)
.typed_get(list_edit.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, user, payload| {
list_edit_POST(path, session, user, payload, State(shared_state))
}
}
.layer(RequireAuth::login_with_role_or_redirect(
Role::User..,
Arc::clone(&login_url),
Some(Arc::new("next".into())),
)),
)
.typed_get(
list_subscribers.layer(RequireAuth::login_with_role_or_redirect(
Role::User..,
Arc::clone(&login_url),
Some(Arc::new("next".into())),
)),
)
.typed_get(
list_candidates.layer(RequireAuth::login_with_role_or_redirect(
Role::User..,
Arc::clone(&login_url),
Some(Arc::new("next".into())),
)),
)
.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 |path, session, user| settings(path, session, user, shared_state)
}
.layer(RequireAuth::login_or_redirect(
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)
.with_state(shared_state)
}
#[tokio::main]
async fn main() {
let config_path = std::env::args()
.nth(1)
.expect("Expected configuration file path as first argument.");
if ["-v", "--version", "info"].contains(&config_path.as_str()) {
println!("{}", crate::get_git_sha());
println!("{CLI_INFO}");
return;
}
#[cfg(test)]
let verbosity = log::LevelFilter::Trace;
#[cfg(not(test))]
let verbosity = log::LevelFilter::Info;
stderrlog::new()
.quiet(false)
.verbosity(verbosity)
.show_module_names(true)
.timestamp(stderrlog::Timestamp::Millisecond)
.init()
.unwrap();
let conf = Configuration::from_file(config_path).unwrap();
let app = create_app(new_state(conf));
let hostname = std::env::var("HOSTNAME").unwrap_or_else(|_| "0.0.0.0".to_string());
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string());
let listen_to = format!("{hostname}:{port}");
println!("Listening to {listen_to}...");
axum::Server::bind(&listen_to.parse().unwrap())
.serve(app.into_make_service())
.await
.unwrap();
}
async fn root(
mut session: WritableSession,
auth: AuthContext,
State(state): State<Arc<AppState>>,
) -> Result<Html<String>, ResponseError> {
let db = Connection::open_db(state.conf.clone())?;
let lists_values = db.lists()?;
let lists = lists_values
.iter()
.map(|list| {
let months = db.months(list.pk)?;
let posts = db.list_posts(list.pk, None)?;
let newest = posts.last().and_then(|p| {
chrono::Utc
.timestamp_opt(p.timestamp as i64, 0)
.earliest()
.map(|d| d.to_string())
});
Ok(minijinja::context! {
name => &list.name,
newest,
posts => &posts,
months => &months,
description => &list.description.as_deref().unwrap_or_default(),
list => Value::from_object(MailingList::from(list.clone())),
})
})
.collect::<Result<Vec<_>, mailpot::Error>>()?;
let crumbs = vec![Crumb {
label: "Home".into(),
url: "/".into(),
}];
let context = minijinja::context! {
page_title => Option::<&'static str>::None,
lists => &lists,
current_user => auth.current_user,
messages => session.drain_messages(),
crumbs => crumbs,
};
Ok(Html(TEMPLATES.get_template("lists.html")?.render(context)?))
}
#[cfg(test)]
mod tests {
use axum::{
body::Body,
http::{
header::{COOKIE, SET_COOKIE},
method::Method,
Request, StatusCode,
},
};
use mailpot::{Configuration, Connection, SendMail};
use mailpot_tests::init_stderr_logging;
use percent_encoding::utf8_percent_encode;
use tempfile::TempDir;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn test_routes() {
init_stderr_logging();
macro_rules! req {
(get $url:expr) => {{
Request::builder()
.uri($url)
.method(Method::GET)
.body(Body::empty())
.unwrap()
}};
(post $url:expr, $body:expr) => {{
Request::builder()
.uri($url)
.method(Method::POST)
.header("Content-Type", "application/x-www-form-urlencoded")
.body(Body::from(
serde_urlencoded::to_string($body).unwrap().into_bytes(),
))
.unwrap()
}};
}
let tmp_dir = TempDir::new().unwrap();
let db_path = tmp_dir.path().join("mpot.db");
std::fs::copy("../mailpot-tests/for_testing.db", &db_path).unwrap();
let mut perms = std::fs::metadata(&db_path).unwrap().permissions();
#[allow(clippy::permissions_set_readonly_false)]
perms.set_readonly(false);
std::fs::set_permissions(&db_path, perms).unwrap();
let config = Configuration {
send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
db_path,
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let db = Connection::open_db(config.clone()).unwrap();
let list = db.lists().unwrap().remove(0);
let state = new_state(config.clone());
// ------------------------------------------------------------
// list()
let cl = |url, state| async move {
let response = create_app(state).oneshot(req!(get & url)).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
hyper::body::to_bytes(response.into_body()).await.unwrap()
};
assert_eq!(
cl(format!("/list/{}/", list.id), state.clone()).await,
cl(format!("/list/{}/", list.pk), state.clone()).await
);
// ------------------------------------------------------------
// list_post(), list_post_eml(), list_post_raw()
{
let msg_id = "<abcdefgh@sator.example.com>";
let res = create_app(state.clone())
.oneshot(req!(
get & format!(
"/list/{id}/posts/{msgid}/",
id = list.id,
msgid = utf8_percent_encode(msg_id, PATH_SEGMENT)
)
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers().get(http::header::CONTENT_TYPE),
Some(&http::HeaderValue::from_static("text/html; charset=utf-8"))
);
let res = create_app(state.clone())
.oneshot(req!(
get & format!(
"/list/{id}/posts/{msgid}/raw/",
id = list.id,
msgid = utf8_percent_encode(msg_id, PATH_SEGMENT)
)
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers().get(http::header::CONTENT_TYPE),
Some(&http::HeaderValue::from_static("text/plain; charset=utf-8"))
);
let res = create_app(state.clone())
.oneshot(req!(
get & format!(
"/list/{id}/posts/{msgid}/eml/",
id = list.id,
msgid = utf8_percent_encode(msg_id, PATH_SEGMENT)
)
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers().get(http::header::CONTENT_TYPE),
Some(&http::HeaderValue::from_static("application/octet-stream"))
);
assert_eq!(
res.headers().get(http::header::CONTENT_DISPOSITION),
Some(&http::HeaderValue::from_static(
"attachment; filename=\"<abcdefgh@sator.example.com>.eml\""
)),
);
}
// ------------------------------------------------------------
// help(), ssh_signin(), root()
for path in ["/help/", "/"] {
let response = create_app(state.clone())
.oneshot(req!(get path))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
// ------------------------------------------------------------
// auth.rs...
let login_app = create_app(state.clone());
let session_cookie = {
let response = login_app
.clone()
.oneshot(req!(get "/login/"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
response.headers().get(SET_COOKIE).unwrap().clone()
};
let user = User {
pk: 1,
ssh_signature: String::new(),
role: Role::User,
public_key: None,
password: String::new(),
name: None,
address: String::new(),
enabled: true,
};
state.insert_user(1, user.clone()).await;
{
let mut request = req!(post "/login/",
AuthFormPayload {
address: "user@example.com".into(),
password: "hunter2".into()
}
);
request
.headers_mut()
.insert(COOKIE, session_cookie.to_owned());
let res = login_app.clone().oneshot(request).await.unwrap();
assert_eq!(
res.headers().get(http::header::LOCATION),
Some(
&SettingsPath
.to_uri()
.to_string()
.as_str()
.try_into()
.unwrap()
)
);
}
// ------------------------------------------------------------
// settings()
{
let mut request = req!(get "/settings/");
request
.headers_mut()
.insert(COOKIE, session_cookie.to_owned());
let response = login_app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
// ------------------------------------------------------------
// settings_post()
{
let mut request = req!(
post "/settings/",
crate::settings::ChangeSetting::Subscribe {
list_pk: IntPOST(1),
});
request
.headers_mut()
.insert(COOKIE, session_cookie.to_owned());
let res = login_app.clone().oneshot(request).await.unwrap();
assert_eq!(
res.headers().get(http::header::LOCATION),
Some(
&SettingsPath
.to_uri()
.to_string()
.as_str()
.try_into()
.unwrap()
)
);
}
// ------------------------------------------------------------
// user_list_subscription() TODO
// ------------------------------------------------------------
// user_list_subscription_post() TODO
// ------------------------------------------------------------
// list_edit()
{
let mut request = req!(get & format!("/list/{id}/edit/", id = list.id,));
request
.headers_mut()
.insert(COOKIE, session_cookie.to_owned());
let response = login_app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
// ------------------------------------------------------------
// list_edit_POST()
{
let mut request = req!(
post & format!("/list/{id}/edit/", id = list.id,),
crate::lists::ChangeSetting::Metadata {
name: "new name".to_string(),
id: "new-name".to_string(),
address: list.address.clone(),
description: list.description.clone(),
owner_local_part: None,
request_local_part: None,
archive_url: None,
}
);
request
.headers_mut()
.insert(COOKIE, session_cookie.to_owned());
let response = login_app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
let list_mod = db.lists().unwrap().remove(0);
assert_eq!(&list_mod.name, "new name");
assert_eq!(&list_mod.id, "new-name");
assert_eq!(&list_mod.address, &list.address);
assert_eq!(&list_mod.description, &list.description);
}
{
let mut request = req!(post "/list/new-name/edit/",
crate::lists::ChangeSetting::SubscriptionPolicy {
send_confirmation: BoolPOST(false),
subscription_policy: crate::lists::SubscriptionPolicySettings::Custom,
}
);
request
.headers_mut()
.insert(COOKIE, session_cookie.to_owned());
let response = login_app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
let policy = db.list_subscription_policy(list.pk()).unwrap().unwrap();
assert!(!policy.send_confirmation);
assert!(policy.custom);
}
{
let mut request = req!(post "/list/new-name/edit/",
crate::lists::ChangeSetting::PostPolicy {
delete_post_policy: None,
post_policy: crate::lists::PostPolicySettings::Custom,
}
);
request
.headers_mut()
.insert(COOKIE, session_cookie.to_owned());
let response = login_app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
let policy = db.list_post_policy(list.pk()).unwrap().unwrap();
assert!(policy.custom);
}
}
}