Add web server with axium and SSH OTP auth

web: impl auth with ssh OTP
axum
Manos Pitsidianakis 2023-04-10 13:02:25 +03:00
parent 886178926a
commit d6273b416e
Signed by: Manos Pitsidianakis
GPG Key ID: 7729C7707F7E09D0
26 changed files with 2944 additions and 151 deletions

721
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,4 +4,5 @@ members = [
"cli",
"core",
"rest-http",
"web",
]

View File

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

View File

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

View File

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

32
web/Cargo.toml 100644
View File

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

12
web/README.md 100644
View File

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

391
web/src/auth.rs 100644
View File

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

244
web/src/cal.rs 100644
View File

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

161
web/src/lib.rs 100644
View File

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

230
web/src/main.rs 100644
View File

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

197
web/src/settings.rs 100644
View File

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

View File

@ -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-----&#10;changemechangemechangemechangemechangemechangemechangemechangemechange&#10;mechangemechangemechangemechangemechangemechangemechangemechangemechan&#10;gemechangemechangemechangemechangemechangemechangemechangemechangemech&#10;angemechangemechangemechangemechangemechangemechangemechangemechangeme&#10;changemechangemechangemechangemechangemechangemechangemechangemechange&#10;mechangemechangemechangemechangemechangemechangemechangemechangemechan&#10;gemechangemechangemechangemechangemechangemechangemechangemechangemech&#10;angemechangemechangemechangemechangemechangemechangemechangemechangeme&#10;changemechangemechangemechangemechangemechangemechangemechangemechange&#10;mechangemechangemechangemechangemechangemechangemechangemechangemechan&#10;gemechangemechangemechangemechangemechangemechangemechangemechangemech&#10;angemechangemechangemechangemechangemechangemechangemechangemechangeme&#10;changemechangemechangemechangemechangemechangemechangemechangemechange&#10;mechangemechangemechangemechangemechangemechangemechangemechangemechan&#10;gemechangemechangemechangemechangemechangemechangemechangemechangemech&#10;angemechangemechangemechangemechangemechangemechangemechangemechangeme&#10;changemechangemechangemechangemechangemechangemechangemechangemechange&#10;mechangemechangemechangemechangemechangemechangemechangemechangemechan&#10;gemechangemechangemechangemechangemechangemechangemechangemechangemech&#10;angemechangemechangemechangemechangemechangemechangemechangemechangeme&#10;changemechangemechangemechangemechangemechangemechangemechangemechange&#10;chang=&#10;-----END SSH SIGNATURE-----&#10;" 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" %}

View File

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

View File

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

View File

@ -0,0 +1,8 @@
<footer>
<hr />
<p>Generated by <a href="https://github.com/meli/mailpot">mailpot</a>.</p>
</footer>
</main>
</body>
</html>

View File

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

View File

@ -0,0 +1,5 @@
{% include "header.html" %}
<div class="body body-grid">
{{ body }}
</div>
{% include "footer.html" %}

View File

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

View File

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

View File

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

View File

@ -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&nbsp;&amp; 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>

View File

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

View File

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

View File

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

188
web/src/utils.rs 100644
View File

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