parent
886178926a
commit
d6273b416e
File diff suppressed because it is too large
Load Diff
|
@ -4,4 +4,5 @@ members = [
|
|||
"cli",
|
||||
"core",
|
||||
"rest-http",
|
||||
"web",
|
||||
]
|
||||
|
|
|
@ -36,6 +36,14 @@ pub struct Connection {
|
|||
conf: Configuration,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Connection {
|
||||
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
fmt.debug_struct("Connection")
|
||||
.field("conf", &self.conf)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
mod error_queue;
|
||||
pub use error_queue::*;
|
||||
mod posts;
|
||||
|
@ -67,9 +75,19 @@ fn user_authorizer_callback(
|
|||
table_name: "post" | "queue" | "candidate_membership" | "membership",
|
||||
}
|
||||
| AuthAction::Update {
|
||||
table_name: "candidate_membership" | "membership" | "account" | "templates",
|
||||
table_name: "candidate_membership" | "account" | "templates",
|
||||
column_name: "accepted" | "last_modified" | "verified" | "address",
|
||||
}
|
||||
| AuthAction::Update {
|
||||
table_name: "membership",
|
||||
column_name:
|
||||
"last_modified"
|
||||
| "digest"
|
||||
| "hide_address"
|
||||
| "receive_duplicates"
|
||||
| "receive_own_posts"
|
||||
| "receive_confirmation",
|
||||
}
|
||||
| AuthAction::Select
|
||||
| AuthAction::Savepoint { .. }
|
||||
| AuthAction::Transaction { .. }
|
||||
|
@ -127,6 +145,7 @@ impl Connection {
|
|||
/// Sets operational limits for this connection.
|
||||
///
|
||||
/// - Allow `INSERT`, `DELETE` only for "queue", "candidate_membership", "membership".
|
||||
/// - Allow `UPDATE` only for "membership" user facing settings.
|
||||
/// - Allow `INSERT` only for "post".
|
||||
/// - Allow read access to all tables.
|
||||
/// - Allow `SELECT`, `TRANSACTION`, `SAVEPOINT`, and the `strftime` function.
|
||||
|
|
|
@ -306,4 +306,86 @@ impl Connection {
|
|||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch account by pk.
|
||||
pub fn account(&self, pk: i64) -> Result<Option<DbVal<Account>>> {
|
||||
let mut stmt = self
|
||||
.connection
|
||||
.prepare("SELECT * FROM account WHERE pk = ?;")?;
|
||||
|
||||
let ret = stmt
|
||||
.query_row(rusqlite::params![&pk], |row| {
|
||||
let _pk: i64 = row.get("pk")?;
|
||||
debug_assert_eq!(pk, _pk);
|
||||
Ok(DbVal(
|
||||
Account {
|
||||
pk,
|
||||
name: row.get("name")?,
|
||||
address: row.get("address")?,
|
||||
enabled: row.get("enabled")?,
|
||||
password: row.get("password")?,
|
||||
},
|
||||
pk,
|
||||
))
|
||||
})
|
||||
.optional()?;
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
/// Fetch account by address.
|
||||
pub fn account_by_address(&self, address: &str) -> Result<Option<DbVal<Account>>> {
|
||||
let mut stmt = self
|
||||
.connection
|
||||
.prepare("SELECT * FROM account WHERE address = ?;")?;
|
||||
|
||||
let ret = stmt
|
||||
.query_row(rusqlite::params![&address], |row| {
|
||||
let pk = row.get("pk")?;
|
||||
Ok(DbVal(
|
||||
Account {
|
||||
pk,
|
||||
name: row.get("name")?,
|
||||
address: row.get("address")?,
|
||||
enabled: row.get("enabled")?,
|
||||
password: row.get("password")?,
|
||||
},
|
||||
pk,
|
||||
))
|
||||
})
|
||||
.optional()?;
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
/// Fetch all subscriptions of an account by primary key.
|
||||
pub fn account_subscriptions(&self, pk: i64) -> Result<Vec<DbVal<ListMembership>>> {
|
||||
let mut stmt = self
|
||||
.connection
|
||||
.prepare("SELECT * FROM membership WHERE account = ?;")?;
|
||||
let list_iter = stmt.query_map([&pk], |row| {
|
||||
let pk = row.get("pk")?;
|
||||
Ok(DbVal(
|
||||
ListMembership {
|
||||
pk: row.get("pk")?,
|
||||
list: row.get("list")?,
|
||||
address: row.get("address")?,
|
||||
name: row.get("name")?,
|
||||
digest: row.get("digest")?,
|
||||
enabled: row.get("enabled")?,
|
||||
verified: row.get("verified")?,
|
||||
hide_address: row.get("hide_address")?,
|
||||
receive_duplicates: row.get("receive_duplicates")?,
|
||||
receive_own_posts: row.get("receive_own_posts")?,
|
||||
receive_confirmation: row.get("receive_confirmation")?,
|
||||
},
|
||||
pk,
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut ret = vec![];
|
||||
for list in list_iter {
|
||||
let list = list?;
|
||||
ret.push(list);
|
||||
}
|
||||
Ok(ret)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -45,6 +45,12 @@ impl<T> std::ops::Deref for DbVal<T> {
|
|||
}
|
||||
}
|
||||
|
||||
impl<T> std::ops::DerefMut for DbVal<T> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::fmt::Display for DbVal<T>
|
||||
where
|
||||
T: std::fmt::Display,
|
||||
|
@ -339,3 +345,24 @@ impl std::fmt::Display for SubscribePolicy {
|
|||
write!(fmt, "{:?}", self)
|
||||
}
|
||||
}
|
||||
|
||||
/// An account entry.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Account {
|
||||
/// Database primary key.
|
||||
pub pk: i64,
|
||||
/// Accounts's display name, optional.
|
||||
pub name: Option<String>,
|
||||
/// Account's e-mail address.
|
||||
pub address: String,
|
||||
/// Whether this account is enabled.
|
||||
pub enabled: bool,
|
||||
/// SSH public key.
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Account {
|
||||
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(fmt, "{:?}", self)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
[package]
|
||||
name = "mpot-web"
|
||||
version = "0.0.0+2023-04-07"
|
||||
authors = ["Manos Pitsidianakis <el13635@mail.ntua.gr>"]
|
||||
edition = "2021"
|
||||
license = "LICENSE"
|
||||
readme = "README.md"
|
||||
description = "mailing list manager"
|
||||
repository = "https://github.com/meli/mailpot"
|
||||
keywords = ["mail", "mailing-lists"]
|
||||
categories = ["email"]
|
||||
|
||||
[[bin]]
|
||||
name = "mpot-web"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "^0.6" }
|
||||
axum-login = { version = "^0.5" }
|
||||
axum-sessions = { version = "^0.5" }
|
||||
chrono = { version = "^0.4" }
|
||||
eyre = { version = "0.6" }
|
||||
http = "0.2"
|
||||
lazy_static = "^1.4"
|
||||
mailpot = { version = "^0.0", path = "../core" }
|
||||
minijinja = { version = "0.31.0", features = ["source", ] }
|
||||
percent-encoding = { version = "^2.1" }
|
||||
rand = { version = "^0.8", features = ["min_const_gen"] }
|
||||
serde = { version = "^1", features = ["derive", ] }
|
||||
serde_json = "^1"
|
||||
tempfile = { version = "^3.5" }
|
||||
tokio = { version = "1", features = ["full"] }
|
|
@ -0,0 +1,12 @@
|
|||
# mailpot REST http server
|
||||
|
||||
```shell
|
||||
cargo run --bin mpot-archives
|
||||
```
|
||||
|
||||
## generate static files
|
||||
|
||||
```shell
|
||||
# mpot-gen CONF_FILE OUTPUT_DIR OPTIONAL_ROOT_URL_PREFIX
|
||||
cargo run --bin mpot-gen -- ../conf.toml ./out/ "/mailpot"
|
||||
```
|
|
@ -0,0 +1,391 @@
|
|||
/*
|
||||
* 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 super::*;
|
||||
use std::borrow::Cow;
|
||||
use tempfile::NamedTempFile;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
|
||||
use std::process::Stdio;
|
||||
|
||||
const TOKEN_KEY: &str = "ssh_challenge";
|
||||
const EXPIRY_IN_SECS: i64 = 6 * 60;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, PartialOrd)]
|
||||
pub enum Role {
|
||||
User,
|
||||
Admin,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
||||
pub struct User {
|
||||
pub id: i64,
|
||||
pub password_hash: String,
|
||||
pub role: Role,
|
||||
pub address: String,
|
||||
}
|
||||
|
||||
impl AuthUser<i64, Role> for User {
|
||||
fn get_id(&self) -> i64 {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn get_password_hash(&self) -> SecretVec<u8> {
|
||||
SecretVec::new(self.password_hash.clone().into())
|
||||
}
|
||||
|
||||
fn get_role(&self) -> Option<Role> {
|
||||
Some(self.role)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Default)]
|
||||
pub struct AuthFormPayload {
|
||||
pub address: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub async fn ssh_signin(
|
||||
mut session: WritableSession,
|
||||
auth: AuthContext,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> impl IntoResponse {
|
||||
if auth.current_user.is_some() {
|
||||
return Redirect::to("/settings/").into_response();
|
||||
}
|
||||
|
||||
let now: i64 = chrono::offset::Utc::now().timestamp();
|
||||
|
||||
let prev_token = if let Some(tok) = session.get::<(String, i64)>(TOKEN_KEY) {
|
||||
let timestamp: i64 = tok.1;
|
||||
if !(timestamp < now && now - timestamp < EXPIRY_IN_SECS) {
|
||||
session.remove(TOKEN_KEY);
|
||||
None
|
||||
} else {
|
||||
Some(tok)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (token, timestamp): (String, i64) = if let Some(tok) = prev_token {
|
||||
tok
|
||||
} else {
|
||||
use rand::distributions::Alphanumeric;
|
||||
use rand::{thread_rng, Rng};
|
||||
|
||||
let mut rng = thread_rng();
|
||||
let chars: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
|
||||
println!("Random chars: {}", chars);
|
||||
session.insert(TOKEN_KEY, (&chars, now)).unwrap();
|
||||
(chars, now)
|
||||
};
|
||||
let timeout_left = ((timestamp + EXPIRY_IN_SECS) - now) as f64 / 60.0;
|
||||
|
||||
let root_url_prefix = &state.root_url_prefix;
|
||||
let crumbs = vec![
|
||||
Crumb {
|
||||
label: "Lists".into(),
|
||||
url: "/".into(),
|
||||
},
|
||||
Crumb {
|
||||
label: "Sign in".into(),
|
||||
url: "/login/".into(),
|
||||
},
|
||||
];
|
||||
|
||||
let context = minijinja::context! {
|
||||
namespace => &state.public_url,
|
||||
title => "mailing list archive",
|
||||
description => "",
|
||||
root_url_prefix => &root_url_prefix,
|
||||
ssh_challenge => token,
|
||||
timeout_left => timeout_left,
|
||||
current_user => auth.current_user,
|
||||
crumbs => crumbs,
|
||||
};
|
||||
Html(
|
||||
TEMPLATES
|
||||
.get_template("auth.html")
|
||||
.unwrap()
|
||||
.render(context)
|
||||
.unwrap_or_else(|err| err.to_string()),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn ssh_signin_post(
|
||||
session: ReadableSession,
|
||||
mut auth: AuthContext,
|
||||
Form(payload): Form<AuthFormPayload>,
|
||||
state: Arc<AppState>,
|
||||
) -> Result<Redirect, ResponseError> {
|
||||
if auth.current_user.as_ref().is_some() {
|
||||
return Ok(Redirect::to("/settings/"));
|
||||
}
|
||||
|
||||
let now: i64 = chrono::offset::Utc::now().timestamp();
|
||||
|
||||
let (prev_token, _) =
|
||||
if let Some(tok @ (_, timestamp)) = session.get::<(String, i64)>(TOKEN_KEY) {
|
||||
if !(timestamp < now && now - timestamp < EXPIRY_IN_SECS) {
|
||||
// expired
|
||||
return Ok(Redirect::to("/login/"));
|
||||
} else {
|
||||
tok
|
||||
}
|
||||
} else {
|
||||
// expired
|
||||
return Ok(Redirect::to("/login/"));
|
||||
};
|
||||
|
||||
drop(session);
|
||||
let db = Connection::open_db(state.conf.clone())?;
|
||||
let acc = match db
|
||||
.account_by_address(&payload.address)
|
||||
.with_status(StatusCode::BAD_REQUEST)?
|
||||
{
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return Err(ResponseError::new(
|
||||
format!("Account for {} not found", payload.address),
|
||||
StatusCode::NOT_FOUND,
|
||||
));
|
||||
}
|
||||
};
|
||||
let sig = SshSignature {
|
||||
email: payload.address.clone(),
|
||||
ssh_public_key: acc.password.clone(),
|
||||
ssh_signature: payload.password.clone(),
|
||||
namespace: "lists.mailpot.rs".into(),
|
||||
token: prev_token,
|
||||
};
|
||||
// FIXME: show failure to user
|
||||
ssh_keygen(sig).await?;
|
||||
|
||||
let user = User {
|
||||
id: acc.pk(),
|
||||
password_hash: payload.password,
|
||||
role: Role::User,
|
||||
address: payload.address,
|
||||
};
|
||||
state.insert_user(acc.pk(), user.clone()).await;
|
||||
auth.login(&user)
|
||||
.await
|
||||
.map_err(|err| ResponseError::new(err.to_string(), StatusCode::BAD_REQUEST))?;
|
||||
Ok(Redirect::to(&format!(
|
||||
"{}/settings/",
|
||||
state.root_url_prefix
|
||||
)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SshSignature {
|
||||
pub email: String,
|
||||
pub ssh_public_key: String,
|
||||
pub ssh_signature: String,
|
||||
pub namespace: Cow<'static, str>,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
/// Run ssh signature validation with `ssh-keygen` binary.
|
||||
///
|
||||
/// ```no_run
|
||||
/// use mpot_web::{ssh_keygen, SshSignature};
|
||||
///
|
||||
/// async fn key_gen(
|
||||
/// ssh_public_key: String,
|
||||
/// ssh_signature: String,
|
||||
/// ) -> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||
/// let mut sig = SshSignature {
|
||||
/// email: "user@example.com".to_string(),
|
||||
/// ssh_public_key,
|
||||
/// ssh_signature,
|
||||
/// namespace: "doc-test@example.com".into(),
|
||||
/// token: "d074a61990".to_string(),
|
||||
/// };
|
||||
///
|
||||
/// ssh_keygen(sig.clone()).await?;
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn ssh_keygen(sig: SshSignature) -> Result<(), ResponseError> {
|
||||
let SshSignature {
|
||||
email,
|
||||
ssh_public_key,
|
||||
ssh_signature,
|
||||
namespace,
|
||||
token,
|
||||
} = sig;
|
||||
let dir = tempfile::tempdir()?;
|
||||
|
||||
let mut allowed_signers_fp = NamedTempFile::new_in(dir.path())?;
|
||||
let mut signature_fp = NamedTempFile::new_in(dir.path())?;
|
||||
{
|
||||
let (tempfile, path) = allowed_signers_fp.into_parts();
|
||||
let mut file = File::from(tempfile);
|
||||
|
||||
file.write_all(format!("{email} {ssh_public_key}").as_bytes())
|
||||
.await?;
|
||||
file.flush().await?;
|
||||
allowed_signers_fp = NamedTempFile::from_parts(file.into_std().await, path);
|
||||
}
|
||||
{
|
||||
let (tempfile, path) = signature_fp.into_parts();
|
||||
let mut file = File::from(tempfile);
|
||||
|
||||
file.write_all(ssh_signature.trim().replace("\r\n", "\n").as_bytes())
|
||||
.await?;
|
||||
file.flush().await?;
|
||||
signature_fp = NamedTempFile::from_parts(file.into_std().await, path);
|
||||
}
|
||||
|
||||
let mut cmd = Command::new("ssh-keygen");
|
||||
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
cmd.stdin(Stdio::piped());
|
||||
|
||||
// Once you have your allowed signers file, verification works like this:
|
||||
// ssh-keygen -Y verify -f allowed_signers -I alice@example.com -n file -s file_to_verify.sig < file_to_verify
|
||||
// Here are the arguments you may need to change:
|
||||
// allowed_signers is the path to the allowed signers file.
|
||||
// alice@example.com is the email address of the person who allegedly signed the file. This email address is looked up in the allowed signers file to get possible public keys.
|
||||
// file is the "namespace", which must match the namespace used for signing as described above.
|
||||
// file_to_verify.sig is the path to the signature file.
|
||||
// file_to_verify is the path to the file to be verified. Note that this file is read from standard in. In the above command, the < shell operator is used to redirect standard in from this file.
|
||||
// If the signature is valid, the command exits with status 0 and prints a message like this:
|
||||
// Good "file" signature for alice@example.com with ED25519 key SHA256:ZGa8RztddW4kE2XKPPsP9ZYC7JnMObs6yZzyxg8xZSk
|
||||
// Otherwise, the command exits with a non-zero status and prints an error message.
|
||||
|
||||
let mut child = cmd
|
||||
.arg("-Y")
|
||||
.arg("verify")
|
||||
.arg("-f")
|
||||
.arg(allowed_signers_fp.path())
|
||||
.arg("-I")
|
||||
.arg(&email)
|
||||
.arg("-n")
|
||||
.arg(namespace.as_ref())
|
||||
.arg("-s")
|
||||
.arg(signature_fp.path())
|
||||
.spawn()
|
||||
.expect("failed to spawn command");
|
||||
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.expect("child did not have a handle to stdin");
|
||||
|
||||
stdin
|
||||
.write_all(token.as_bytes())
|
||||
.await
|
||||
.expect("could not write to stdin");
|
||||
|
||||
drop(stdin);
|
||||
|
||||
let op = child.wait_with_output().await?;
|
||||
|
||||
if !op.status.success() {
|
||||
return Err(ResponseError::new(
|
||||
format!(
|
||||
"ssh-keygen exited with {}:\nstdout: {}\n\nstderr: {}",
|
||||
op.status.code().unwrap_or(-1),
|
||||
String::from_utf8_lossy(&op.stdout),
|
||||
String::from_utf8_lossy(&op.stderr)
|
||||
),
|
||||
StatusCode::BAD_REQUEST,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn logout_handler(mut auth: AuthContext, State(state): State<Arc<AppState>>) -> Redirect {
|
||||
auth.logout().await;
|
||||
Redirect::to(&format!("{}/settings/", state.root_url_prefix))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ssh_keygen() {
|
||||
const PKEY: &str = concat!("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCzXp8nLJL8GPNw7S+Dqt0m3Dw/",
|
||||
"xFOAdwKXcekTFI9cLDEUII2rNPf0uUZTpv57OgU+",
|
||||
"QOEEIvWMjz+5KSWBX8qdP8OtV0QNvynlZkEKZN0cUqGKaNXo5a+PUDyiJ2rHroPe1aMo6mUBL9kLR6J2U1CYD/dLfL8ywXsAGmOL0bsK0GRPVBJAjpUNRjpGU/",
|
||||
"2FFIlU6s6GawdbDXEHDox/UoOVAKIlhKabaTrFBA0ACFLRX2/GCBmHqqt5d4ZZjefYzReLs/beOjafYImoyhHC428wZDcUjvLrpSJbIOE/",
|
||||
"gSPCWlRbcsxg4JGcKOtALUurE+ok+avy9M7eFjGhLGSlTKLdshIVQr/3W667M7bYfOT6xP/",
|
||||
"lyjxeWIUYyj7rjlqKJ9tzygek7QNxCtuqH5xsZAZqzQCN8wfrPAlwDykvWityKOw+Bt2DWjimITqyKgsBsOaA+",
|
||||
"eVCllFvooJxoYvAjODASjAUoOdgVzyBDpFnOhLFYiIIyL3F6NROS9i7z086paX7mrzcQzvLr4ckF9qT7DrI88ikISCR9bFR4vPq3aH",
|
||||
"zJdjDDpWxACa5b11NG8KdCJPe/L0kDw82Q00U13CpW9FI9sZjvk+",
|
||||
"lyw8bTFvVsIl6A0ueboFvrNvznAqHrtfWu75fXRh5sKj2TGk8rhm3vyNgrBSr5zAfFVM8LgqBxbAAYw==");
|
||||
|
||||
const SIG: &str = concat!(
|
||||
"-----BEGIN SSH SIGNATURE-----\n",
|
||||
"U1NIU0lHAAAAAQAAAhcAAAAHc3NoLXJzYQAAAAMBAAEAAAIBALNenycskvwY83DtL4Oq3S\n",
|
||||
"bcPD/EU4B3Apdx6RMUj1wsMRQgjas09/S5RlOm/ns6BT5A4QQi9YyPP7kpJYFfyp0/w61X\n",
|
||||
"RA2/KeVmQQpk3RxSoYpo1ejlr49QPKInaseug97VoyjqZQEv2QtHonZTUJgP90t8vzLBew\n",
|
||||
"AaY4vRuwrQZE9UEkCOlQ1GOkZT/YUUiVTqzoZrB1sNcQcOjH9Sg5UAoiWEpptpOsUEDQAI\n",
|
||||
"UtFfb8YIGYeqq3l3hlmN59jNF4uz9t46Np9giajKEcLjbzBkNxSO8uulIlsg4T+BI8JaVF\n",
|
||||
"tyzGDgkZwo60AtS6sT6iT5q/L0zt4WMaEsZKVMot2yEhVCv/dbrrsztth85PrE/+XKPF5Y\n",
|
||||
"hRjKPuuOWoon23PKB6TtA3EK26ofnGxkBmrNAI3zB+s8CXAPKS9aK3Io7D4G3YNaOKYhOr\n",
|
||||
"IqCwGw5oD55UKWUW+ignGhi8CM4MBKMBSg52BXPIEOkWc6EsViIgjIvcXo1E5L2LvPTzql\n",
|
||||
"pfuavNxDO8uvhyQX2pPsOsjzyKQhIJH1sVHi8+rdofMl2MMOlbEAJrlvXU0bwp0Ik978vS\n",
|
||||
"QPDzZDTRTXcKlb0Uj2xmO+T6XLDxtMW9WwiXoDS55ugW+s2/OcCoeu19a7vl9dGHmwqPZM\n",
|
||||
"aTyuGbe/I2CsFKvnMB8VUzwuCoHFsABjAAAAFGRvYy10ZXN0QGV4YW1wbGUuY29tAAAAAA\n",
|
||||
"AAAAZzaGE1MTIAAAIUAAAADHJzYS1zaGEyLTUxMgAAAgBxaMqIfeapKTrhQzggDssD+76s\n",
|
||||
"jZxv3XxzgsuAjlIdtw+/nyxU6skTnrGoam2shpmQvx0HuqSQ7HyS2USBK7T4LZNoE53zR/\n",
|
||||
"ZmHLGoyQAoexiHSEW9Lk53kyRNPhpXQedTvm8REHPGM3zw6WO6mAXVVxvebvawf81LTbBb\n",
|
||||
"p9ubNRcHgktVeywMO/sD6zWSyShq1gjVv1PdRBOjUgqkwjImL8dFKi1QUeoffCxyk3JhTO\n",
|
||||
"siTy79HZSz/kOvkvL1vQuqaP2R8lE9P1uaD19dGOMTPRod3u+QmpYX47ri5KM3Fmkfxdwq\n",
|
||||
"p8JVmfAA9nme7bmNS1hWgmF2Nbh9qjh1zOZvCimIpuNtz5eEl9K+1DxG6w5tX86wSGvBMO\n",
|
||||
"znx0k1gGfkiAULqgrkdul7mqMPRvPN9J6QlNJ7SLFChRhzlJIJc6tOvCs7qkVD43Zcb+I5\n",
|
||||
"Z+K4NiFf5jf8kVX/pjjeW/ucbrctJIkGsZ58OkHKi1EDRcq7NtCF6SKlcv8g3fMLd9wW6K\n",
|
||||
"aaed0TBDC+s+f6naNIGvWqfWCwDuK5xGyDTTmJGcrsMwWuT9K6uLk8cGdv7t5mOFuWi5jl\n",
|
||||
"E+IKZKVABMuWqSj96ErMIiBjtsAZfNSezpsK49wQztoSPhdwLhD6fHrSAyPCqN2xRkcsIb\n",
|
||||
"6PxWKC/OELf3gyEBRPouxsF7xSZQ==\n",
|
||||
"-----END SSH SIGNATURE-----\n"
|
||||
);
|
||||
const NAMESPACE: &str = "doc-test@example.com";
|
||||
|
||||
let mut sig = SshSignature {
|
||||
email: "user@example.com".to_string(),
|
||||
ssh_public_key: PKEY.to_string(),
|
||||
ssh_signature: SIG.to_string(),
|
||||
namespace: "doc-test@example.com".into(),
|
||||
token: "d074a61990".to_string(),
|
||||
};
|
||||
|
||||
ssh_keygen(sig.clone()).await.unwrap();
|
||||
|
||||
sig.ssh_signature = sig.ssh_signature.replace("J", "0");
|
||||
|
||||
let err = ssh_keygen(sig).await.unwrap_err();
|
||||
|
||||
assert!(
|
||||
err.to_string().starts_with("ssh-keygen exited with"),
|
||||
"{}",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,244 @@
|
|||
// MIT License
|
||||
//
|
||||
// Copyright (c) 2021 sadnessOjisan
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// 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 SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
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.
|
||||
/// A value of zero means a date that not exists in the current month.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use chrono::*;
|
||||
/// use mpot_web::calendarize;
|
||||
///
|
||||
/// let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
|
||||
/// // Week = [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
|
||||
/// println!("{:?}", calendarize(date));
|
||||
/// // [0, 0, 0, 0, 0, 1, 2],
|
||||
/// // [3, 4, 5, 6, 7, 8, 9],
|
||||
/// // [10, 11, 12, 13, 14, 15, 16],
|
||||
/// // [17, 18, 19, 20, 21, 22, 23],
|
||||
/// // [24, 25, 26, 27, 28, 29, 30],
|
||||
/// // [31, 0, 0, 0, 0, 0, 0]
|
||||
/// ```
|
||||
pub fn calendarize(date: NaiveDate) -> Vec<[u32; 7]> {
|
||||
calendarize_with_offset(date, 0)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// A value of zero means a date that not exists in the current month.
|
||||
///
|
||||
/// Offset means the number of days from sunday.
|
||||
/// For example, 1 means monday, 6 means saturday.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use chrono::*;
|
||||
/// use mpot_web::calendarize_with_offset;
|
||||
///
|
||||
/// let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
|
||||
/// // Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
|
||||
/// println!("{:?}", calendarize_with_offset(date, 1));
|
||||
/// // [0, 0, 0, 0, 1, 2, 3],
|
||||
/// // [4, 5, 6, 7, 8, 9, 10],
|
||||
/// // [11, 12, 13, 14, 15, 16, 17],
|
||||
/// // [18, 19, 20, 21, 22, 23, 24],
|
||||
/// // [25, 26, 27, 28, 29, 30, 0],
|
||||
/// ```
|
||||
pub fn calendarize_with_offset(date: NaiveDate, offset: u32) -> Vec<[u32; 7]> {
|
||||
let mut monthly_calendar: Vec<[u32; 7]> = Vec::with_capacity(6);
|
||||
let year = date.year();
|
||||
let month = date.month();
|
||||
let num_days_from_sunday = NaiveDate::from_ymd_opt(year, month, 1)
|
||||
.unwrap()
|
||||
.weekday()
|
||||
.num_days_from_sunday();
|
||||
let mut first_date_day;
|
||||
if num_days_from_sunday < offset {
|
||||
first_date_day = num_days_from_sunday + (7 - offset);
|
||||
} else {
|
||||
first_date_day = num_days_from_sunday - offset;
|
||||
}
|
||||
let end_date = NaiveDate::from_ymd_opt(year, month + 1, 1)
|
||||
.unwrap_or_else(|| NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap())
|
||||
.pred_opt()
|
||||
.unwrap()
|
||||
.day();
|
||||
|
||||
let mut date: u32 = 0;
|
||||
while date < end_date {
|
||||
let mut week: [u32; 7] = [0; 7];
|
||||
for day in first_date_day..7 {
|
||||
date += 1;
|
||||
week[day as usize] = date;
|
||||
if date >= end_date {
|
||||
break;
|
||||
}
|
||||
}
|
||||
first_date_day = 0;
|
||||
|
||||
monthly_calendar.push(week);
|
||||
}
|
||||
|
||||
monthly_calendar
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn january() {
|
||||
let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
|
||||
let actual = calendarize(date);
|
||||
assert_eq!(
|
||||
vec![
|
||||
[0, 0, 0, 0, 0, 1, 2],
|
||||
[3, 4, 5, 6, 7, 8, 9],
|
||||
[10, 11, 12, 13, 14, 15, 16],
|
||||
[17, 18, 19, 20, 21, 22, 23],
|
||||
[24, 25, 26, 27, 28, 29, 30],
|
||||
[31, 0, 0, 0, 0, 0, 0]
|
||||
],
|
||||
actual
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
// Week = [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
|
||||
fn with_offset_from_sunday() {
|
||||
let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
|
||||
let actual = calendarize_with_offset(date, 0);
|
||||
assert_eq!(
|
||||
vec![
|
||||
[0, 0, 0, 0, 0, 1, 2],
|
||||
[3, 4, 5, 6, 7, 8, 9],
|
||||
[10, 11, 12, 13, 14, 15, 16],
|
||||
[17, 18, 19, 20, 21, 22, 23],
|
||||
[24, 25, 26, 27, 28, 29, 30],
|
||||
],
|
||||
actual
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
// Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
|
||||
fn with_offset_from_monday() {
|
||||
let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
|
||||
let actual = calendarize_with_offset(date, 1);
|
||||
assert_eq!(
|
||||
vec![
|
||||
[0, 0, 0, 0, 1, 2, 3],
|
||||
[4, 5, 6, 7, 8, 9, 10],
|
||||
[11, 12, 13, 14, 15, 16, 17],
|
||||
[18, 19, 20, 21, 22, 23, 24],
|
||||
[25, 26, 27, 28, 29, 30, 0],
|
||||
],
|
||||
actual
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
// Week = [Sat, Sun, Mon, Tue, Wed, Thu, Fri]
|
||||
fn with_offset_from_saturday() {
|
||||
let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
|
||||
let actual = calendarize_with_offset(date, 6);
|
||||
assert_eq!(
|
||||
vec![
|
||||
[0, 0, 0, 0, 0, 0, 1],
|
||||
[2, 3, 4, 5, 6, 7, 8],
|
||||
[9, 10, 11, 12, 13, 14, 15],
|
||||
[16, 17, 18, 19, 20, 21, 22],
|
||||
[23, 24, 25, 26, 27, 28, 29],
|
||||
[30, 0, 0, 0, 0, 0, 0]
|
||||
],
|
||||
actual
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
// Week = [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
|
||||
fn with_offset_from_sunday_with7() {
|
||||
let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
|
||||
let actual = calendarize_with_offset(date, 7);
|
||||
assert_eq!(
|
||||
vec![
|
||||
[0, 0, 0, 0, 0, 1, 2],
|
||||
[3, 4, 5, 6, 7, 8, 9],
|
||||
[10, 11, 12, 13, 14, 15, 16],
|
||||
[17, 18, 19, 20, 21, 22, 23],
|
||||
[24, 25, 26, 27, 28, 29, 30],
|
||||
],
|
||||
actual
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn april() {
|
||||
let date = NaiveDate::parse_from_str("2021-04-02", "%Y-%m-%d").unwrap();
|
||||
let actual = calendarize(date);
|
||||
assert_eq!(
|
||||
vec![
|
||||
[0, 0, 0, 0, 1, 2, 3],
|
||||
[4, 5, 6, 7, 8, 9, 10],
|
||||
[11, 12, 13, 14, 15, 16, 17],
|
||||
[18, 19, 20, 21, 22, 23, 24],
|
||||
[25, 26, 27, 28, 29, 30, 0]
|
||||
],
|
||||
actual
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uruudoshi() {
|
||||
let date = NaiveDate::parse_from_str("2020-02-02", "%Y-%m-%d").unwrap();
|
||||
let actual = calendarize(date);
|
||||
assert_eq!(
|
||||
vec![
|
||||
[0, 0, 0, 0, 0, 0, 1],
|
||||
[2, 3, 4, 5, 6, 7, 8],
|
||||
[9, 10, 11, 12, 13, 14, 15],
|
||||
[16, 17, 18, 19, 20, 21, 22],
|
||||
[23, 24, 25, 26, 27, 28, 29]
|
||||
],
|
||||
actual
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uruwanaidoshi() {
|
||||
let date = NaiveDate::parse_from_str("2021-02-02", "%Y-%m-%d").unwrap();
|
||||
let actual = calendarize(date);
|
||||
assert_eq!(
|
||||
vec![
|
||||
[0, 1, 2, 3, 4, 5, 6],
|
||||
[7, 8, 9, 10, 11, 12, 13],
|
||||
[14, 15, 16, 17, 18, 19, 20],
|
||||
[21, 22, 23, 24, 25, 26, 27],
|
||||
[28, 0, 0, 0, 0, 0, 0]
|
||||
],
|
||||
actual
|
||||
);
|
||||
}
|
|
@ -0,0 +1,161 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
pub use axum::{
|
||||
extract::{Path, State},
|
||||
handler::Handler,
|
||||
response::{Html, IntoResponse, Redirect},
|
||||
routing::{get, post},
|
||||
Extension, Form, Router,
|
||||
};
|
||||
|
||||
pub use http::{Request, Response, StatusCode};
|
||||
|
||||
pub use axum_login::{
|
||||
memory_store::MemoryStore as AuthMemoryStore, secrecy::SecretVec, AuthLayer, AuthUser,
|
||||
RequireAuthorizationLayer,
|
||||
};
|
||||
pub use axum_sessions::{
|
||||
async_session::MemoryStore,
|
||||
extractors::{ReadableSession, WritableSession},
|
||||
SessionLayer,
|
||||
};
|
||||
|
||||
pub type AuthContext =
|
||||
axum_login::extractors::AuthContext<i64, auth::User, Arc<AppState>, auth::Role>;
|
||||
|
||||
pub type RequireAuth = RequireAuthorizationLayer<i64, auth::User, auth::Role>;
|
||||
|
||||
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;
|
||||
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 utils;
|
||||
|
||||
pub use auth::*;
|
||||
pub use cal::calendarize;
|
||||
pub use cal::*;
|
||||
pub use settings::*;
|
||||
pub use utils::*;
|
||||
|
||||
pub struct ResponseError {
|
||||
pub inner: Box<dyn std::error::Error + Send + 'static>,
|
||||
pub status: StatusCode,
|
||||
}
|
||||
|
||||
impl ResponseError {
|
||||
pub fn new(msg: String, status: StatusCode) -> Self {
|
||||
Self {
|
||||
inner: Box::<dyn std::error::Error + Send + Sync>::from(msg),
|
||||
status,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: std::error::Error + Send + 'static> From<E> for ResponseError {
|
||||
fn from(err: E) -> Self {
|
||||
Self {
|
||||
inner: Box::new(err),
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait IntoResponseError: Send + 'static {
|
||||
fn with_status(self, status: StatusCode) -> ResponseError;
|
||||
}
|
||||
|
||||
impl<E: std::error::Error + Send + 'static + Sized> IntoResponseError for E {
|
||||
fn with_status(self, status: StatusCode) -> ResponseError {
|
||||
ResponseError {
|
||||
status,
|
||||
..ResponseError::from(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ResponseError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let Self { inner, status } = self;
|
||||
(status, inner.to_string()).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub trait IntoResponseErrorResult<R>: Send + 'static + Sized {
|
||||
fn with_status(self, status: StatusCode) -> std::result::Result<R, ResponseError>;
|
||||
}
|
||||
|
||||
impl<R: Send + 'static + Sized, E> IntoResponseErrorResult<R> for std::result::Result<R, E>
|
||||
where
|
||||
E: IntoResponseError,
|
||||
{
|
||||
fn with_status(self, status: StatusCode) -> std::result::Result<R, ResponseError> {
|
||||
self.map_err(|err| err.with_status(status))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub conf: Configuration,
|
||||
pub root_url_prefix: String,
|
||||
pub public_url: String,
|
||||
pub user_store: Arc<RwLock<HashMap<i64, User>>>,
|
||||
// ...
|
||||
}
|
||||
|
||||
mod auth_impls {
|
||||
use super::*;
|
||||
type UserId = i64;
|
||||
type User = auth::User;
|
||||
type Role = auth::Role;
|
||||
|
||||
impl AppState {
|
||||
pub async fn insert_user(&self, pk: UserId, user: User) {
|
||||
self.user_store.write().await.insert(pk, user);
|
||||
}
|
||||
}
|
||||
|
||||
#[axum::async_trait]
|
||||
impl axum_login::UserStore<UserId, Role> for Arc<AppState>
|
||||
where
|
||||
User: axum_login::AuthUser<UserId, Role>,
|
||||
{
|
||||
type User = User;
|
||||
|
||||
async fn load_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> std::result::Result<Option<Self::User>, eyre::Report> {
|
||||
Ok(self.user_store.read().await.get(user_id).cloned())
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,230 @@
|
|||
/*
|
||||
* 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 mpot_web::*;
|
||||
use rand::Rng;
|
||||
|
||||
use minijinja::value::Value;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let config_path = std::env::args()
|
||||
.nth(1)
|
||||
.expect("Expected configuration file path as first argument.");
|
||||
let conf = Configuration::from_file(config_path).unwrap();
|
||||
|
||||
let store = MemoryStore::new();
|
||||
let secret = rand::thread_rng().gen::<[u8; 128]>();
|
||||
let session_layer = SessionLayer::new(store, &secret).with_secure(false);
|
||||
|
||||
let shared_state = Arc::new(AppState {
|
||||
conf,
|
||||
root_url_prefix: String::new(),
|
||||
public_url: "lists.mailpot.rs".into(),
|
||||
user_store: Arc::new(RwLock::new(HashMap::default())),
|
||||
});
|
||||
|
||||
let auth_layer = AuthLayer::new(shared_state.clone(), &secret);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(root))
|
||||
.route("/lists/:pk/", get(list))
|
||||
.route("/lists/:pk/edit/", get(list_edit))
|
||||
.route("/help/", get(help))
|
||||
.route(
|
||||
"/login/",
|
||||
get(auth::ssh_signin).post({
|
||||
let shared_state = Arc::clone(&shared_state);
|
||||
move |session, auth, body| auth::ssh_signin_post(session, auth, body, shared_state)
|
||||
}),
|
||||
)
|
||||
.route("/logout/", get(logout_handler))
|
||||
.route(
|
||||
"/settings/",
|
||||
get({
|
||||
let shared_state = Arc::clone(&shared_state);
|
||||
move |user| settings(user, shared_state)
|
||||
}
|
||||
.layer(RequireAuth::login())),
|
||||
)
|
||||
.route(
|
||||
"/settings/list/:pk/",
|
||||
get(user_list_subscription)
|
||||
.layer(RequireAuth::login_with_role(Role::User..))
|
||||
.post({
|
||||
let shared_state = Arc::clone(&shared_state);
|
||||
move |path, user, body| {
|
||||
user_list_subscription_post(path, user, body, shared_state)
|
||||
}
|
||||
})
|
||||
.layer(RequireAuth::login_with_role(Role::User..)),
|
||||
)
|
||||
.layer(auth_layer)
|
||||
.layer(session_layer)
|
||||
.with_state(shared_state);
|
||||
|
||||
// run it with hyper on localhost:3000
|
||||
axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
|
||||
.serve(app.into_make_service())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn root(
|
||||
auth: AuthContext,
|
||||
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 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)?;
|
||||
Ok(minijinja::context! {
|
||||
title => &list.name,
|
||||
posts => &posts,
|
||||
months => &months,
|
||||
body => &list.description.as_deref().unwrap_or_default(),
|
||||
root_url_prefix => &root_url_prefix,
|
||||
list => Value::from_object(MailingList::from(list.clone())),
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, mailpot::Error>>()?;
|
||||
let crumbs = vec![Crumb {
|
||||
label: "Lists".into(),
|
||||
url: "/".into(),
|
||||
}];
|
||||
|
||||
let context = minijinja::context! {
|
||||
title => "mailing list archive",
|
||||
description => "",
|
||||
lists => &lists,
|
||||
root_url_prefix => &root_url_prefix,
|
||||
current_user => auth.current_user,
|
||||
crumbs => crumbs,
|
||||
};
|
||||
Ok(Html(TEMPLATES.get_template("lists.html")?.render(context)?))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
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 post_policy = db.list_policy(list.pk)?;
|
||||
let months = db.months(list.pk)?;
|
||||
|
||||
let posts = db.list_posts(list.pk, None)?;
|
||||
let mut hist = months
|
||||
.iter()
|
||||
.map(|m| (m.to_string(), [0usize; 31]))
|
||||
.collect::<HashMap<String, [usize; 31]>>();
|
||||
let posts_ctx = posts
|
||||
.iter()
|
||||
.map(|post| {
|
||||
//2019-07-14T14:21:02
|
||||
if let Some(day) = post.datetime.get(8..10).and_then(|d| d.parse::<u64>().ok()) {
|
||||
hist.get_mut(&post.month_year).unwrap()[day.saturating_sub(1) as usize] += 1;
|
||||
}
|
||||
let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
|
||||
.expect("Could not parse mail");
|
||||
let mut msg_id = &post.message_id[1..];
|
||||
msg_id = &msg_id[..msg_id.len().saturating_sub(1)];
|
||||
let subject = envelope.subject();
|
||||
let mut subject_ref = subject.trim();
|
||||
if subject_ref.starts_with('[')
|
||||
&& subject_ref[1..].starts_with(&list.id)
|
||||
&& subject_ref[1 + list.id.len()..].starts_with(']')
|
||||
{
|
||||
subject_ref = subject_ref[2 + list.id.len()..].trim();
|
||||
}
|
||||
minijinja::context! {
|
||||
pk => post.pk,
|
||||
list => post.list,
|
||||
subject => subject_ref,
|
||||
address => post.address,
|
||||
message_id => msg_id,
|
||||
message => post.message,
|
||||
timestamp => post.timestamp,
|
||||
datetime => post.datetime,
|
||||
root_url_prefix => "",
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let crumbs = vec![
|
||||
Crumb {
|
||||
label: "Lists".into(),
|
||||
url: "/".into(),
|
||||
},
|
||||
Crumb {
|
||||
label: list.name.clone().into(),
|
||||
url: format!("/lists/{}/", list.pk).into(),
|
||||
},
|
||||
];
|
||||
let context = minijinja::context! {
|
||||
title => &list.name,
|
||||
description => &list.description,
|
||||
post_policy => &post_policy,
|
||||
preamble => true,
|
||||
months => &months,
|
||||
hists => &hist,
|
||||
posts => posts_ctx,
|
||||
body => &list.description.clone().unwrap_or_default(),
|
||||
root_url_prefix => "",
|
||||
list => Value::from_object(MailingList::from(list)),
|
||||
current_user => auth.current_user,
|
||||
crumbs => crumbs,
|
||||
};
|
||||
Ok(Html(TEMPLATES.get_template("list.html")?.render(context)?))
|
||||
}
|
||||
|
||||
async fn list_edit(Path(_): Path<i64>, State(_): State<Arc<AppState>>) {}
|
||||
|
||||
async fn help(
|
||||
auth: AuthContext,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Html<String>, ResponseError> {
|
||||
let root_url_prefix = &state.root_url_prefix;
|
||||
let crumbs = vec![
|
||||
Crumb {
|
||||
label: "Lists".into(),
|
||||
url: "/".into(),
|
||||
},
|
||||
Crumb {
|
||||
label: "Help".into(),
|
||||
url: "/help/".into(),
|
||||
},
|
||||
];
|
||||
let context = minijinja::context! {
|
||||
title => "Help & Documentation",
|
||||
description => "",
|
||||
root_url_prefix => root_url_prefix,
|
||||
current_user => auth.current_user,
|
||||
crumbs => crumbs,
|
||||
};
|
||||
Ok(Html(TEMPLATES.get_template("help.html")?.render(context)?))
|
||||
}
|
|
@ -0,0 +1,197 @@
|
|||
/*
|
||||
* 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 super::*;
|
||||
use mailpot::models::changesets::ListMembershipChangeset;
|
||||
|
||||
pub async fn settings(
|
||||
Extension(user): Extension<User>,
|
||||
state: Arc<AppState>,
|
||||
) -> Result<Html<String>, ResponseError> {
|
||||
let root_url_prefix = &state.root_url_prefix;
|
||||
let crumbs = vec![
|
||||
Crumb {
|
||||
label: "Lists".into(),
|
||||
url: "/".into(),
|
||||
},
|
||||
Crumb {
|
||||
label: "Settings".into(),
|
||||
url: "/settings/".into(),
|
||||
},
|
||||
];
|
||||
let db = Connection::open_db(state.conf.clone())?;
|
||||
let acc = db
|
||||
.account_by_address(&user.address)
|
||||
.with_status(StatusCode::BAD_REQUEST)?
|
||||
.ok_or_else(|| {
|
||||
ResponseError::new("Account not found".to_string(), StatusCode::BAD_REQUEST)
|
||||
})?;
|
||||
let subscriptions = db
|
||||
.account_subscriptions(acc.pk())
|
||||
.with_status(StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let context = minijinja::context! {
|
||||
title => "mailing list archive",
|
||||
description => "",
|
||||
root_url_prefix => &root_url_prefix,
|
||||
user => user,
|
||||
subscriptions => subscriptions,
|
||||
current_user => user,
|
||||
crumbs => crumbs,
|
||||
};
|
||||
Ok(Html(
|
||||
TEMPLATES.get_template("settings.html")?.render(context)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn user_list_subscription(
|
||||
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 acc = match db.account_by_address(&user.address)? {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return Err(ResponseError::new(
|
||||
"Account not found".to_string(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
))
|
||||
}
|
||||
};
|
||||
let mut subscriptions = db
|
||||
.account_subscriptions(acc.pk())
|
||||
.with_status(StatusCode::BAD_REQUEST)?;
|
||||
subscriptions.retain(|s| s.list == id);
|
||||
let subscription = db
|
||||
.list_member(
|
||||
id,
|
||||
subscriptions
|
||||
.get(0)
|
||||
.ok_or_else(|| {
|
||||
ResponseError::new(
|
||||
"Subscription not found".to_string(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
})?
|
||||
.pk(),
|
||||
)
|
||||
.with_status(StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let context = minijinja::context! {
|
||||
title => "mailing list archive",
|
||||
description => "",
|
||||
root_url_prefix => &root_url_prefix,
|
||||
user => user,
|
||||
list => list,
|
||||
subscription => subscription,
|
||||
current_user => user,
|
||||
crumbs => crumbs,
|
||||
};
|
||||
Ok(Html(
|
||||
TEMPLATES
|
||||
.get_template("settings_subscription.html")?
|
||||
.render(context)?,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Default)]
|
||||
pub struct SubscriptionFormPayload {
|
||||
#[serde(default)]
|
||||
pub digest: bool,
|
||||
#[serde(default)]
|
||||
pub hide_address: bool,
|
||||
#[serde(default)]
|
||||
pub receive_duplicates: bool,
|
||||
#[serde(default)]
|
||||
pub receive_own_posts: bool,
|
||||
#[serde(default)]
|
||||
pub receive_confirmation: bool,
|
||||
}
|
||||
|
||||
pub async fn user_list_subscription_post(
|
||||
Path(id): Path<i64>,
|
||||
Extension(user): Extension<User>,
|
||||
Form(payload): Form<SubscriptionFormPayload>,
|
||||
state: Arc<AppState>,
|
||||
) -> Result<impl IntoResponse, ResponseError> {
|
||||
let mut db = Connection::open_db(state.conf.clone())?;
|
||||
|
||||
let _list = db.list(id).with_status(StatusCode::NOT_FOUND)?;
|
||||
|
||||
let acc = match db.account_by_address(&user.address)? {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return Err(ResponseError::new(
|
||||
"Account with this address was not found".to_string(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
));
|
||||
}
|
||||
};
|
||||
let mut subscriptions = db
|
||||
.account_subscriptions(acc.pk())
|
||||
.with_status(StatusCode::BAD_REQUEST)?;
|
||||
|
||||
subscriptions.retain(|s| s.list == id);
|
||||
let mut s = db
|
||||
.list_member(id, subscriptions[0].pk())
|
||||
.with_status(StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let SubscriptionFormPayload {
|
||||
digest,
|
||||
hide_address,
|
||||
receive_duplicates,
|
||||
receive_own_posts,
|
||||
receive_confirmation,
|
||||
} = payload;
|
||||
|
||||
let cset = ListMembershipChangeset {
|
||||
list: s.list,
|
||||
address: std::mem::take(&mut s.address),
|
||||
name: None,
|
||||
digest: Some(digest),
|
||||
hide_address: Some(hide_address),
|
||||
receive_duplicates: Some(receive_duplicates),
|
||||
receive_own_posts: Some(receive_own_posts),
|
||||
receive_confirmation: Some(receive_confirmation),
|
||||
enabled: None,
|
||||
verified: None,
|
||||
};
|
||||
|
||||
db.update_member(cset)
|
||||
.with_status(StatusCode::BAD_REQUEST)?;
|
||||
|
||||
Ok(Redirect::to(&format!("{}/settings/list/{id}/", &state.root_url_prefix)).into_response())
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
{% include "header.html" %}
|
||||
<div class="body body-grid">
|
||||
<p>Sign <mark><code>{{ ssh_challenge }}</code></mark> with your previously configured key within <time title="{{ timeout_left }}" datetime="{{ timeout_left }}">{{ timeout_left }} minutes</time>. Example:</p>
|
||||
<pre class="command-line-example">printf '<ruby><mark>{{ ssh_challenge }}</mark><rp>(</rp><rt>signin challenge</rt><rp>)</rp></ruby>' | ssh-keygen -Y sign -f <ruby>~/.ssh/id_rsa <rp>(</rp><rt>your account's key</rt><rp>)</rp></ruby> -n <ruby>{{ namespace }}<rp>(</rp><rt>namespace</rt><rp>)</rp></ruby></pre>
|
||||
<form method="post" class="login-form login-ssh">
|
||||
<label for="id_address">Email address:</label>
|
||||
<input type="text" name="address" required="" id="id_address">
|
||||
<label for="id_password">SSH signature:</label>
|
||||
<textarea name="password" cols="15" rows="5" placeholder="-----BEGIN SSH SIGNATURE----- changemechangemechangemechangemechangemechangemechangemechangemechange mechangemechangemechangemechangemechangemechangemechangemechangemechan gemechangemechangemechangemechangemechangemechangemechangemechangemech angemechangemechangemechangemechangemechangemechangemechangemechangeme changemechangemechangemechangemechangemechangemechangemechangemechange mechangemechangemechangemechangemechangemechangemechangemechangemechan gemechangemechangemechangemechangemechangemechangemechangemechangemech angemechangemechangemechangemechangemechangemechangemechangemechangeme changemechangemechangemechangemechangemechangemechangemechangemechange mechangemechangemechangemechangemechangemechangemechangemechangemechan gemechangemechangemechangemechangemechangemechangemechangemechangemech angemechangemechangemechangemechangemechangemechangemechangemechangeme changemechangemechangemechangemechangemechangemechangemechangemechange mechangemechangemechangemechangemechangemechangemechangemechangemechan gemechangemechangemechangemechangemechangemechangemechangemechangemech angemechangemechangemechangemechangemechangemechangemechangemechangeme changemechangemechangemechangemechangemechangemechangemechangemechange mechangemechangemechangemechangemechangemechangemechangemechangemechan gemechangemechangemechangemechangemechangemechangemechangemechangemech angemechangemechangemechangemechangemechangemechangemechangemechangeme changemechangemechangemechangemechangemechangemechangemechangemechange chang= -----END SSH SIGNATURE----- " required="" id="id_password"></textarea>
|
||||
<input type="submit" value="login">
|
||||
<input type="hidden" name="next" value="">
|
||||
<input formaction="/accounts/sshlogin/" formnovalidate="true" type="submit" name="refresh" value="refresh token">
|
||||
</form>
|
||||
</div>
|
||||
{% include "footer.html" %}
|
|
@ -0,0 +1,43 @@
|
|||
{% macro cal(date, hists, root_url_prefix, pk) %}
|
||||
{% set c=calendarize(date, hists) %}
|
||||
{% if c.sum > 0 %}
|
||||
<table>
|
||||
<caption align="top">
|
||||
<!--<a href="{{ root_url_prefix|safe }}/list/{{pk}}/{{ c.month }}">-->
|
||||
<a href="#" style="color: GrayText;">
|
||||
{{ c.month_name }} {{ c.year }}
|
||||
</a>
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>M</th>
|
||||
<th>Tu</th>
|
||||
<th>W</th>
|
||||
<th>Th</th>
|
||||
<th>F</th>
|
||||
<th>Sa</th>
|
||||
<th>Su</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for week in c.weeks %}
|
||||
<tr>
|
||||
{% for day in week %}
|
||||
{% if day == 0 %}
|
||||
<td></td>
|
||||
{% else %}
|
||||
{% set num = c.hist[day-1] %}
|
||||
{% if num > 0 %}
|
||||
<td><ruby>{{ day }}<rt>({{ num }})</rt></ruby></td>
|
||||
{% else %}
|
||||
<td class="empty">{{ day }}</td>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
{% set alias = cal %}
|
|
@ -0,0 +1,472 @@
|
|||
<style>
|
||||
@charset "UTF-8";
|
||||
* Use a more intuitive box-sizing model */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Remove all margins & padding */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Only show focus outline when the user is tabbing (not when clicking) */
|
||||
*:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: 1px solid blue;
|
||||
}
|
||||
|
||||
/* Prevent mobile browsers increasing font-size */
|
||||
html {
|
||||
-moz-text-size-adjust: none;
|
||||
-webkit-text-size-adjust: none;
|
||||
text-size-adjust: none;
|
||||
font-family:-apple-system,BlinkMacSystemFont,Arial,sans-serif;
|
||||
line-height:1.15;
|
||||
-webkit-text-size-adjust:100%;
|
||||
overflow-y:scroll;
|
||||
}
|
||||
|
||||
/* Allow percentage-based heights */
|
||||
/* Setting width: 100% isn't required because it is a default for block-level elements (html & body are block level) */
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
/* Prevent the rubber band effect when the user scrolls to the top or bottom of the page (WebKit only) */
|
||||
overscroll-behavior: none;
|
||||
|
||||
/* Prevent the browser from synthesizing missing typefaces */
|
||||
font-synthesis: none;
|
||||
|
||||
color: black;
|
||||
/* UI controls color (example: range input) */
|
||||
accent-color: black;
|
||||
|
||||
/* Because overscroll-behavior: none only works on WebKit, a background color is set that will show when overscroll occurs */
|
||||
background: white;
|
||||
margin:0;
|
||||
font-feature-settings:"onum" 1;
|
||||
text-rendering:optimizeLegibility;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
-moz-osx-font-smoothing:grayscale;
|
||||
font-family:-apple-system,BlinkMacSystemFont,Roboto,Roboto Slab,Droid Serif,Segoe UI,system-ui,Arial,sans-serif;
|
||||
font-size:1.125em
|
||||
}
|
||||
|
||||
/* Remove unintuitive behaviour such as gaps around media elements. */
|
||||
img, picture, video, canvas, svg, iframe {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Avoid text overflow */
|
||||
h1, h2, h3, h4, h5, h6, p, strong {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
input {
|
||||
border: none;
|
||||
}
|
||||
|
||||
input, button, textarea, select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* Create a root stacking context (only when using frameworks like Next.js) */
|
||||
#__next {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
|
||||
body>main.layout {
|
||||
width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
display: grid;
|
||||
grid:
|
||||
"header header header" auto
|
||||
"leftside body rightside" 1fr
|
||||
"footer footer footer" auto
|
||||
/ auto 1fr auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
main.layout>.header { grid-area: header; }
|
||||
main.layout>.leftside { grid-area: leftside; }
|
||||
main.layout>div.body { grid-area: body; }
|
||||
main.layout>.rightside { grid-area: rightside; }
|
||||
main.layout>footer {
|
||||
grid-area: footer;
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
|
||||
main.layout>div.header>h1 {
|
||||
margin: 1rem;
|
||||
}
|
||||
main.layout>div.header>nav + nav {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
nav.main-nav {
|
||||
padding: 0rem 1rem;
|
||||
border: 1px solid #4d4e53;
|
||||
border-radius: 2px;
|
||||
padding: 10px 14px 10px 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
nav.main-nav>ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
nav.main-nav>ul>li>a {
|
||||
padding: 1rem;
|
||||
}
|
||||
nav.main-nav > ul > li > a:hover {
|
||||
outline: 0.1rem solid;
|
||||
outline-offset: -0.5rem;
|
||||
}
|
||||
nav.main-nav >ul .push {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
main.layout>div.body h2 {
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
nav.breadcrumbs {
|
||||
padding: 10px 14px 10px 10px;
|
||||
}
|
||||
|
||||
nav.breadcrumbs ol {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.crumb {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.crumb a::after {
|
||||
display: inline-block;
|
||||
color: #000;
|
||||
content: '>';
|
||||
font-size: 80%;
|
||||
font-weight: bold;
|
||||
padding: 0 3px;
|
||||
}
|
||||
|
||||
.crumb span[aria-current="page"] {
|
||||
color: GrayText;
|
||||
padding: 0.4rem;
|
||||
margin-left: -0.4rem;
|
||||
}
|
||||
|
||||
div.preamble {
|
||||
border-left: 0.2rem solid GrayText;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
div.calendar th {
|
||||
padding: 0.5rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
div.calendar tr,
|
||||
div.calendar th {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
div.calendar table {
|
||||
display: inline-table;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
div.calendar td {
|
||||
padding: 0.1rem 0.4rem;
|
||||
}
|
||||
|
||||
div.calendar td.empty {
|
||||
color: GrayText;
|
||||
}
|
||||
|
||||
div.calendar td:not(.empty) {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
div.calendar td:not(:empty) {
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
div.calendar td:empty {
|
||||
background: GrayText;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
div.calendar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
div.calendar caption {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
div.posts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
div.posts>div.entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
div.posts>div.entry>span.subject {
|
||||
font-size: larger;
|
||||
}
|
||||
|
||||
div.posts>div.entry>span.metadata {
|
||||
color: GrayText;
|
||||
}
|
||||
|
||||
div.posts>div.entry>span.metadata>span.from {
|
||||
margin-inline-end: 1rem;
|
||||
}
|
||||
|
||||
table.headers tr>th {
|
||||
text-align: right;
|
||||
color: GrayText;
|
||||
}
|
||||
table.headers th[scope="row"] {
|
||||
padding-right: .5rem;
|
||||
}
|
||||
table.headers tr>th:after {
|
||||
content:':';
|
||||
display: inline-block;
|
||||
}
|
||||
div.post-body {
|
||||
margin: 1rem;
|
||||
}
|
||||
div.post-body>pre {
|
||||
max-width: 98vw;
|
||||
overflow-wrap: break-word;
|
||||
white-space: pre-line;
|
||||
}
|
||||
td.message-id,
|
||||
span.message-id{
|
||||
color: GrayText;
|
||||
}
|
||||
td.message-id:before,
|
||||
span.message-id:before{
|
||||
content:'<';
|
||||
display: inline-block;
|
||||
}
|
||||
td.message-id:after,
|
||||
span.message-id:after{
|
||||
content:'>';
|
||||
display: inline-block;
|
||||
}
|
||||
span.message-id + span.message-id:before{
|
||||
content:', <';
|
||||
display: inline-block;
|
||||
}
|
||||
td.faded,
|
||||
span.faded {
|
||||
color: GrayText;
|
||||
}
|
||||
td.faded:is(:focus, :hover, :focus-visible, :focus-within),
|
||||
span.faded:is(:focus, :hover, :focus-visible, :focus-within) {
|
||||
color: revert;
|
||||
}
|
||||
|
||||
ul.lists {
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
|
||||
ul.lists li {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
ul.lists li + li {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 1rem 0rem;
|
||||
}
|
||||
|
||||
.command-line-example {
|
||||
user-select: all;
|
||||
display: inline-block;
|
||||
ruby-align: center;
|
||||
ruby-position: under;
|
||||
padding: 0;
|
||||
|
||||
--pre-bg-color: #e9e9e9;
|
||||
background: var(--pre-bg-color);
|
||||
outline: 5px solid var(--pre-bg-color);
|
||||
width: min-content;
|
||||
max-width: 90vw;
|
||||
padding: 2px 7px;
|
||||
overflow-wrap: break-word;
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.login-ssh textarea#id_password {
|
||||
font-family: monospace;
|
||||
font-size: 0.5rem;
|
||||
font-weight: bolder;
|
||||
width: 71ch;
|
||||
height: 29rem;
|
||||
max-width: 88vw;
|
||||
overflow-wrap: break-word;
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.login-ssh textarea#id_password::placeholder {
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.body-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-auto-rows: min-content;
|
||||
row-gap: min(6vw, 3rem);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
form.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 98vw;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
form.login-form > :not([type="hidden"]) + label, fieldset > :not([type="hidden"], legend) + label {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
form.settings-form {
|
||||
display: grid;
|
||||
grid-template-columns: auto;
|
||||
gap: 1rem;
|
||||
max-width: 90vw;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
form.settings-form>input[type="submit"] {
|
||||
place-self: start;
|
||||
}
|
||||
|
||||
form.settings-form>fieldset {
|
||||
padding: 1rem 1.5rem 2rem 1.5rem;
|
||||
}
|
||||
|
||||
form.settings-form>fieldset>legend {
|
||||
padding: .5rem 1rem;
|
||||
border: 1px ridge lightgray;
|
||||
font-weight: bold;
|
||||
font-size: small;
|
||||
margin-left: 0.8rem;
|
||||
}
|
||||
|
||||
form.settings-form>fieldset>div {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
form.settings-form>fieldset>div>label {
|
||||
padding: 1rem 0 1rem 1rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
|
||||
form.settings-form>fieldset>div>input {
|
||||
margin: 0.8rem;
|
||||
}
|
||||
|
||||
button, input {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
button, input, optgroup, select, textarea {
|
||||
font-family: inherit;
|
||||
font-size: 100%;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
form label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
textarea {
|
||||
max-width: var(--main-width);
|
||||
width: 100%;
|
||||
resize: both;
|
||||
}
|
||||
textarea {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
button, [type="button"], [type="reset"], [type="submit"] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
display: inline-block;
|
||||
appearance: auto;
|
||||
-moz-default-appearance: textfield;
|
||||
padding: 1px;
|
||||
border: 2px inset ButtonBorder;
|
||||
border-radius: 5px;
|
||||
padding: .5rem;
|
||||
background-color: Field;
|
||||
color: FieldText;
|
||||
font: -moz-field;
|
||||
text-rendering: optimizeLegibility;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
button, ::file-selector-button, input:is([type="color"], [type="reset"], [type="button"], [type="submit"]) {
|
||||
appearance: auto;
|
||||
-moz-default-appearance: button;
|
||||
padding-block: 1px;
|
||||
padding-inline: 8px;
|
||||
border: 2px outset ButtonBorder;
|
||||
border-radius: 5px;
|
||||
background-color: ButtonFace;
|
||||
cursor: default;
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
padding: .5rem;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,8 @@
|
|||
<footer>
|
||||
<hr />
|
||||
<p>Generated by <a href="https://github.com/meli/mailpot">mailpot</a>.</p>
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ title }}</title>
|
||||
{% include "css.html" %}
|
||||
</head>
|
||||
<body>
|
||||
<main class="layout">
|
||||
<div class="header">
|
||||
<h1>{{ title }}</h1>
|
||||
{% if description %}
|
||||
<p class="description">{{ description }}</p>
|
||||
{% endif %}
|
||||
{% include "menu.html" %}
|
||||
</div>
|
|
@ -0,0 +1,5 @@
|
|||
{% include "header.html" %}
|
||||
<div class="body body-grid">
|
||||
{{ body }}
|
||||
</div>
|
||||
{% include "footer.html" %}
|
|
@ -0,0 +1,12 @@
|
|||
{% include "header.html" %}
|
||||
<div class="entry">
|
||||
<h1>{{title}}</h1>
|
||||
<div class="body">
|
||||
<ul>
|
||||
{% for l in lists %}
|
||||
<li><a href="{{ root_url_prefix|safe }}/lists/{{ l.list.pk }}/">{{l.title}}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% include "footer.html" %}
|
|
@ -0,0 +1,82 @@
|
|||
{% include "header.html" %}
|
||||
<div class="body">
|
||||
{% if preamble %}
|
||||
<div id="preamble" class="preamble">
|
||||
{% if preamble.custom %}
|
||||
{{ preamble.custom|safe }}
|
||||
{% else %}
|
||||
{% if not post_policy.no_subscriptions %}
|
||||
<h2 id="subscribe">Subscribe</h2>
|
||||
{% set subscribe_mailto=list.subscribe_mailto() %}
|
||||
{% if subscribe_mailto %}
|
||||
{% if subscribe_mailto.subject %}
|
||||
<p>
|
||||
<a href="mailto:{{ subscribe_mailto.address|safe }}?subject={{ subscribe_mailto.subject|safe }}"><code>{{ subscribe_mailto.address }}</code></a> with the following subject: <code>{{ subscribe_mailto.subject}}</code>
|
||||
</p>
|
||||
{% else %}
|
||||
<p>
|
||||
<a href="mailto:{{ subscribe_mailto.address|safe }}"><code>{{ subscribe_mailto.address }}</code></a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p>List is not open for subscriptions.</p>
|
||||
{% endif %}
|
||||
|
||||
{% set unsubscribe_mailto=list.unsubscribe_mailto() %}
|
||||
{% if unsubscribe_mailto %}
|
||||
<h2 id="unsubscribe">Unsubscribe</h2>
|
||||
{% if unsubscribe_mailto.subject %}
|
||||
<p>
|
||||
<a href="mailto:{{ unsubscribe_mailto.address|safe }}?subject={{ unsubscribe_mailto.subject|safe }}"><code>{{ unsubscribe_mailto.address }}</code></a> with the following subject: <code>{{unsubscribe_mailto.subject}}</code>
|
||||
</p>
|
||||
{% else %}
|
||||
<p>
|
||||
<a href="mailto:{{ unsubscribe_mailto.address|safe }}"><code>{{ unsubscribe_mailto.address }}</code></a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<h2 id="post">Post</h2>
|
||||
{% if post_policy.announce_only %}
|
||||
<p>List is <em>announce-only</em>, i.e. you can only subscribe to receive announcements.</p>
|
||||
{% elif post_policy.subscriber_only %}
|
||||
<p>List is <em>subscriber-only</em>, i.e. you can only post if you are subscribed.</p>
|
||||
<p>If you are subscribed, you can send new posts to:
|
||||
<a href="mailto:{{ list.address| safe }}"><code>{{ list.address }}</code></a>
|
||||
</p>
|
||||
{% elif post_policy.approval_needed or post_policy.no_subscriptions %}
|
||||
<p>List is open to all posts <em>after approval</em> by the list owners.</p>
|
||||
<p>You can send new posts to:
|
||||
<a href="mailto:{{ list.address| safe }}"><code>{{ list.address }}</code></a>
|
||||
</p>
|
||||
{% else %}
|
||||
<p>List is not open for submissions.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<hr />
|
||||
{% endif %}
|
||||
<div class="list">
|
||||
<h2 id="calendar">Calendar</h2>
|
||||
<div class="calendar">
|
||||
{%- from "calendar.html" import cal %}
|
||||
{% for date in months %}
|
||||
{{ cal(date, hists, root_url_prefix, list.pk) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<hr />
|
||||
<h2 id="posts">Posts</h2>
|
||||
<div class="posts">
|
||||
<p>{{ posts | length }} post(s)</p>
|
||||
{% for post in posts %}
|
||||
<div class="entry">
|
||||
<span class="subject"><a href="{{ root_url_prefix|safe }}/list/{{post.list}}/{{ post.message_id }}.html">{{ 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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "footer.html" %}
|
|
@ -0,0 +1,12 @@
|
|||
{% include "header.html" %}
|
||||
<div class="body">
|
||||
<p>{{lists|length}} lists</p>
|
||||
<div class="entry">
|
||||
<ul class="lists">
|
||||
{% for l in lists %}
|
||||
<li><a href="{{ root_url_prefix|safe }}/lists/{{ l.list.pk }}/">{{ l.title }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% include "footer.html" %}
|
|
@ -0,0 +1,22 @@
|
|||
<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>
|
||||
{% if current_user %}
|
||||
<li class="push">Settings: <a href="{{ root_url_prefix }}/settings/">{{ current_user.address }}</a></li>
|
||||
{% else %}
|
||||
<li class="push"><a href="{{ root_url_prefix }}/login/">Login with SSH OTP</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
<nav aria-label="Breadcrumb" class="breadcrumbs">
|
||||
<ol>
|
||||
{% for crumb in crumbs %}
|
||||
{% if loop.last %}
|
||||
<li class="crumb"><span aria-current="page">{{ crumb.label }}</span></li>
|
||||
{% else %}
|
||||
<li class="crumb"><a href="{{ root_url_prefix }}{{ crumb.url }}">{{ crumb.label }}</a></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</nav>
|
|
@ -0,0 +1,42 @@
|
|||
{% include "header.html" %}
|
||||
<div class="body">
|
||||
<h2>{{trimmed_subject}}</h2>
|
||||
<table class="headers">
|
||||
<tr>
|
||||
<th scope="row">List</th>
|
||||
<td class="faded">{{ list.id }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">From</th>
|
||||
<td>{{ from }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">To</th>
|
||||
<td class="faded">{{ to }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Subject</th>
|
||||
<td>{{ subject }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Date</th>
|
||||
<td class="faded">{{ date }}</td>
|
||||
</tr>
|
||||
{% if in_reply_to %}
|
||||
<tr>
|
||||
<th scope="row">In-Reply-To</th>
|
||||
<td class="faded message-id"><a href="{{ root_url_prefix|safe }}/list/{{list.pk}}/{{ in_reply_to }}.html">{{ 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 }}/list/{{list.pk}}/{{ r }}.html">{{ r }}</a></span>{% endfor %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
<div class="post-body">
|
||||
<pre>{{body}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% include "footer.html" %}
|
|
@ -0,0 +1,12 @@
|
|||
{% include "header.html" %}
|
||||
<div class="body body-grid">
|
||||
<p>Address: {{ user.address }}</p>
|
||||
<ol>
|
||||
{% for s in subscriptions %}
|
||||
<li>
|
||||
<a href="{{ root_url_prefix }}/settings/list/{{ s.list }}/">{{ s.list }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
{% include "footer.html" %}
|
|
@ -0,0 +1,47 @@
|
|||
{% 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>
|
||||
<address>
|
||||
{{ list.name }} <a href="mailto:{{ list.address | safe }}"><code>{{ list.address }}</code></a>
|
||||
</address>
|
||||
{% if list.description %}
|
||||
<p>{{ list.description }}</p>
|
||||
{% endif %}
|
||||
{% if list.archive_url %}
|
||||
<p><a href="{{ list.archive_url }}">{{ list.archive_url }}</a></p>
|
||||
{% endif %}
|
||||
<form method="post" class="settings-form">
|
||||
<fieldset>
|
||||
<legend>subscription settings</legend>
|
||||
|
||||
<div>
|
||||
<input type="checkbox" value="true" name="digest" id="id_digest"{% if subscription.digest %} checked{% endif %}>
|
||||
<label for="id_digest">Receive posts as a digest.</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input type="checkbox" value="true" name="hide_address" id="id_hide_address"{% if subscription.hide_address %} checked{% endif %}>
|
||||
<label for="id_hide_address">Hide your e-mail address in your posts.</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input type="checkbox" value="true" name="receive_duplicates" id="id_receive_duplicates"{% if subscription.receive_duplicates %} checked{% endif %}>
|
||||
<label for="id_receive_duplicates">Receive mailing list post duplicates, <abbr title="that is">i.e.</abbr> posts addressed both to you and the mailing list to which you are subscribed.</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input type="checkbox" value="true" name="receive_own_posts" id="id_receive_own_posts"{% if subscription.receive_own_posts %} checked{% endif %}>
|
||||
<label for="id_receive_own_posts">Receive your own mailing list posts from the mailing list.</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input type="checkbox" value="true" name="receive_confirmation" id="id_receive_confirmation"{% if subscription.receive_confirmation %} checked{% endif %}>
|
||||
<label for="id_receive_confirmation">Receive a plain confirmation for your own mailing list posts.</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<input type="submit" value="Update settings">
|
||||
<input type="hidden" name="next" value="">
|
||||
</form>
|
||||
</div>
|
||||
{% include "footer.html" %}
|
|
@ -0,0 +1,188 @@
|
|||
/*
|
||||
* 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 super::*;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref TEMPLATES: Environment<'static> = {
|
||||
let mut env = Environment::new();
|
||||
env.add_function("calendarize", calendarize);
|
||||
env.set_source(Source::from_path("web/src/templates/"));
|
||||
|
||||
env
|
||||
};
|
||||
}
|
||||
|
||||
pub trait StripCarets {
|
||||
fn strip_carets(&self) -> &str;
|
||||
}
|
||||
|
||||
impl StripCarets for &str {
|
||||
fn strip_carets(&self) -> &str {
|
||||
let mut self_ref = self.trim();
|
||||
if self_ref.starts_with('<') && self_ref.ends_with('>') {
|
||||
self_ref = &self_ref[1..self_ref.len().saturating_sub(1)];
|
||||
}
|
||||
self_ref
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
|
||||
pub struct MailingList {
|
||||
pub pk: i64,
|
||||
pub name: String,
|
||||
pub id: String,
|
||||
pub address: String,
|
||||
pub description: Option<String>,
|
||||
pub archive_url: Option<String>,
|
||||
pub inner: DbVal<mailpot::models::MailingList>,
|
||||
}
|
||||
|
||||
impl From<DbVal<mailpot::models::MailingList>> for MailingList {
|
||||
fn from(val: DbVal<mailpot::models::MailingList>) -> Self {
|
||||
let DbVal(
|
||||
mailpot::models::MailingList {
|
||||
pk,
|
||||
name,
|
||||
id,
|
||||
address,
|
||||
description,
|
||||
archive_url,
|
||||
},
|
||||
_,
|
||||
) = val.clone();
|
||||
|
||||
Self {
|
||||
pk,
|
||||
name,
|
||||
id,
|
||||
address,
|
||||
description,
|
||||
archive_url,
|
||||
inner: val,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MailingList {
|
||||
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
self.id.fmt(fmt)
|
||||
}
|
||||
}
|
||||
|
||||
impl Object for MailingList {
|
||||
fn kind(&self) -> minijinja::value::ObjectKind {
|
||||
minijinja::value::ObjectKind::Struct(self)
|
||||
}
|
||||
|
||||
fn call_method(
|
||||
&self,
|
||||
_state: &minijinja::State,
|
||||
name: &str,
|
||||
_args: &[Value],
|
||||
) -> std::result::Result<Value, Error> {
|
||||
match name {
|
||||
"subscribe_mailto" => Ok(Value::from_serializable(&self.inner.subscribe_mailto())),
|
||||
"unsubscribe_mailto" => Ok(Value::from_serializable(&self.inner.unsubscribe_mailto())),
|
||||
_ => Err(Error::new(
|
||||
minijinja::ErrorKind::UnknownMethod,
|
||||
format!("aaaobject has no method named {name}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl minijinja::value::StructObject for MailingList {
|
||||
fn get_field(&self, name: &str) -> Option<Value> {
|
||||
match name {
|
||||
"pk" => Some(Value::from_serializable(&self.pk)),
|
||||
"name" => Some(Value::from_serializable(&self.name)),
|
||||
"id" => Some(Value::from_serializable(&self.id)),
|
||||
"address" => Some(Value::from_serializable(&self.address)),
|
||||
"description" => Some(Value::from_serializable(&self.description)),
|
||||
"archive_url" => Some(Value::from_serializable(&self.archive_url)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn static_fields(&self) -> Option<&'static [&'static str]> {
|
||||
Some(&["pk", "name", "id", "address", "description", "archive_url"][..])
|
||||
}
|
||||
}
|
||||
|
||||
pub fn calendarize(
|
||||
_state: &minijinja::State,
|
||||
args: Value,
|
||||
hists: Value,
|
||||
) -> std::result::Result<Value, Error> {
|
||||
use chrono::Month;
|
||||
|
||||
macro_rules! month {
|
||||
($int:expr) => {{
|
||||
let int = $int;
|
||||
match int {
|
||||
1 => Month::January.name(),
|
||||
2 => Month::February.name(),
|
||||
3 => Month::March.name(),
|
||||
4 => Month::April.name(),
|
||||
5 => Month::May.name(),
|
||||
6 => Month::June.name(),
|
||||
7 => Month::July.name(),
|
||||
8 => Month::August.name(),
|
||||
9 => Month::September.name(),
|
||||
10 => Month::October.name(),
|
||||
11 => Month::November.name(),
|
||||
12 => Month::December.name(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}};
|
||||
}
|
||||
let month = args.as_str().unwrap();
|
||||
let hist = hists
|
||||
.get_item(&Value::from(month))?
|
||||
.as_seq()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| usize::try_from(v).unwrap())
|
||||
.collect::<Vec<usize>>();
|
||||
let sum: usize = hists
|
||||
.get_item(&Value::from(month))?
|
||||
.as_seq()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| usize::try_from(v).unwrap())
|
||||
.sum();
|
||||
let date = chrono::NaiveDate::parse_from_str(&format!("{}-01", month), "%F").unwrap();
|
||||
// Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
|
||||
Ok(minijinja::context! {
|
||||
month_name => month!(date.month()),
|
||||
month => month,
|
||||
month_int => date.month() as usize,
|
||||
year => date.year(),
|
||||
weeks => cal::calendarize_with_offset(date, 1),
|
||||
hist => hist,
|
||||
sum => sum,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Crumb {
|
||||
pub label: Cow<'static, str>,
|
||||
pub url: Cow<'static, str>,
|
||||
}
|
Loading…
Reference in New Issue