Compare commits
7 Commits
Author | SHA1 | Date |
---|---|---|
Manos Pitsidianakis | 37a269a2d0 | |
Manos Pitsidianakis | 2283b6b61e | |
Manos Pitsidianakis | 3da43ed984 | |
Manos Pitsidianakis | 6dd3575355 | |
Manos Pitsidianakis | d11df45c9d | |
Manos Pitsidianakis | 9815a6f0b1 | |
Manos Pitsidianakis | d6273b416e |
File diff suppressed because it is too large
Load Diff
|
@ -4,4 +4,5 @@ members = [
|
|||
"cli",
|
||||
"core",
|
||||
"rest-http",
|
||||
"web",
|
||||
]
|
||||
|
|
22
README.md
22
README.md
|
@ -130,8 +130,8 @@ TRACE - Received envelope to post: Envelope {
|
|||
TRACE - Is post related to list [#1 test] Test list <test@localhost>? false
|
||||
TRACE - Is post related to list [#2 test-announce] test announcements <test-announce@localhost>? true
|
||||
TRACE - Examining list "test announcements" <test-announce@localhost>
|
||||
TRACE - List members [
|
||||
ListMembership {
|
||||
TRACE - List subscriptions [
|
||||
ListSubscription {
|
||||
list: 2,
|
||||
address: "exxxxx@localhost",
|
||||
name: None,
|
||||
|
@ -147,9 +147,9 @@ TRACE - Running FixCRLF filter
|
|||
TRACE - Running PostRightsCheck filter
|
||||
TRACE - Running AddListHeaders filter
|
||||
TRACE - Running FinalizeRecipients filter
|
||||
TRACE - examining member ListMembership { list: 2, address: "exxxxx@localhost", name: None, digest: false, hide_address: false, receive_duplicates: false, receive_own_posts: true, receive_confirmation: true, enabled: true }
|
||||
TRACE - member is submitter
|
||||
TRACE - Member gets copy
|
||||
TRACE - examining subscription ListSubscription { list: 2, address: "exxxxx@localhost", name: None, digest: false, hide_address: false, receive_duplicates: false, receive_own_posts: true, receive_confirmation: true, enabled: true }
|
||||
TRACE - subscription is submitter
|
||||
TRACE - subscription gets copy
|
||||
TRACE - result Ok(
|
||||
Post {
|
||||
list: MailingList {
|
||||
|
@ -164,7 +164,7 @@ TRACE - result Ok(
|
|||
display_name: "Mxxxx Pxxxxxxxxxxxx",
|
||||
address_spec: "exxxxx@localhost",
|
||||
},
|
||||
members: 1,
|
||||
subscriptions: 1,
|
||||
bytes: 851,
|
||||
policy: None,
|
||||
to: [
|
||||
|
@ -217,17 +217,17 @@ db.set_list_policy(
|
|||
pk: 0,
|
||||
list: list_pk,
|
||||
announce_only: false,
|
||||
subscriber_only: true,
|
||||
subscription_only: true,
|
||||
approval_needed: false,
|
||||
open: false,
|
||||
custom: false,
|
||||
},
|
||||
)?;
|
||||
|
||||
// Drop privileges; we can only process new e-mail and modify memberships from now on.
|
||||
// Drop privileges; we can only process new e-mail and modify subscriptions from now on.
|
||||
let mut db = db.untrusted();
|
||||
|
||||
assert_eq!(db.list_members(list_pk)?.len(), 0);
|
||||
assert_eq!(db.list_subscriptions(list_pk)?.len(), 0);
|
||||
assert_eq!(db.list_posts(list_pk, None)?.len(), 0);
|
||||
|
||||
// Process a subscription request e-mail
|
||||
|
@ -241,7 +241,7 @@ Message-ID: <1@example.com>
|
|||
let envelope = melib::Envelope::from_bytes(subscribe_bytes, None)?;
|
||||
db.post(&envelope, subscribe_bytes, /* dry_run */ false)?;
|
||||
|
||||
assert_eq!(db.list_members(list_pk)?.len(), 1);
|
||||
assert_eq!(db.list_subscriptions(list_pk)?.len(), 1);
|
||||
assert_eq!(db.list_posts(list_pk, None)?.len(), 0);
|
||||
|
||||
// Process a post
|
||||
|
@ -257,7 +257,7 @@ let envelope =
|
|||
melib::Envelope::from_bytes(post_bytes, None).expect("Could not parse message");
|
||||
db.post(&envelope, post_bytes, /* dry_run */ false)?;
|
||||
|
||||
assert_eq!(db.list_members(list_pk)?.len(), 1);
|
||||
assert_eq!(db.list_subscriptions(list_pk)?.len(), 1);
|
||||
assert_eq!(db.list_posts(list_pk, None)?.len(), 1);
|
||||
# Ok::<(), Error>(())
|
||||
```
|
||||
|
|
|
@ -7,31 +7,31 @@
|
|||
{% 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 %}
|
||||
{% set subscription_mailto=list.subscription_mailto() %}
|
||||
{% if subscription_mailto %}
|
||||
{% if subscription_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>
|
||||
<a href="mailto:{{ subscription_mailto.address|safe }}?subject={{ subscription_mailto.subject|safe }}"><code>{{ subscription_mailto.address }}</code></a> with the following subject: <code>{{ subscription_mailto.subject}}</code>
|
||||
</p>
|
||||
{% else %}
|
||||
<p>
|
||||
<a href="mailto:{{ subscribe_mailto.address|safe }}"><code>{{ subscribe_mailto.address }}</code></a>
|
||||
<a href="mailto:{{ subscription_mailto.address|safe }}"><code>{{ subscription_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 %}
|
||||
{% set unsubscription_mailto=list.unsubscription_mailto() %}
|
||||
{% if unsubscription_mailto %}
|
||||
<h2 id="unsubscribe">Unsubscribe</h2>
|
||||
{% if unsubscribe_mailto.subject %}
|
||||
{% if unsubscription_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>
|
||||
<a href="mailto:{{ unsubscription_mailto.address|safe }}?subject={{ unsubscription_mailto.subject|safe }}"><code>{{ unsubscription_mailto.address }}</code></a> with the following subject: <code>{{unsubscription_mailto.subject}}</code>
|
||||
</p>
|
||||
{% else %}
|
||||
<p>
|
||||
<a href="mailto:{{ unsubscribe_mailto.address|safe }}"><code>{{ unsubscribe_mailto.address }}</code></a>
|
||||
<a href="mailto:{{ unsubscription_mailto.address|safe }}"><code>{{ unsubscription_mailto.address }}</code></a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
@ -40,8 +40,8 @@
|
|||
<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>
|
||||
{% elif post_policy.subscription_only %}
|
||||
<p>List is <em>subscription-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>
|
||||
|
|
|
@ -98,8 +98,12 @@ impl Object for MailingList {
|
|||
_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())),
|
||||
"subscription_mailto" => {
|
||||
Ok(Value::from_serializable(&self.inner.subscription_mailto()))
|
||||
}
|
||||
"unsubscription_mailto" => Ok(Value::from_serializable(
|
||||
&self.inner.unsubscription_mailto(),
|
||||
)),
|
||||
_ => Err(Error::new(
|
||||
minijinja::ErrorKind::UnknownMethod,
|
||||
format!("aaaobject has no method named {name}"),
|
||||
|
|
108
cli/src/args.rs
108
cli/src/args.rs
|
@ -133,6 +133,54 @@ pub enum Command {
|
|||
#[clap(flatten)]
|
||||
config: PostfixConfig,
|
||||
},
|
||||
/// All Accounts.
|
||||
Accounts,
|
||||
/// Account info.
|
||||
AccountInfo {
|
||||
/// Account address.
|
||||
address: String,
|
||||
},
|
||||
/// Add account.
|
||||
AddAccount {
|
||||
/// E-mail address.
|
||||
#[arg(long)]
|
||||
address: String,
|
||||
/// SSH public key for authentication.
|
||||
#[arg(long)]
|
||||
password: String,
|
||||
/// Name.
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
/// Public key.
|
||||
#[arg(long)]
|
||||
public_key: Option<String>,
|
||||
#[arg(long)]
|
||||
/// Is account enabled.
|
||||
enabled: Option<bool>,
|
||||
},
|
||||
/// Remove account.
|
||||
RemoveAccount {
|
||||
#[arg(long)]
|
||||
/// E-mail address.
|
||||
address: String,
|
||||
},
|
||||
/// Update account info.
|
||||
UpdateAccount {
|
||||
/// Address to edit.
|
||||
address: String,
|
||||
/// Public key for authentication.
|
||||
#[arg(long)]
|
||||
password: Option<String>,
|
||||
/// Name.
|
||||
#[arg(long)]
|
||||
name: Option<Option<String>>,
|
||||
/// Public key.
|
||||
#[arg(long)]
|
||||
public_key: Option<Option<String>>,
|
||||
#[arg(long)]
|
||||
/// Is account enabled.
|
||||
enabled: Option<Option<bool>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Postfix config values.
|
||||
|
@ -193,9 +241,9 @@ pub enum ErrorQueueCommand {
|
|||
},
|
||||
}
|
||||
|
||||
/// Member options.
|
||||
/// Subscription options.
|
||||
#[derive(Debug, Args)]
|
||||
pub struct MemberOptions {
|
||||
pub struct SubscriptionOptions {
|
||||
/// Name.
|
||||
#[arg(long)]
|
||||
pub name: Option<String>,
|
||||
|
@ -223,30 +271,44 @@ pub struct MemberOptions {
|
|||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
/// Account options.
|
||||
#[derive(Debug, Args)]
|
||||
pub struct AccountOptions {
|
||||
/// Name.
|
||||
#[arg(long)]
|
||||
pub name: Option<String>,
|
||||
/// Public key.
|
||||
#[arg(long)]
|
||||
pub public_key: Option<String>,
|
||||
#[arg(long)]
|
||||
/// Is account enabled.
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum ListCommand {
|
||||
/// List members of list.
|
||||
Members,
|
||||
/// Add member to list.
|
||||
AddMember {
|
||||
/// List subscriptions of list.
|
||||
Subscriptions,
|
||||
/// Add subscription to list.
|
||||
AddSubscription {
|
||||
/// E-mail address.
|
||||
#[arg(long)]
|
||||
address: String,
|
||||
#[clap(flatten)]
|
||||
member_options: MemberOptions,
|
||||
subscription_options: SubscriptionOptions,
|
||||
},
|
||||
/// Remove member from list.
|
||||
RemoveMember {
|
||||
/// Remove subscription from list.
|
||||
RemoveSubscription {
|
||||
#[arg(long)]
|
||||
/// E-mail address.
|
||||
address: String,
|
||||
},
|
||||
/// Update membership info.
|
||||
UpdateMembership {
|
||||
/// Update subscription info.
|
||||
UpdateSubscription {
|
||||
/// Address to edit.
|
||||
address: String,
|
||||
#[clap(flatten)]
|
||||
member_options: MemberOptions,
|
||||
subscription_options: SubscriptionOptions,
|
||||
},
|
||||
/// Add a new post policy.
|
||||
AddPolicy {
|
||||
|
@ -254,10 +316,10 @@ pub enum ListCommand {
|
|||
/// Only list owners can post.
|
||||
announce_only: bool,
|
||||
#[arg(long)]
|
||||
/// Only subscribers can post.
|
||||
subscriber_only: bool,
|
||||
/// Only subscriptions can post.
|
||||
subscription_only: bool,
|
||||
#[arg(long)]
|
||||
/// Subscribers can post.
|
||||
/// Subscriptions can post.
|
||||
/// Other posts must be approved by list owners.
|
||||
approval_needed: bool,
|
||||
#[arg(long)]
|
||||
|
@ -282,7 +344,7 @@ pub enum ListCommand {
|
|||
/// Anyone can subscribe without restrictions.
|
||||
open: bool,
|
||||
#[arg(long)]
|
||||
/// Only list owners can manually add subscribers.
|
||||
/// Only list owners can manually add subscriptions.
|
||||
manual: bool,
|
||||
#[arg(long)]
|
||||
/// Anyone can request to subscribe.
|
||||
|
@ -308,14 +370,14 @@ pub enum ListCommand {
|
|||
/// List owner primary key.
|
||||
pk: i64,
|
||||
},
|
||||
/// Alias for update-membership --enabled true.
|
||||
EnableMembership {
|
||||
/// Member address.
|
||||
/// Alias for update-subscription --enabled true.
|
||||
EnableSubscription {
|
||||
/// Subscription address.
|
||||
address: String,
|
||||
},
|
||||
/// Alias for update-membership --enabled false.
|
||||
DisableMembership {
|
||||
/// Member address.
|
||||
/// Alias for update-subscription --enabled false.
|
||||
DisableSubscription {
|
||||
/// Subscription address.
|
||||
address: String,
|
||||
},
|
||||
/// Update mailing list details.
|
||||
|
@ -345,7 +407,7 @@ pub enum ListCommand {
|
|||
request_local_part: Option<String>,
|
||||
/// Require verification of e-mails for new subscriptions.
|
||||
///
|
||||
/// Subscriptions that are initiated from the member's address are verified automatically.
|
||||
/// Subscriptions that are initiated from the subscription's address are verified automatically.
|
||||
#[arg(long)]
|
||||
verify: Option<bool>,
|
||||
/// Public visibility of list.
|
||||
|
|
208
cli/src/main.rs
208
cli/src/main.rs
|
@ -40,6 +40,17 @@ macro_rules! list {
|
|||
})
|
||||
}};
|
||||
}
|
||||
|
||||
macro_rules! string_opts {
|
||||
($field:ident) => {
|
||||
if $field.as_deref().map(str::is_empty).unwrap_or(false) {
|
||||
None
|
||||
} else {
|
||||
Some($field)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn run_app(opt: Opt) -> Result<()> {
|
||||
if opt.debug {
|
||||
println!("DEBUG: {:?}", &opt);
|
||||
|
@ -58,7 +69,7 @@ fn run_app(opt: Opt) -> Result<()> {
|
|||
let mut stdout = std::io::stdout();
|
||||
serde_json::to_writer_pretty(&mut stdout, &lists)?;
|
||||
for l in &lists {
|
||||
serde_json::to_writer_pretty(&mut stdout, &db.list_members(l.pk)?)?;
|
||||
serde_json::to_writer_pretty(&mut stdout, &db.list_subscriptions(l.pk)?)?;
|
||||
}
|
||||
}
|
||||
ListLists => {
|
||||
|
@ -95,21 +106,21 @@ fn run_app(opt: Opt) -> Result<()> {
|
|||
};
|
||||
use ListCommand::*;
|
||||
match cmd {
|
||||
Members => {
|
||||
let members = db.list_members(list.pk)?;
|
||||
if members.is_empty() {
|
||||
println!("No members found.");
|
||||
Subscriptions => {
|
||||
let subscriptions = db.list_subscriptions(list.pk)?;
|
||||
if subscriptions.is_empty() {
|
||||
println!("No subscriptions found.");
|
||||
} else {
|
||||
println!("Members of list {}", list.id);
|
||||
for l in members {
|
||||
println!("Subscriptions of list {}", list.id);
|
||||
for l in subscriptions {
|
||||
println!("- {}", &l);
|
||||
}
|
||||
}
|
||||
}
|
||||
AddMember {
|
||||
AddSubscription {
|
||||
address,
|
||||
member_options:
|
||||
MemberOptions {
|
||||
subscription_options:
|
||||
SubscriptionOptions {
|
||||
name,
|
||||
digest,
|
||||
hide_address,
|
||||
|
@ -120,13 +131,14 @@ fn run_app(opt: Opt) -> Result<()> {
|
|||
verified,
|
||||
},
|
||||
} => {
|
||||
db.add_member(
|
||||
db.add_subscription(
|
||||
list.pk,
|
||||
ListMembership {
|
||||
ListSubscription {
|
||||
pk: 0,
|
||||
list: list.pk,
|
||||
name,
|
||||
address,
|
||||
account: None,
|
||||
name,
|
||||
digest: digest.unwrap_or(false),
|
||||
hide_address: hide_address.unwrap_or(false),
|
||||
receive_confirmation: receive_confirmation.unwrap_or(true),
|
||||
|
@ -137,13 +149,14 @@ fn run_app(opt: Opt) -> Result<()> {
|
|||
},
|
||||
)?;
|
||||
}
|
||||
RemoveMember { address } => {
|
||||
RemoveSubscription { address } => {
|
||||
let mut input = String::new();
|
||||
loop {
|
||||
println!(
|
||||
"Are you sure you want to remove membership of {} from list {}? [Yy/n]",
|
||||
"Are you sure you want to remove subscription of {} from list {}? [Yy/n]",
|
||||
address, list
|
||||
);
|
||||
let mut input = String::new();
|
||||
input.clear();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
if input.trim() == "Y" || input.trim() == "y" || input.trim() == "" {
|
||||
break;
|
||||
|
@ -152,12 +165,13 @@ fn run_app(opt: Opt) -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
db.remove_membership(list.pk, &address)?;
|
||||
db.remove_subscription(list.pk, &address)?;
|
||||
}
|
||||
Health => {
|
||||
println!("{} health:", list);
|
||||
let list_owners = db.list_owners(list.pk)?;
|
||||
let list_policy = db.list_policy(list.pk)?;
|
||||
let post_policy = db.list_policy(list.pk)?;
|
||||
let subscription_policy = db.list_subscription_policy(list.pk)?;
|
||||
if list_owners.is_empty() {
|
||||
println!("\tList has no owners: you should add at least one.");
|
||||
} else {
|
||||
|
@ -165,23 +179,29 @@ fn run_app(opt: Opt) -> Result<()> {
|
|||
println!("\tList owner: {}.", owner);
|
||||
}
|
||||
}
|
||||
if let Some(list_policy) = list_policy {
|
||||
println!("\tList has post policy: {}.", list_policy);
|
||||
if let Some(p) = post_policy {
|
||||
println!("\tList has post policy: {p}.");
|
||||
} else {
|
||||
println!("\tList has no post policy: you should add one.");
|
||||
}
|
||||
if let Some(p) = subscription_policy {
|
||||
println!("\tList has subscription policy: {p}.");
|
||||
} else {
|
||||
println!("\tList has no subscription policy: you should add one.");
|
||||
}
|
||||
}
|
||||
Info => {
|
||||
println!("{} info:", list);
|
||||
let list_owners = db.list_owners(list.pk)?;
|
||||
let list_policy = db.list_policy(list.pk)?;
|
||||
let members = db.list_members(list.pk)?;
|
||||
if members.is_empty() {
|
||||
println!("No members.");
|
||||
} else if members.len() == 1 {
|
||||
println!("1 member.");
|
||||
let post_policy = db.list_policy(list.pk)?;
|
||||
let subscription_policy = db.list_subscription_policy(list.pk)?;
|
||||
let subscriptions = db.list_subscriptions(list.pk)?;
|
||||
if subscriptions.is_empty() {
|
||||
println!("No subscriptions.");
|
||||
} else if subscriptions.len() == 1 {
|
||||
println!("1 subscription.");
|
||||
} else {
|
||||
println!("{} members.", members.len());
|
||||
println!("{} subscriptions.", subscriptions.len());
|
||||
}
|
||||
if list_owners.is_empty() {
|
||||
println!("List owners: None");
|
||||
|
@ -191,16 +211,21 @@ fn run_app(opt: Opt) -> Result<()> {
|
|||
println!("\t- {}", o);
|
||||
}
|
||||
}
|
||||
if let Some(s) = list_policy {
|
||||
println!("List policy: {}", s);
|
||||
if let Some(s) = post_policy {
|
||||
println!("Post policy: {s}");
|
||||
} else {
|
||||
println!("List policy: None");
|
||||
println!("Post policy: None");
|
||||
}
|
||||
if let Some(s) = subscription_policy {
|
||||
println!("Subscription policy: {s}");
|
||||
} else {
|
||||
println!("Subscription policy: None");
|
||||
}
|
||||
}
|
||||
UpdateMembership {
|
||||
UpdateSubscription {
|
||||
address,
|
||||
member_options:
|
||||
MemberOptions {
|
||||
subscription_options:
|
||||
SubscriptionOptions {
|
||||
name,
|
||||
digest,
|
||||
hide_address,
|
||||
|
@ -220,9 +245,10 @@ fn run_app(opt: Opt) -> Result<()> {
|
|||
} else {
|
||||
Some(name)
|
||||
};
|
||||
let changeset = ListMembershipChangeset {
|
||||
let changeset = ListSubscriptionChangeset {
|
||||
list: list.pk,
|
||||
address,
|
||||
account: None,
|
||||
name,
|
||||
digest,
|
||||
verified,
|
||||
|
@ -232,11 +258,11 @@ fn run_app(opt: Opt) -> Result<()> {
|
|||
receive_confirmation,
|
||||
enabled,
|
||||
};
|
||||
db.update_member(changeset)?;
|
||||
db.update_subscription(changeset)?;
|
||||
}
|
||||
AddPolicy {
|
||||
announce_only,
|
||||
subscriber_only,
|
||||
subscription_only,
|
||||
approval_needed,
|
||||
open,
|
||||
custom,
|
||||
|
@ -245,7 +271,7 @@ fn run_app(opt: Opt) -> Result<()> {
|
|||
pk: 0,
|
||||
list: list.pk,
|
||||
announce_only,
|
||||
subscriber_only,
|
||||
subscription_only,
|
||||
approval_needed,
|
||||
open,
|
||||
custom,
|
||||
|
@ -264,7 +290,7 @@ fn run_app(opt: Opt) -> Result<()> {
|
|||
request,
|
||||
custom,
|
||||
} => {
|
||||
let policy = SubscribePolicy {
|
||||
let policy = SubscriptionPolicy {
|
||||
pk: 0,
|
||||
list: list.pk,
|
||||
send_confirmation,
|
||||
|
@ -273,11 +299,11 @@ fn run_app(opt: Opt) -> Result<()> {
|
|||
request,
|
||||
custom,
|
||||
};
|
||||
let new_val = db.set_list_subscribe_policy(policy)?;
|
||||
let new_val = db.set_list_subscription_policy(policy)?;
|
||||
println!("Added new subscribe policy with pk = {}", new_val.pk());
|
||||
}
|
||||
RemoveSubscribePolicy { pk } => {
|
||||
db.remove_list_subscribe_policy(list.pk, pk)?;
|
||||
db.remove_list_subscription_policy(list.pk, pk)?;
|
||||
println!("Removed subscribe policy with pk = {}", pk);
|
||||
}
|
||||
AddListOwner { address, name } => {
|
||||
|
@ -294,10 +320,11 @@ fn run_app(opt: Opt) -> Result<()> {
|
|||
db.remove_list_owner(list.pk, pk)?;
|
||||
println!("Removed list owner with pk = {}", pk);
|
||||
}
|
||||
EnableMembership { address } => {
|
||||
let changeset = ListMembershipChangeset {
|
||||
EnableSubscription { address } => {
|
||||
let changeset = ListSubscriptionChangeset {
|
||||
list: list.pk,
|
||||
address,
|
||||
account: None,
|
||||
name: None,
|
||||
digest: None,
|
||||
verified: None,
|
||||
|
@ -307,12 +334,13 @@ fn run_app(opt: Opt) -> Result<()> {
|
|||
receive_own_posts: None,
|
||||
receive_confirmation: None,
|
||||
};
|
||||
db.update_member(changeset)?;
|
||||
db.update_subscription(changeset)?;
|
||||
}
|
||||
DisableMembership { address } => {
|
||||
let changeset = ListMembershipChangeset {
|
||||
DisableSubscription { address } => {
|
||||
let changeset = ListSubscriptionChangeset {
|
||||
list: list.pk,
|
||||
address,
|
||||
account: None,
|
||||
name: None,
|
||||
digest: None,
|
||||
enabled: Some(false),
|
||||
|
@ -322,7 +350,7 @@ fn run_app(opt: Opt) -> Result<()> {
|
|||
receive_own_posts: None,
|
||||
receive_confirmation: None,
|
||||
};
|
||||
db.update_member(changeset)?;
|
||||
db.update_subscription(changeset)?;
|
||||
}
|
||||
Update {
|
||||
name,
|
||||
|
@ -336,19 +364,6 @@ fn run_app(opt: Opt) -> Result<()> {
|
|||
hidden,
|
||||
enabled,
|
||||
} => {
|
||||
macro_rules! string_opts {
|
||||
($field:ident) => {
|
||||
if $field
|
||||
.as_ref()
|
||||
.map(|s: &String| s.is_empty())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some($field)
|
||||
}
|
||||
};
|
||||
}
|
||||
let description = string_opts!(description);
|
||||
let archive_url = string_opts!(archive_url);
|
||||
let owner_local_part = string_opts!(owner_local_part);
|
||||
|
@ -575,6 +590,81 @@ fn run_app(opt: Opt) -> Result<()> {
|
|||
|
||||
println!("{maps}\n\n{mastercf}\n");
|
||||
}
|
||||
Accounts => {
|
||||
let accounts = db.accounts()?;
|
||||
if accounts.is_empty() {
|
||||
println!("No accounts found.");
|
||||
} else {
|
||||
for a in accounts {
|
||||
println!("- {:?}", a);
|
||||
}
|
||||
}
|
||||
}
|
||||
AccountInfo { address } => {
|
||||
if let Some(acc) = db.account_by_address(&address)? {
|
||||
let subs = db.account_subscriptions(acc.pk())?;
|
||||
if subs.is_empty() {
|
||||
println!("No subscriptions found.");
|
||||
} else {
|
||||
for s in subs {
|
||||
let list = db.list(s.list).unwrap_or_else(|err| panic!("Found subscription with list_pk = {} but no such list exists.\nListSubscription = {:?}\n\n{err}", s.list, s));
|
||||
println!("- {:?} {}", s, list);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("account with this address not found!");
|
||||
};
|
||||
}
|
||||
AddAccount {
|
||||
address,
|
||||
password,
|
||||
name,
|
||||
public_key,
|
||||
enabled,
|
||||
} => {
|
||||
db.add_account(Account {
|
||||
pk: 0,
|
||||
name,
|
||||
address,
|
||||
public_key,
|
||||
password,
|
||||
enabled: enabled.unwrap_or(true),
|
||||
})?;
|
||||
}
|
||||
RemoveAccount { address } => {
|
||||
let mut input = String::new();
|
||||
loop {
|
||||
println!(
|
||||
"Are you sure you want to remove account with address {}? [Yy/n]",
|
||||
address
|
||||
);
|
||||
input.clear();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
if input.trim() == "Y" || input.trim() == "y" || input.trim() == "" {
|
||||
break;
|
||||
} else if input.trim() == "n" {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
db.remove_account(&address)?;
|
||||
}
|
||||
UpdateAccount {
|
||||
address,
|
||||
password,
|
||||
name,
|
||||
public_key,
|
||||
enabled,
|
||||
} => {
|
||||
let changeset = AccountChangeset {
|
||||
address,
|
||||
name,
|
||||
public_key,
|
||||
password,
|
||||
enabled,
|
||||
};
|
||||
db.update_account(changeset)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
|
@ -36,12 +36,20 @@ 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;
|
||||
pub use posts::*;
|
||||
mod members;
|
||||
pub use members::*;
|
||||
mod subscriptions;
|
||||
pub use subscriptions::*;
|
||||
mod policies;
|
||||
pub use policies::*;
|
||||
|
||||
|
@ -52,7 +60,7 @@ fn log_callback(error_code: std::ffi::c_int, message: &str) {
|
|||
_ => log::error!("{error_code} {}", message),
|
||||
}
|
||||
}
|
||||
|
||||
// INSERT INTO subscription(list, address, name, enabled, digest, verified, hide_address, receive_duplicates, receive_own_posts, receive_confirmation) VALUES
|
||||
fn user_authorizer_callback(
|
||||
auth_context: rusqlite::hooks::AuthContext<'_>,
|
||||
) -> rusqlite::hooks::Authorization {
|
||||
|
@ -61,15 +69,31 @@ fn user_authorizer_callback(
|
|||
// [ref:sync_auth_doc] sync with `untrusted()` rustdoc when changing this.
|
||||
match auth_context.action {
|
||||
AuthAction::Delete {
|
||||
table_name: "queue" | "candidate_membership" | "membership",
|
||||
table_name: "queue" | "candidate_subscription" | "subscription",
|
||||
}
|
||||
| AuthAction::Insert {
|
||||
table_name: "post" | "queue" | "candidate_membership" | "membership",
|
||||
table_name: "post" | "queue" | "candidate_subscription" | "subscription",
|
||||
}
|
||||
| AuthAction::Update {
|
||||
table_name: "candidate_membership" | "membership" | "account" | "templates",
|
||||
table_name: "candidate_subscription" | "templates",
|
||||
column_name: "accepted" | "last_modified" | "verified" | "address",
|
||||
}
|
||||
| AuthAction::Update {
|
||||
table_name: "account",
|
||||
column_name: "last_modified" | "name" | "public_key" | "password",
|
||||
}
|
||||
| AuthAction::Update {
|
||||
table_name: "subscription",
|
||||
column_name:
|
||||
"last_modified"
|
||||
| "account"
|
||||
| "digest"
|
||||
| "verified"
|
||||
| "hide_address"
|
||||
| "receive_duplicates"
|
||||
| "receive_own_posts"
|
||||
| "receive_confirmation",
|
||||
}
|
||||
| AuthAction::Select
|
||||
| AuthAction::Savepoint { .. }
|
||||
| AuthAction::Transaction { .. }
|
||||
|
@ -126,7 +150,8 @@ impl Connection {
|
|||
// [tag:sync_auth_doc]
|
||||
/// Sets operational limits for this connection.
|
||||
///
|
||||
/// - Allow `INSERT`, `DELETE` only for "queue", "candidate_membership", "membership".
|
||||
/// - Allow `INSERT`, `DELETE` only for "queue", "candidate_subscription", "subscription".
|
||||
/// - Allow `UPDATE` only for "subscription" user facing settings.
|
||||
/// - Allow `INSERT` only for "post".
|
||||
/// - Allow read access to all tables.
|
||||
/// - Allow `SELECT`, `TRANSACTION`, `SAVEPOINT`, and the `strftime` function.
|
||||
|
|
|
@ -1,309 +0,0 @@
|
|||
/*
|
||||
* 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::*;
|
||||
|
||||
impl Connection {
|
||||
/// Fetch all members of a mailing list.
|
||||
pub fn list_members(&self, pk: i64) -> Result<Vec<DbVal<ListMembership>>> {
|
||||
let mut stmt = self
|
||||
.connection
|
||||
.prepare("SELECT * FROM membership WHERE list = ?;")?;
|
||||
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)
|
||||
}
|
||||
|
||||
/// Fetch mailing list member.
|
||||
pub fn list_member(&self, list_pk: i64, pk: i64) -> Result<DbVal<ListMembership>> {
|
||||
let mut stmt = self
|
||||
.connection
|
||||
.prepare("SELECT * FROM membership WHERE list = ? AND pk = ?;")?;
|
||||
|
||||
let ret = stmt.query_row([&list_pk, &pk], |row| {
|
||||
let _pk: i64 = row.get("pk")?;
|
||||
debug_assert_eq!(pk, _pk);
|
||||
Ok(DbVal(
|
||||
ListMembership {
|
||||
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,
|
||||
))
|
||||
})?;
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
/// Fetch mailing list member by their address.
|
||||
pub fn list_member_by_address(
|
||||
&self,
|
||||
list_pk: i64,
|
||||
address: &str,
|
||||
) -> Result<DbVal<ListMembership>> {
|
||||
let mut stmt = self
|
||||
.connection
|
||||
.prepare("SELECT * FROM membership WHERE list = ? AND address = ?;")?;
|
||||
|
||||
let ret = stmt.query_row(rusqlite::params![&list_pk, &address], |row| {
|
||||
let pk = row.get("pk")?;
|
||||
let address_ = row.get("address")?;
|
||||
debug_assert_eq!(address, &address_);
|
||||
Ok(DbVal(
|
||||
ListMembership {
|
||||
pk,
|
||||
list: row.get("list")?,
|
||||
address: 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,
|
||||
))
|
||||
})?;
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
/// Add member to mailing list.
|
||||
pub fn add_member(
|
||||
&self,
|
||||
list_pk: i64,
|
||||
mut new_val: ListMembership,
|
||||
) -> Result<DbVal<ListMembership>> {
|
||||
new_val.list = list_pk;
|
||||
let mut stmt = self
|
||||
.connection
|
||||
.prepare("INSERT INTO membership(list, address, name, enabled, digest, verified, hide_address, receive_duplicates, receive_own_posts, receive_confirmation) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *;").unwrap();
|
||||
let ret = stmt.query_row(
|
||||
rusqlite::params![
|
||||
&new_val.list,
|
||||
&new_val.address,
|
||||
&new_val.name,
|
||||
&new_val.enabled,
|
||||
&new_val.digest,
|
||||
&new_val.verified,
|
||||
&new_val.hide_address,
|
||||
&new_val.receive_duplicates,
|
||||
&new_val.receive_own_posts,
|
||||
&new_val.receive_confirmation
|
||||
],
|
||||
|row| {
|
||||
let pk = row.get("pk")?;
|
||||
Ok(DbVal(
|
||||
ListMembership {
|
||||
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,
|
||||
))
|
||||
},
|
||||
)?;
|
||||
|
||||
trace!("add_member {:?}.", &ret);
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
/// Create membership candidate.
|
||||
pub fn add_candidate_member(
|
||||
&mut self,
|
||||
list_pk: i64,
|
||||
mut new_val: ListMembership,
|
||||
) -> Result<i64> {
|
||||
new_val.list = list_pk;
|
||||
let mut stmt = self
|
||||
.connection
|
||||
.prepare("INSERT INTO candidate_membership(list, address, name, accepted) VALUES(?, ?, ?, ?) RETURNING pk;")?;
|
||||
let ret = stmt.query_row(
|
||||
rusqlite::params![&new_val.list, &new_val.address, &new_val.name, None::<i64>,],
|
||||
|row| {
|
||||
let pk: i64 = row.get("pk")?;
|
||||
Ok(pk)
|
||||
},
|
||||
)?;
|
||||
drop(stmt);
|
||||
|
||||
trace!("add_candidate_member {:?}.", &ret);
|
||||
self.accept_candidate_member(ret)?;
|
||||
// [ref:FIXME]: add approval required option for subscriptions.
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
/// Accept membership candidate.
|
||||
pub fn accept_candidate_member(&mut self, pk: i64) -> Result<DbVal<ListMembership>> {
|
||||
let tx = self.connection.transaction()?;
|
||||
let mut stmt = tx
|
||||
.prepare("INSERT INTO membership(list, address, name, enabled, digest, verified, hide_address, receive_duplicates, receive_own_posts, receive_confirmation) SELECT list, address, name, 1, 0, 0, 0, 1, 1, 0 FROM candidate_membership WHERE pk = ? RETURNING *;")?;
|
||||
let ret = stmt.query_row(rusqlite::params![&pk], |row| {
|
||||
let pk = row.get("pk")?;
|
||||
Ok(DbVal(
|
||||
ListMembership {
|
||||
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,
|
||||
))
|
||||
})?;
|
||||
drop(stmt);
|
||||
tx.execute(
|
||||
"UPDATE candidate_membership SET accepted = ? WHERE pk = ?;",
|
||||
[&ret.pk, &pk],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
|
||||
trace!("accept_candidate_member {:?}.", &ret);
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
/// Remove a member by their address.
|
||||
pub fn remove_membership(&self, list_pk: i64, address: &str) -> Result<()> {
|
||||
self.connection
|
||||
.query_row(
|
||||
"DELETE FROM membership WHERE list_pk = ? AND address = ? RETURNING *;",
|
||||
rusqlite::params![&list_pk, &address],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.map_err(|err| {
|
||||
if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
|
||||
Error::from(err).chain_err(|| NotFound("list or list owner not found!"))
|
||||
} else {
|
||||
err.into()
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update a mailing list membership.
|
||||
pub fn update_member(&mut self, change_set: ListMembershipChangeset) -> Result<()> {
|
||||
let pk = self
|
||||
.list_member_by_address(change_set.list, &change_set.address)?
|
||||
.pk;
|
||||
if matches!(
|
||||
change_set,
|
||||
ListMembershipChangeset {
|
||||
list: _,
|
||||
address: _,
|
||||
name: None,
|
||||
digest: None,
|
||||
verified: None,
|
||||
hide_address: None,
|
||||
receive_duplicates: None,
|
||||
receive_own_posts: None,
|
||||
receive_confirmation: None,
|
||||
enabled: None,
|
||||
}
|
||||
) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let ListMembershipChangeset {
|
||||
list,
|
||||
address: _,
|
||||
name,
|
||||
digest,
|
||||
enabled,
|
||||
verified,
|
||||
hide_address,
|
||||
receive_duplicates,
|
||||
receive_own_posts,
|
||||
receive_confirmation,
|
||||
} = change_set;
|
||||
let tx = self.connection.transaction()?;
|
||||
|
||||
macro_rules! update {
|
||||
($field:tt) => {{
|
||||
if let Some($field) = $field {
|
||||
tx.execute(
|
||||
concat!(
|
||||
"UPDATE membership SET ",
|
||||
stringify!($field),
|
||||
" = ? WHERE list = ? AND pk = ?;"
|
||||
),
|
||||
rusqlite::params![&$field, &list, &pk],
|
||||
)?;
|
||||
}
|
||||
}};
|
||||
}
|
||||
update!(name);
|
||||
update!(digest);
|
||||
update!(enabled);
|
||||
update!(verified);
|
||||
update!(hide_address);
|
||||
update!(receive_duplicates);
|
||||
update!(receive_own_posts);
|
||||
update!(receive_confirmation);
|
||||
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -20,7 +20,7 @@
|
|||
use super::*;
|
||||
|
||||
pub use post_policy::*;
|
||||
pub use subscribe_policy::*;
|
||||
pub use subscription_policy::*;
|
||||
mod post_policy {
|
||||
use super::*;
|
||||
|
||||
|
@ -38,7 +38,7 @@ mod post_policy {
|
|||
pk,
|
||||
list: row.get("list")?,
|
||||
announce_only: row.get("announce_only")?,
|
||||
subscriber_only: row.get("subscriber_only")?,
|
||||
subscription_only: row.get("subscription_only")?,
|
||||
approval_needed: row.get("approval_needed")?,
|
||||
open: row.get("open")?,
|
||||
custom: row.get("custom")?,
|
||||
|
@ -80,7 +80,7 @@ mod post_policy {
|
|||
/// pk: 0,
|
||||
/// list: list_pk,
|
||||
/// announce_only: false,
|
||||
/// subscriber_only: true,
|
||||
/// subscription_only: true,
|
||||
/// approval_needed: false,
|
||||
/// open: false,
|
||||
/// custom: false,
|
||||
|
@ -131,7 +131,7 @@ mod post_policy {
|
|||
/// Set the unique post policy for a list.
|
||||
pub fn set_list_policy(&self, policy: PostPolicy) -> Result<DbVal<PostPolicy>> {
|
||||
if !(policy.announce_only
|
||||
|| policy.subscriber_only
|
||||
|| policy.subscription_only
|
||||
|| policy.approval_needed
|
||||
|| policy.open
|
||||
|| policy.custom)
|
||||
|
@ -143,13 +143,13 @@ mod post_policy {
|
|||
}
|
||||
let list_pk = policy.list;
|
||||
|
||||
let mut stmt = self.connection.prepare("INSERT OR REPLACE INTO post_policy(list, announce_only, subscriber_only, approval_needed, open, custom) VALUES (?, ?, ?, ?, ?, ?) RETURNING *;")?;
|
||||
let mut stmt = self.connection.prepare("INSERT OR REPLACE INTO post_policy(list, announce_only, subscription_only, approval_needed, open, custom) VALUES (?, ?, ?, ?, ?, ?) RETURNING *;")?;
|
||||
let ret = stmt
|
||||
.query_row(
|
||||
rusqlite::params![
|
||||
&list_pk,
|
||||
&policy.announce_only,
|
||||
&policy.subscriber_only,
|
||||
&policy.subscription_only,
|
||||
&policy.approval_needed,
|
||||
&policy.open,
|
||||
&policy.custom,
|
||||
|
@ -161,7 +161,7 @@ mod post_policy {
|
|||
pk,
|
||||
list: row.get("list")?,
|
||||
announce_only: row.get("announce_only")?,
|
||||
subscriber_only: row.get("subscriber_only")?,
|
||||
subscription_only: row.get("subscription_only")?,
|
||||
approval_needed: row.get("approval_needed")?,
|
||||
open: row.get("open")?,
|
||||
custom: row.get("custom")?,
|
||||
|
@ -194,20 +194,23 @@ mod post_policy {
|
|||
}
|
||||
}
|
||||
|
||||
mod subscribe_policy {
|
||||
mod subscription_policy {
|
||||
use super::*;
|
||||
|
||||
impl Connection {
|
||||
/// Fetch the subscribe policy of a mailing list.
|
||||
pub fn list_subscrbe_policy(&self, pk: i64) -> Result<Option<DbVal<SubscribePolicy>>> {
|
||||
/// Fetch the subscription policy of a mailing list.
|
||||
pub fn list_subscription_policy(
|
||||
&self,
|
||||
pk: i64,
|
||||
) -> Result<Option<DbVal<SubscriptionPolicy>>> {
|
||||
let mut stmt = self
|
||||
.connection
|
||||
.prepare("SELECT * FROM subscribe_policy WHERE list = ?;")?;
|
||||
.prepare("SELECT * FROM subscription_policy WHERE list = ?;")?;
|
||||
let ret = stmt
|
||||
.query_row([&pk], |row| {
|
||||
let pk = row.get("pk")?;
|
||||
Ok(DbVal(
|
||||
SubscribePolicy {
|
||||
SubscriptionPolicy {
|
||||
pk,
|
||||
list: row.get("list")?,
|
||||
send_confirmation: row.get("send_confirmation")?,
|
||||
|
@ -253,7 +256,7 @@ mod subscribe_policy {
|
|||
/// pk: 0,
|
||||
/// list: list_pk,
|
||||
/// announce_only: false,
|
||||
/// subscriber_only: true,
|
||||
/// subscription_only: true,
|
||||
/// approval_needed: false,
|
||||
/// open: false,
|
||||
/// custom: false,
|
||||
|
@ -263,10 +266,10 @@ mod subscribe_policy {
|
|||
/// # }
|
||||
/// # do_test(config);
|
||||
/// ```
|
||||
pub fn remove_list_subscribe_policy(&self, list_pk: i64, policy_pk: i64) -> Result<()> {
|
||||
let mut stmt = self
|
||||
.connection
|
||||
.prepare("DELETE FROM subscribe_policy WHERE pk = ? AND list = ? RETURNING *;")?;
|
||||
pub fn remove_list_subscription_policy(&self, list_pk: i64, policy_pk: i64) -> Result<()> {
|
||||
let mut stmt = self.connection.prepare(
|
||||
"DELETE FROM subscription_policy WHERE pk = ? AND list = ? RETURNING *;",
|
||||
)?;
|
||||
stmt.query_row(rusqlite::params![&policy_pk, &list_pk,], |_| Ok(()))
|
||||
.map_err(|err| {
|
||||
if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
|
||||
|
@ -276,7 +279,7 @@ mod subscribe_policy {
|
|||
}
|
||||
})?;
|
||||
|
||||
trace!("remove_list_subscribe_policy {} {}.", list_pk, policy_pk);
|
||||
trace!("remove_list_subscription_policy {} {}.", list_pk, policy_pk);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -299,13 +302,13 @@ mod subscribe_policy {
|
|||
/// # do_test(config);
|
||||
/// ```
|
||||
#[cfg(doc)]
|
||||
pub fn remove_list_subscribe_policy_panic() {}
|
||||
pub fn remove_list_subscription_policy_panic() {}
|
||||
|
||||
/// Set the unique post policy for a list.
|
||||
pub fn set_list_subscribe_policy(
|
||||
pub fn set_list_subscription_policy(
|
||||
&self,
|
||||
policy: SubscribePolicy,
|
||||
) -> Result<DbVal<SubscribePolicy>> {
|
||||
policy: SubscriptionPolicy,
|
||||
) -> Result<DbVal<SubscriptionPolicy>> {
|
||||
if !(policy.open || policy.manual || policy.request || policy.custom) {
|
||||
return Err(
|
||||
"Cannot add empty policy. Having no policy is probably what you want to do."
|
||||
|
@ -314,7 +317,7 @@ mod subscribe_policy {
|
|||
}
|
||||
let list_pk = policy.list;
|
||||
|
||||
let mut stmt = self.connection.prepare("INSERT OR REPLACE INTO subscribe_policy(list, send_confirmation, open, manual, request, custom) VALUES (?, ?, ?, ?, ?, ?) RETURNING *;")?;
|
||||
let mut stmt = self.connection.prepare("INSERT OR REPLACE INTO subscription_policy(list, send_confirmation, open, manual, request, custom) VALUES (?, ?, ?, ?, ?, ?) RETURNING *;")?;
|
||||
let ret = stmt
|
||||
.query_row(
|
||||
rusqlite::params![
|
||||
|
@ -328,7 +331,7 @@ mod subscribe_policy {
|
|||
|row| {
|
||||
let pk = row.get("pk")?;
|
||||
Ok(DbVal(
|
||||
SubscribePolicy {
|
||||
SubscriptionPolicy {
|
||||
pk,
|
||||
list: row.get("list")?,
|
||||
send_confirmation: row.get("send_confirmation")?,
|
||||
|
@ -359,7 +362,7 @@ mod subscribe_policy {
|
|||
}
|
||||
})?;
|
||||
|
||||
trace!("set_list_subscribe_policy {:?}.", &ret);
|
||||
trace!("set_list_subscription_policy {:?}.", &ret);
|
||||
Ok(ret)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -150,14 +150,14 @@ impl Connection {
|
|||
for mut list in lists {
|
||||
trace!("Examining list {}", list.display_name());
|
||||
let filters = self.list_filters(&list);
|
||||
let memberships = self.list_members(list.pk)?;
|
||||
let subscriptions = self.list_subscriptions(list.pk)?;
|
||||
let owners = self.list_owners(list.pk)?;
|
||||
trace!("List members {:#?}", &memberships);
|
||||
trace!("List subscriptions {:#?}", &subscriptions);
|
||||
let mut list_ctx = ListContext {
|
||||
policy: self.list_policy(list.pk)?,
|
||||
list_owners: &owners,
|
||||
list: &mut list,
|
||||
memberships: &memberships,
|
||||
subscriptions: &subscriptions,
|
||||
scheduled_jobs: vec![],
|
||||
};
|
||||
let mut post = Post {
|
||||
|
@ -252,10 +252,11 @@ impl Connection {
|
|||
.map(|p| p.approval_needed)
|
||||
.unwrap_or(false);
|
||||
for f in env.from() {
|
||||
let membership = ListMembership {
|
||||
let subscription = ListSubscription {
|
||||
pk: 0,
|
||||
list: list.pk,
|
||||
address: f.get_email(),
|
||||
account: None,
|
||||
name: f.get_display_name(),
|
||||
digest: false,
|
||||
hide_address: false,
|
||||
|
@ -266,12 +267,12 @@ impl Connection {
|
|||
verified: true,
|
||||
};
|
||||
if approval_needed {
|
||||
match self.add_candidate_member(list.pk, membership) {
|
||||
match self.add_candidate_subscription(list.pk, subscription) {
|
||||
Ok(_) => {}
|
||||
Err(_err) => {}
|
||||
}
|
||||
//FIXME: send notification to list-owner
|
||||
} else if let Err(_err) = self.add_member(list.pk, membership) {
|
||||
} else if let Err(_err) = self.add_subscription(list.pk, subscription) {
|
||||
//FIXME: send failure notice to f
|
||||
} else {
|
||||
//FIXME: send success notice
|
||||
|
@ -285,7 +286,7 @@ impl Connection {
|
|||
list
|
||||
);
|
||||
for f in env.from() {
|
||||
if let Err(_err) = self.remove_membership(list.pk, &f.get_email()) {
|
||||
if let Err(_err) = self.remove_subscription(list.pk, &f.get_email()) {
|
||||
//FIXME: send failure notice to f
|
||||
} else {
|
||||
//FIXME: send success notice to f
|
||||
|
|
|
@ -0,0 +1,534 @@
|
|||
/*
|
||||
* 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::*;
|
||||
|
||||
impl Connection {
|
||||
/// Fetch all subscriptions of a mailing list.
|
||||
pub fn list_subscriptions(&self, pk: i64) -> Result<Vec<DbVal<ListSubscription>>> {
|
||||
let mut stmt = self
|
||||
.connection
|
||||
.prepare("SELECT * FROM subscription WHERE list = ?;")?;
|
||||
let list_iter = stmt.query_map([&pk], |row| {
|
||||
let pk = row.get("pk")?;
|
||||
Ok(DbVal(
|
||||
ListSubscription {
|
||||
pk: row.get("pk")?,
|
||||
list: row.get("list")?,
|
||||
address: row.get("address")?,
|
||||
account: row.get("account")?,
|
||||
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)
|
||||
}
|
||||
|
||||
/// Fetch mailing list subscription.
|
||||
pub fn list_subscription(&self, list_pk: i64, pk: i64) -> Result<DbVal<ListSubscription>> {
|
||||
let mut stmt = self
|
||||
.connection
|
||||
.prepare("SELECT * FROM subscription WHERE list = ? AND pk = ?;")?;
|
||||
|
||||
let ret = stmt.query_row([&list_pk, &pk], |row| {
|
||||
let _pk: i64 = row.get("pk")?;
|
||||
debug_assert_eq!(pk, _pk);
|
||||
Ok(DbVal(
|
||||
ListSubscription {
|
||||
pk,
|
||||
list: row.get("list")?,
|
||||
address: row.get("address")?,
|
||||
account: row.get("account")?,
|
||||
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,
|
||||
))
|
||||
})?;
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
/// Fetch mailing list subscription by their address.
|
||||
pub fn list_subscription_by_address(
|
||||
&self,
|
||||
list_pk: i64,
|
||||
address: &str,
|
||||
) -> Result<DbVal<ListSubscription>> {
|
||||
let mut stmt = self
|
||||
.connection
|
||||
.prepare("SELECT * FROM subscription WHERE list = ? AND address = ?;")?;
|
||||
|
||||
let ret = stmt.query_row(rusqlite::params![&list_pk, &address], |row| {
|
||||
let pk = row.get("pk")?;
|
||||
let address_ = row.get("address")?;
|
||||
debug_assert_eq!(address, &address_);
|
||||
Ok(DbVal(
|
||||
ListSubscription {
|
||||
pk,
|
||||
list: row.get("list")?,
|
||||
address: address_,
|
||||
account: row.get("account")?,
|
||||
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,
|
||||
))
|
||||
})?;
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
/// Add subscription to mailing list.
|
||||
pub fn add_subscription(
|
||||
&self,
|
||||
list_pk: i64,
|
||||
mut new_val: ListSubscription,
|
||||
) -> Result<DbVal<ListSubscription>> {
|
||||
new_val.list = list_pk;
|
||||
let mut stmt = self
|
||||
.connection
|
||||
.prepare("INSERT INTO subscription(list, address, account, name, enabled, digest, verified, hide_address, receive_duplicates, receive_own_posts, receive_confirmation) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *;").unwrap();
|
||||
let ret = stmt.query_row(
|
||||
rusqlite::params![
|
||||
&new_val.list,
|
||||
&new_val.address,
|
||||
&new_val.account,
|
||||
&new_val.name,
|
||||
&new_val.enabled,
|
||||
&new_val.digest,
|
||||
&new_val.verified,
|
||||
&new_val.hide_address,
|
||||
&new_val.receive_duplicates,
|
||||
&new_val.receive_own_posts,
|
||||
&new_val.receive_confirmation
|
||||
],
|
||||
|row| {
|
||||
let pk = row.get("pk")?;
|
||||
Ok(DbVal(
|
||||
ListSubscription {
|
||||
pk,
|
||||
list: row.get("list")?,
|
||||
address: row.get("address")?,
|
||||
name: row.get("name")?,
|
||||
account: row.get("account")?,
|
||||
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,
|
||||
))
|
||||
},
|
||||
)?;
|
||||
|
||||
trace!("add_subscription {:?}.", &ret);
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
/// Create subscription candidate.
|
||||
pub fn add_candidate_subscription(
|
||||
&mut self,
|
||||
list_pk: i64,
|
||||
mut new_val: ListSubscription,
|
||||
) -> Result<i64> {
|
||||
new_val.list = list_pk;
|
||||
let mut stmt = self
|
||||
.connection
|
||||
.prepare("INSERT INTO candidate_subscription(list, address, name, accepted) VALUES(?, ?, ?, ?) RETURNING pk;")?;
|
||||
let ret = stmt.query_row(
|
||||
rusqlite::params![&new_val.list, &new_val.address, &new_val.name, None::<i64>,],
|
||||
|row| {
|
||||
let pk: i64 = row.get("pk")?;
|
||||
Ok(pk)
|
||||
},
|
||||
)?;
|
||||
drop(stmt);
|
||||
|
||||
trace!("add_candidate_subscription {:?}.", &ret);
|
||||
self.accept_candidate_subscription(ret)?;
|
||||
// [ref:FIXME]: add approval required option for subscriptions.
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
/// Accept subscription candidate.
|
||||
pub fn accept_candidate_subscription(&mut self, pk: i64) -> Result<DbVal<ListSubscription>> {
|
||||
let tx = self.connection.transaction()?;
|
||||
let mut stmt = tx
|
||||
.prepare("INSERT INTO subscription(list, address, name, enabled, digest, verified, hide_address, receive_duplicates, receive_own_posts, receive_confirmation) SELECT list, address, name, 1, 0, 0, 0, 1, 1, 0 FROM candidate_subscription WHERE pk = ? RETURNING *;")?;
|
||||
let ret = stmt.query_row(rusqlite::params![&pk], |row| {
|
||||
let pk = row.get("pk")?;
|
||||
Ok(DbVal(
|
||||
ListSubscription {
|
||||
pk,
|
||||
list: row.get("list")?,
|
||||
address: row.get("address")?,
|
||||
account: row.get("account")?,
|
||||
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,
|
||||
))
|
||||
})?;
|
||||
drop(stmt);
|
||||
tx.execute(
|
||||
"UPDATE candidate_subscription SET accepted = ? WHERE pk = ?;",
|
||||
[&ret.pk, &pk],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
|
||||
trace!("accept_candidate_subscription {:?}.", &ret);
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
/// Remove a subscription by their address.
|
||||
pub fn remove_subscription(&self, list_pk: i64, address: &str) -> Result<()> {
|
||||
self.connection
|
||||
.query_row(
|
||||
"DELETE FROM subscription WHERE list = ? AND address = ? RETURNING *;",
|
||||
rusqlite::params![&list_pk, &address],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.map_err(|err| {
|
||||
if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
|
||||
Error::from(err).chain_err(|| NotFound("list or list owner not found!"))
|
||||
} else {
|
||||
err.into()
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update a mailing list subscription.
|
||||
pub fn update_subscription(&mut self, change_set: ListSubscriptionChangeset) -> Result<()> {
|
||||
let pk = self
|
||||
.list_subscription_by_address(change_set.list, &change_set.address)?
|
||||
.pk;
|
||||
if matches!(
|
||||
change_set,
|
||||
ListSubscriptionChangeset {
|
||||
list: _,
|
||||
address: _,
|
||||
account: None,
|
||||
name: None,
|
||||
digest: None,
|
||||
verified: None,
|
||||
hide_address: None,
|
||||
receive_duplicates: None,
|
||||
receive_own_posts: None,
|
||||
receive_confirmation: None,
|
||||
enabled: None,
|
||||
}
|
||||
) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let ListSubscriptionChangeset {
|
||||
list,
|
||||
address: _,
|
||||
name,
|
||||
account,
|
||||
digest,
|
||||
enabled,
|
||||
verified,
|
||||
hide_address,
|
||||
receive_duplicates,
|
||||
receive_own_posts,
|
||||
receive_confirmation,
|
||||
} = change_set;
|
||||
let tx = self.connection.transaction()?;
|
||||
|
||||
macro_rules! update {
|
||||
($field:tt) => {{
|
||||
if let Some($field) = $field {
|
||||
tx.execute(
|
||||
concat!(
|
||||
"UPDATE subscription SET ",
|
||||
stringify!($field),
|
||||
" = ? WHERE list = ? AND pk = ?;"
|
||||
),
|
||||
rusqlite::params![&$field, &list, &pk],
|
||||
)?;
|
||||
}
|
||||
}};
|
||||
}
|
||||
update!(name);
|
||||
update!(account);
|
||||
update!(digest);
|
||||
update!(enabled);
|
||||
update!(verified);
|
||||
update!(hide_address);
|
||||
update!(receive_duplicates);
|
||||
update!(receive_own_posts);
|
||||
update!(receive_confirmation);
|
||||
|
||||
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")?,
|
||||
public_key: row.get("public_key")?,
|
||||
password: row.get("password")?,
|
||||
enabled: row.get("enabled")?,
|
||||
},
|
||||
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")?,
|
||||
public_key: row.get("public_key")?,
|
||||
password: row.get("password")?,
|
||||
enabled: row.get("enabled")?,
|
||||
},
|
||||
pk,
|
||||
))
|
||||
})
|
||||
.optional()?;
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
/// Fetch all subscriptions of an account by primary key.
|
||||
pub fn account_subscriptions(&self, pk: i64) -> Result<Vec<DbVal<ListSubscription>>> {
|
||||
let mut stmt = self
|
||||
.connection
|
||||
.prepare("SELECT * FROM subscription WHERE account = ?;")?;
|
||||
let list_iter = stmt.query_map([&pk], |row| {
|
||||
let pk = row.get("pk")?;
|
||||
Ok(DbVal(
|
||||
ListSubscription {
|
||||
pk: row.get("pk")?,
|
||||
list: row.get("list")?,
|
||||
address: row.get("address")?,
|
||||
account: row.get("account")?,
|
||||
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)
|
||||
}
|
||||
|
||||
/// Fetch all accounts.
|
||||
pub fn accounts(&self) -> Result<Vec<DbVal<Account>>> {
|
||||
let mut stmt = self
|
||||
.connection
|
||||
.prepare("SELECT * FROM account ORDER BY pk ASC;")?;
|
||||
let list_iter = stmt.query_map([], |row| {
|
||||
let pk = row.get("pk")?;
|
||||
Ok(DbVal(
|
||||
Account {
|
||||
pk,
|
||||
name: row.get("name")?,
|
||||
address: row.get("address")?,
|
||||
public_key: row.get("public_key")?,
|
||||
password: row.get("password")?,
|
||||
enabled: row.get("enabled")?,
|
||||
},
|
||||
pk,
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut ret = vec![];
|
||||
for list in list_iter {
|
||||
let list = list?;
|
||||
ret.push(list);
|
||||
}
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
/// Add account.
|
||||
pub fn add_account(&self, new_val: Account) -> Result<DbVal<Account>> {
|
||||
let mut stmt = self
|
||||
.connection
|
||||
.prepare("INSERT INTO account(name, address, public_key, password, enabled) VALUES(?, ?, ?, ?, ?) RETURNING *;").unwrap();
|
||||
let ret = stmt.query_row(
|
||||
rusqlite::params![
|
||||
&new_val.name,
|
||||
&new_val.address,
|
||||
&new_val.public_key,
|
||||
&new_val.password,
|
||||
&new_val.enabled,
|
||||
],
|
||||
|row| {
|
||||
let pk = row.get("pk")?;
|
||||
Ok(DbVal(
|
||||
Account {
|
||||
pk,
|
||||
name: row.get("name")?,
|
||||
address: row.get("address")?,
|
||||
public_key: row.get("public_key")?,
|
||||
password: row.get("password")?,
|
||||
enabled: row.get("enabled")?,
|
||||
},
|
||||
pk,
|
||||
))
|
||||
},
|
||||
)?;
|
||||
|
||||
trace!("add_account {:?}.", &ret);
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
/// Remove an account by their address.
|
||||
pub fn remove_account(&self, address: &str) -> Result<()> {
|
||||
self.connection
|
||||
.query_row(
|
||||
"DELETE FROM account WHERE address = ? RETURNING *;",
|
||||
rusqlite::params![&address],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.map_err(|err| {
|
||||
if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
|
||||
Error::from(err).chain_err(|| NotFound("account not found!"))
|
||||
} else {
|
||||
err.into()
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an account.
|
||||
pub fn update_account(&mut self, change_set: AccountChangeset) -> Result<()> {
|
||||
let Some(acc) = self.account_by_address(&change_set.address)? else {
|
||||
return Err(NotFound("account with this address not found!").into());
|
||||
};
|
||||
let pk = acc.pk;
|
||||
if matches!(
|
||||
change_set,
|
||||
AccountChangeset {
|
||||
address: _,
|
||||
name: None,
|
||||
public_key: None,
|
||||
password: None,
|
||||
enabled: None,
|
||||
}
|
||||
) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let AccountChangeset {
|
||||
address: _,
|
||||
name,
|
||||
public_key,
|
||||
password,
|
||||
enabled,
|
||||
} = change_set;
|
||||
let tx = self.connection.transaction()?;
|
||||
|
||||
macro_rules! update {
|
||||
($field:tt) => {{
|
||||
if let Some($field) = $field {
|
||||
tx.execute(
|
||||
concat!(
|
||||
"UPDATE account SET ",
|
||||
stringify!($field),
|
||||
" = ? WHERE pk = ?;"
|
||||
),
|
||||
rusqlite::params![&$field, &pk],
|
||||
)?;
|
||||
}
|
||||
}};
|
||||
}
|
||||
update!(name);
|
||||
update!(public_key);
|
||||
update!(password);
|
||||
update!(enabled);
|
||||
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -78,17 +78,17 @@
|
|||
//! pk: 0,
|
||||
//! list: list_pk,
|
||||
//! announce_only: false,
|
||||
//! subscriber_only: true,
|
||||
//! subscription_only: true,
|
||||
//! approval_needed: false,
|
||||
//! open: false,
|
||||
//! custom: false,
|
||||
//! },
|
||||
//! )?;
|
||||
//!
|
||||
//! // Drop privileges; we can only process new e-mail and modify memberships from now on.
|
||||
//! // Drop privileges; we can only process new e-mail and modify subscriptions from now on.
|
||||
//! let mut db = db.untrusted();
|
||||
//!
|
||||
//! assert_eq!(db.list_members(list_pk)?.len(), 0);
|
||||
//! assert_eq!(db.list_subscriptions(list_pk)?.len(), 0);
|
||||
//! assert_eq!(db.list_posts(list_pk, None)?.len(), 0);
|
||||
//!
|
||||
//! // Process a subscription request e-mail
|
||||
|
@ -102,7 +102,7 @@
|
|||
//! let envelope = melib::Envelope::from_bytes(subscribe_bytes, None)?;
|
||||
//! db.post(&envelope, subscribe_bytes, /* dry_run */ false)?;
|
||||
//!
|
||||
//! assert_eq!(db.list_members(list_pk)?.len(), 1);
|
||||
//! assert_eq!(db.list_subscriptions(list_pk)?.len(), 1);
|
||||
//! assert_eq!(db.list_posts(list_pk, None)?.len(), 0);
|
||||
//!
|
||||
//! // Process a post
|
||||
|
@ -118,7 +118,7 @@
|
|||
//! melib::Envelope::from_bytes(post_bytes, None).expect("Could not parse message");
|
||||
//! db.post(&envelope, post_bytes, /* dry_run */ false)?;
|
||||
//!
|
||||
//! assert_eq!(db.list_members(list_pk)?.len(), 1);
|
||||
//! assert_eq!(db.list_subscriptions(list_pk)?.len(), 1);
|
||||
//! assert_eq!(db.list_posts(list_pk, None)?.len(), 1);
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
|
|
|
@ -50,8 +50,8 @@ pub struct ListContext<'list> {
|
|||
pub list: &'list MailingList,
|
||||
/// The mailing list owners.
|
||||
pub list_owners: &'list [DbVal<ListOwner>],
|
||||
/// The mailing list memberships.
|
||||
pub memberships: &'list [DbVal<ListMembership>],
|
||||
/// The mailing list subscriptions.
|
||||
pub subscriptions: &'list [DbVal<ListSubscription>],
|
||||
/// The mailing list post policy.
|
||||
pub policy: Option<DbVal<PostPolicy>>,
|
||||
/// The scheduled jobs added by each filter in a list's [`PostFilter`](message_filters::PostFilter) stack.
|
||||
|
@ -122,7 +122,7 @@ pub enum ListRequest {
|
|||
RetrieveArchive(String, String),
|
||||
/// Request reception of specific mailing list posts from `Message-ID` values.
|
||||
RetrieveMessages(Vec<String>),
|
||||
/// Request change in digest preferences. (See [`ListMembership`])
|
||||
/// Request change in digest preferences. (See [`ListSubscription`])
|
||||
SetDigest(bool),
|
||||
/// Other type of request.
|
||||
Other(String),
|
||||
|
|
|
@ -78,15 +78,15 @@ impl PostFilter for PostRightsCheck {
|
|||
};
|
||||
return Err(());
|
||||
}
|
||||
} else if policy.subscriber_only {
|
||||
trace!("post policy is subscriber_only");
|
||||
} else if policy.subscription_only {
|
||||
trace!("post policy is subscription_only");
|
||||
let email_from = post.from.get_email();
|
||||
trace!("post from is {:?}", &email_from);
|
||||
trace!("post memberships are {:#?}", &ctx.memberships);
|
||||
if !ctx.memberships.iter().any(|lm| lm.address == email_from) {
|
||||
trace!("post subscriptions are {:#?}", &ctx.subscriptions);
|
||||
if !ctx.subscriptions.iter().any(|lm| lm.address == email_from) {
|
||||
trace!("Envelope from is not subscribed to this list");
|
||||
post.action = PostAction::Reject {
|
||||
reason: "Only subscribers can post to this list.".to_string(),
|
||||
reason: "Only subscriptions can post to this list.".to_string(),
|
||||
};
|
||||
return Err(());
|
||||
}
|
||||
|
@ -94,8 +94,8 @@ impl PostFilter for PostRightsCheck {
|
|||
trace!("post policy says approval_needed");
|
||||
let email_from = post.from.get_email();
|
||||
trace!("post from is {:?}", &email_from);
|
||||
trace!("post memberships are {:#?}", &ctx.memberships);
|
||||
if !ctx.memberships.iter().any(|lm| lm.address == email_from) {
|
||||
trace!("post subscriptions are {:#?}", &ctx.subscriptions);
|
||||
if !ctx.subscriptions.iter().any(|lm| lm.address == email_from) {
|
||||
trace!("Envelope from is not subscribed to this list");
|
||||
post.action = PostAction::Defer {
|
||||
reason: "Your posting has been deferred. Approval from the list's moderators is required before it is submitted.".to_string(),
|
||||
|
@ -143,7 +143,7 @@ impl PostFilter for AddListHeaders {
|
|||
headers.push((&b"List-ID"[..], list_id.as_bytes()));
|
||||
headers.push((&b"Sender"[..], sender.as_bytes()));
|
||||
let list_post = ctx.list.post_header();
|
||||
let list_unsubscribe = ctx.list.unsubscribe_header();
|
||||
let list_unsubscribe = ctx.list.unsubscription_header();
|
||||
let list_archive = ctx.list.archive_header();
|
||||
if let Some(post) = list_post.as_ref() {
|
||||
headers.push((&b"List-Post"[..], post.as_bytes()));
|
||||
|
@ -189,7 +189,7 @@ impl PostFilter for ArchivedAtLink {
|
|||
}
|
||||
}
|
||||
|
||||
/// Assuming there are no more changes to be done on the post, it finalizes which list members
|
||||
/// Assuming there are no more changes to be done on the post, it finalizes which list subscriptions
|
||||
/// will receive the post in `post.action` field.
|
||||
pub struct FinalizeRecipients;
|
||||
impl PostFilter for FinalizeRecipients {
|
||||
|
@ -202,21 +202,21 @@ impl PostFilter for FinalizeRecipients {
|
|||
let mut recipients = vec![];
|
||||
let mut digests = vec![];
|
||||
let email_from = post.from.get_email();
|
||||
for member in ctx.memberships {
|
||||
trace!("examining member {:?}", &member);
|
||||
if member.address == email_from {
|
||||
trace!("member is submitter");
|
||||
for subscription in ctx.subscriptions {
|
||||
trace!("examining subscription {:?}", &subscription);
|
||||
if subscription.address == email_from {
|
||||
trace!("subscription is submitter");
|
||||
}
|
||||
if member.digest {
|
||||
if member.address != email_from || member.receive_own_posts {
|
||||
trace!("Member gets digest");
|
||||
digests.push(member.address());
|
||||
if subscription.digest {
|
||||
if subscription.address != email_from || subscription.receive_own_posts {
|
||||
trace!("Subscription gets digest");
|
||||
digests.push(subscription.address());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if member.address != email_from || member.receive_own_posts {
|
||||
trace!("Member gets copy");
|
||||
recipients.push(member.address());
|
||||
if subscription.address != email_from || subscription.receive_own_posts {
|
||||
trace!("Subscription gets copy");
|
||||
recipients.push(subscription.address());
|
||||
}
|
||||
// TODO:
|
||||
// - check for duplicates (To,Cc,Bcc)
|
||||
|
|
|
@ -17,8 +17,8 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
//! Database models: [`MailingList`], [`ListOwner`], [`ListMembership`], [`PostPolicy`],
|
||||
//! [`SubscribePolicy`] and [`Post`].
|
||||
//! Database models: [`MailingList`], [`ListOwner`], [`ListSubscription`], [`PostPolicy`],
|
||||
//! [`SubscriptionPolicy`] and [`Post`].
|
||||
|
||||
use super::*;
|
||||
pub mod changesets;
|
||||
|
@ -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,
|
||||
|
@ -105,7 +111,7 @@ impl MailingList {
|
|||
/// Value of `List-Unsubscribe` header.
|
||||
///
|
||||
/// See RFC2369 Section 3.2: <https://www.rfc-editor.org/rfc/rfc2369#section-3.2>
|
||||
pub fn unsubscribe_header(&self) -> Option<String> {
|
||||
pub fn unsubscription_header(&self) -> Option<String> {
|
||||
let p = self.address.split('@').collect::<Vec<&str>>();
|
||||
Some(format!(
|
||||
"<mailto:{}+request@{}?subject=subscribe>",
|
||||
|
@ -126,7 +132,7 @@ impl MailingList {
|
|||
}
|
||||
|
||||
/// List unsubscribe action as a [`MailtoAddress`](super::MailtoAddress).
|
||||
pub fn unsubscribe_mailto(&self) -> MailtoAddress {
|
||||
pub fn unsubscription_mailto(&self) -> MailtoAddress {
|
||||
let p = self.address.split('@').collect::<Vec<&str>>();
|
||||
MailtoAddress {
|
||||
address: format!("{}+request@{}", p[0], p[1]),
|
||||
|
@ -135,7 +141,7 @@ impl MailingList {
|
|||
}
|
||||
|
||||
/// List subscribe action as a [`MailtoAddress`](super::MailtoAddress).
|
||||
pub fn subscribe_mailto(&self) -> MailtoAddress {
|
||||
pub fn subscription_mailto(&self) -> MailtoAddress {
|
||||
let p = self.address.split('@').collect::<Vec<&str>>();
|
||||
MailtoAddress {
|
||||
address: format!("{}+request@{}", p[0], p[1]),
|
||||
|
@ -158,36 +164,38 @@ impl MailingList {
|
|||
}
|
||||
}
|
||||
|
||||
/// A mailing list membership entry.
|
||||
/// A mailing list subscription entry.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ListMembership {
|
||||
pub struct ListSubscription {
|
||||
/// Database primary key.
|
||||
pub pk: i64,
|
||||
/// Mailing list foreign key (See [`MailingList`]).
|
||||
pub list: i64,
|
||||
/// Member's e-mail address.
|
||||
/// Subscription's e-mail address.
|
||||
pub address: String,
|
||||
/// Member's name, optional.
|
||||
/// Subscription's name, optional.
|
||||
pub name: Option<String>,
|
||||
/// Whether this membership is enabled.
|
||||
/// Subscription's account foreign key, optional.
|
||||
pub account: Option<i64>,
|
||||
/// Whether this subscription is enabled.
|
||||
pub enabled: bool,
|
||||
/// Whether the e-mail address is verified.
|
||||
pub verified: bool,
|
||||
/// Whether member wishes to receive list posts as a periodical digest e-mail.
|
||||
/// Whether subscription wishes to receive list posts as a periodical digest e-mail.
|
||||
pub digest: bool,
|
||||
/// Whether member wishes their e-mail address hidden from public view.
|
||||
/// Whether subscription wishes their e-mail address hidden from public view.
|
||||
pub hide_address: bool,
|
||||
/// Whether member wishes to receive mailing list post duplicates, i.e. posts addressed to them
|
||||
/// Whether subscription wishes to receive mailing list post duplicates, i.e. posts addressed to them
|
||||
/// and the mailing list to which they are subscribed.
|
||||
pub receive_duplicates: bool,
|
||||
/// Whether member wishes to receive their own mailing list posts from the mailing list, as a
|
||||
/// Whether subscription wishes to receive their own mailing list posts from the mailing list, as a
|
||||
/// confirmation.
|
||||
pub receive_own_posts: bool,
|
||||
/// Whether member wishes to receive a plain confirmation for their own mailing list posts.
|
||||
/// Whether subscription wishes to receive a plain confirmation for their own mailing list posts.
|
||||
pub receive_confirmation: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ListMembership {
|
||||
impl std::fmt::Display for ListSubscription {
|
||||
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(
|
||||
fmt,
|
||||
|
@ -205,8 +213,8 @@ impl std::fmt::Display for ListMembership {
|
|||
}
|
||||
}
|
||||
|
||||
impl ListMembership {
|
||||
/// Member address as a [`melib::Address`]
|
||||
impl ListSubscription {
|
||||
/// Subscription address as a [`melib::Address`]
|
||||
pub fn address(&self) -> Address {
|
||||
Address::new(self.name.clone(), self.address.clone())
|
||||
}
|
||||
|
@ -224,8 +232,8 @@ pub struct PostPolicy {
|
|||
/// Whether the policy is announce only (Only list owners can submit posts, and everyone will
|
||||
/// receive them).
|
||||
pub announce_only: bool,
|
||||
/// Whether the policy is "subscriber only" (Only list subscribers can post).
|
||||
pub subscriber_only: bool,
|
||||
/// Whether the policy is "subscription only" (Only list subscriptions can post).
|
||||
pub subscription_only: bool,
|
||||
/// Whether the policy is "approval needed" (Anyone can post, but approval from list owners is
|
||||
/// required if they are not subscribed).
|
||||
pub approval_needed: bool,
|
||||
|
@ -261,13 +269,14 @@ impl std::fmt::Display for ListOwner {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<ListOwner> for ListMembership {
|
||||
impl From<ListOwner> for ListSubscription {
|
||||
fn from(val: ListOwner) -> Self {
|
||||
Self {
|
||||
pk: 0,
|
||||
list: val.list,
|
||||
address: val.address,
|
||||
name: val.name,
|
||||
account: None,
|
||||
digest: false,
|
||||
hide_address: false,
|
||||
receive_duplicates: true,
|
||||
|
@ -317,7 +326,7 @@ impl std::fmt::Display for Post {
|
|||
///
|
||||
/// Only one of the policy boolean flags must be set to true.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct SubscribePolicy {
|
||||
pub struct SubscriptionPolicy {
|
||||
/// Database primary key.
|
||||
pub pk: i64,
|
||||
/// Mailing list foreign key (See [`MailingList`]).
|
||||
|
@ -326,7 +335,7 @@ pub struct SubscribePolicy {
|
|||
pub send_confirmation: bool,
|
||||
/// Anyone can subscribe without restrictions.
|
||||
pub open: bool,
|
||||
/// Only list owners can manually add subscribers.
|
||||
/// Only list owners can manually add subscriptions.
|
||||
pub manual: bool,
|
||||
/// Anyone can request to subscribe.
|
||||
pub request: bool,
|
||||
|
@ -334,7 +343,30 @@ pub struct SubscribePolicy {
|
|||
pub custom: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SubscribePolicy {
|
||||
impl std::fmt::Display for SubscriptionPolicy {
|
||||
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
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,
|
||||
/// GPG public key.
|
||||
pub public_key: Option<String>,
|
||||
/// SSH public key.
|
||||
pub password: String,
|
||||
/// Whether this account is enabled.
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Account {
|
||||
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(fmt, "{:?}", self)
|
||||
}
|
||||
|
|
|
@ -19,6 +19,16 @@
|
|||
|
||||
//! Changeset structs: update specific struct fields.
|
||||
|
||||
macro_rules! impl_display {
|
||||
($t:ty) => {
|
||||
impl std::fmt::Display for $t {
|
||||
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(fmt, "{:?}", self)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Changeset struct for [`Mailinglist`](super::MailingList).
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct MailingListChangeset {
|
||||
|
@ -46,14 +56,18 @@ pub struct MailingListChangeset {
|
|||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
/// Changeset struct for [`ListMembership`](super::ListMembership).
|
||||
impl_display!(MailingListChangeset);
|
||||
|
||||
/// Changeset struct for [`ListSubscription`](super::ListSubscription).
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ListMembershipChangeset {
|
||||
pub struct ListSubscriptionChangeset {
|
||||
/// Mailing list foreign key (See [`MailingList`](super::MailingList)).
|
||||
pub list: i64,
|
||||
/// Membership e-mail address.
|
||||
/// Subscription e-mail address.
|
||||
pub address: String,
|
||||
/// Optional new value.
|
||||
pub account: Option<Option<i64>>,
|
||||
/// Optional new value.
|
||||
pub name: Option<Option<String>>,
|
||||
/// Optional new value.
|
||||
pub digest: Option<bool>,
|
||||
|
@ -71,6 +85,8 @@ pub struct ListMembershipChangeset {
|
|||
pub receive_confirmation: Option<bool>,
|
||||
}
|
||||
|
||||
impl_display!(ListSubscriptionChangeset);
|
||||
|
||||
/// Changeset struct for [`PostPolicy`](super::PostPolicy).
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct PostPolicyChangeset {
|
||||
|
@ -81,11 +97,13 @@ pub struct PostPolicyChangeset {
|
|||
/// Optional new value.
|
||||
pub announce_only: Option<bool>,
|
||||
/// Optional new value.
|
||||
pub subscriber_only: Option<bool>,
|
||||
pub subscription_only: Option<bool>,
|
||||
/// Optional new value.
|
||||
pub approval_needed: Option<bool>,
|
||||
}
|
||||
|
||||
impl_display!(PostPolicyChangeset);
|
||||
|
||||
/// Changeset struct for [`ListOwner`](super::ListOwner).
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ListOwnerChangeset {
|
||||
|
@ -99,24 +117,21 @@ pub struct ListOwnerChangeset {
|
|||
pub name: Option<Option<String>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MailingListChangeset {
|
||||
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(fmt, "{:?}", self)
|
||||
}
|
||||
impl_display!(ListOwnerChangeset);
|
||||
|
||||
/// Changeset struct for [`Account`](super::Account).
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct AccountChangeset {
|
||||
/// Account e-mail address.
|
||||
pub address: String,
|
||||
/// Optional new value.
|
||||
pub name: Option<Option<String>>,
|
||||
/// Optional new value.
|
||||
pub public_key: Option<Option<String>>,
|
||||
/// Optional new value.
|
||||
pub password: Option<String>,
|
||||
/// Optional new value.
|
||||
pub enabled: Option<Option<bool>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ListMembershipChangeset {
|
||||
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(fmt, "{:?}", self)
|
||||
}
|
||||
}
|
||||
impl std::fmt::Display for PostPolicyChangeset {
|
||||
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(fmt, "{:?}", self)
|
||||
}
|
||||
}
|
||||
impl std::fmt::Display for ListOwnerChangeset {
|
||||
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(fmt, "{:?}", self)
|
||||
}
|
||||
}
|
||||
impl_display!(AccountChangeset);
|
||||
|
|
|
@ -157,7 +157,7 @@ impl PostfixConfiguration {
|
|||
},
|
||||
Some(PostPolicy { .. }) => {
|
||||
push_addr!(list.address);
|
||||
push_addr!(list.subscribe_mailto().address);
|
||||
push_addr!(list.subscription_mailto().address);
|
||||
push_addr!(list.owner_mailto().address);
|
||||
ret.push('\n');
|
||||
}
|
||||
|
@ -419,7 +419,7 @@ fn test_postfix_generation() -> Result<()> {
|
|||
pk: 0,
|
||||
list: second.pk(),
|
||||
announce_only: false,
|
||||
subscriber_only: false,
|
||||
subscription_only: false,
|
||||
approval_needed: false,
|
||||
open: true,
|
||||
custom: false,
|
||||
|
@ -439,7 +439,7 @@ fn test_postfix_generation() -> Result<()> {
|
|||
pk: 0,
|
||||
list: third.pk(),
|
||||
announce_only: false,
|
||||
subscriber_only: false,
|
||||
subscription_only: false,
|
||||
approval_needed: true,
|
||||
open: false,
|
||||
custom: false,
|
||||
|
|
|
@ -31,17 +31,17 @@ CREATE TABLE IF NOT EXISTS post_policy (
|
|||
pk INTEGER PRIMARY KEY NOT NULL,
|
||||
list INTEGER NOT NULL UNIQUE,
|
||||
announce_only BOOLEAN CHECK (announce_only in (0, 1)) NOT NULL DEFAULT 0,
|
||||
subscriber_only BOOLEAN CHECK (subscriber_only in (0, 1)) NOT NULL DEFAULT 0,
|
||||
subscription_only BOOLEAN CHECK (subscription_only in (0, 1)) NOT NULL DEFAULT 0,
|
||||
approval_needed BOOLEAN CHECK (approval_needed in (0, 1)) NOT NULL DEFAULT 0,
|
||||
open BOOLEAN CHECK (open in (0, 1)) NOT NULL DEFAULT 0,
|
||||
custom BOOLEAN CHECK (custom in (0, 1)) NOT NULL DEFAULT 0,
|
||||
created INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
last_modified INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
CHECK(((custom) OR (((open) OR (((approval_needed) OR (((announce_only) OR (subscriber_only)) AND NOT ((announce_only) AND (subscriber_only)))) AND NOT ((approval_needed) AND (((announce_only) OR (subscriber_only)) AND NOT ((announce_only) AND (subscriber_only)))))) AND NOT ((open) AND (((approval_needed) OR (((announce_only) OR (subscriber_only)) AND NOT ((announce_only) AND (subscriber_only)))) AND NOT ((approval_needed) AND (((announce_only) OR (subscriber_only)) AND NOT ((announce_only) AND (subscriber_only)))))))) AND NOT ((custom) AND (((open) OR (((approval_needed) OR (((announce_only) OR (subscriber_only)) AND NOT ((announce_only) AND (subscriber_only)))) AND NOT ((approval_needed) AND (((announce_only) OR (subscriber_only)) AND NOT ((announce_only) AND (subscriber_only)))))) AND NOT ((open) AND (((approval_needed) OR (((announce_only) OR (subscriber_only)) AND NOT ((announce_only) AND (subscriber_only)))) AND NOT ((approval_needed) AND (((announce_only) OR (subscriber_only)) AND NOT ((announce_only) AND (subscriber_only))))))))),
|
||||
CHECK(((custom) OR (((open) OR (((approval_needed) OR (((announce_only) OR (subscription_only)) AND NOT ((announce_only) AND (subscription_only)))) AND NOT ((approval_needed) AND (((announce_only) OR (subscription_only)) AND NOT ((announce_only) AND (subscription_only)))))) AND NOT ((open) AND (((approval_needed) OR (((announce_only) OR (subscription_only)) AND NOT ((announce_only) AND (subscription_only)))) AND NOT ((approval_needed) AND (((announce_only) OR (subscription_only)) AND NOT ((announce_only) AND (subscription_only)))))))) AND NOT ((custom) AND (((open) OR (((approval_needed) OR (((announce_only) OR (subscription_only)) AND NOT ((announce_only) AND (subscription_only)))) AND NOT ((approval_needed) AND (((announce_only) OR (subscription_only)) AND NOT ((announce_only) AND (subscription_only)))))) AND NOT ((open) AND (((approval_needed) OR (((announce_only) OR (subscription_only)) AND NOT ((announce_only) AND (subscription_only)))) AND NOT ((approval_needed) AND (((announce_only) OR (subscription_only)) AND NOT ((announce_only) AND (subscription_only))))))))),
|
||||
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subscribe_policy (
|
||||
CREATE TABLE IF NOT EXISTS subscription_policy (
|
||||
pk INTEGER PRIMARY KEY NOT NULL,
|
||||
list INTEGER NOT NULL UNIQUE,
|
||||
send_confirmation BOOLEAN CHECK (send_confirmation in (0, 1)) NOT NULL DEFAULT 1,
|
||||
|
@ -55,7 +55,7 @@ CREATE TABLE IF NOT EXISTS subscribe_policy (
|
|||
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS membership (
|
||||
CREATE TABLE IF NOT EXISTS subscription (
|
||||
pk INTEGER PRIMARY KEY NOT NULL,
|
||||
list INTEGER NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
|
@ -86,7 +86,7 @@ CREATE TABLE IF NOT EXISTS account (
|
|||
last_modified INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS candidate_membership (
|
||||
CREATE TABLE IF NOT EXISTS candidate_subscription (
|
||||
pk INTEGER PRIMARY KEY NOT NULL,
|
||||
list INTEGER NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
|
@ -95,7 +95,7 @@ CREATE TABLE IF NOT EXISTS candidate_membership (
|
|||
created INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
|
||||
FOREIGN KEY (accepted) REFERENCES membership(pk) ON DELETE CASCADE,
|
||||
FOREIGN KEY (accepted) REFERENCES subscription(pk) ON DELETE CASCADE,
|
||||
UNIQUE (list, address) ON CONFLICT ROLLBACK
|
||||
);
|
||||
|
||||
|
@ -162,29 +162,46 @@ CREATE TABLE IF NOT EXISTS queue (
|
|||
|
||||
CREATE TABLE IF NOT EXISTS bounce (
|
||||
pk INTEGER PRIMARY KEY NOT NULL,
|
||||
member INTEGER NOT NULL UNIQUE,
|
||||
subscription INTEGER NOT NULL UNIQUE,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
last_bounce TEXT NOT NULL DEFAULT (datetime()),
|
||||
FOREIGN KEY (member) REFERENCES membership(pk) ON DELETE CASCADE
|
||||
FOREIGN KEY (subscription) REFERENCES subscription(pk) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS post_listpk_idx ON post(list);
|
||||
CREATE INDEX IF NOT EXISTS post_msgid_idx ON post(message_id);
|
||||
CREATE INDEX IF NOT EXISTS list_idx ON list(id);
|
||||
CREATE INDEX IF NOT EXISTS membership_idx ON membership(address);
|
||||
CREATE INDEX IF NOT EXISTS subscription_idx ON subscription(address);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS accept_candidate AFTER INSERT ON membership
|
||||
CREATE TRIGGER IF NOT EXISTS accept_candidate AFTER INSERT ON subscription
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE candidate_membership SET accepted = NEW.pk, last_modified = unixepoch()
|
||||
WHERE candidate_membership.list = NEW.list AND candidate_membership.address = NEW.address;
|
||||
UPDATE candidate_subscription SET accepted = NEW.pk, last_modified = unixepoch()
|
||||
WHERE candidate_subscription.list = NEW.list AND candidate_subscription.address = NEW.address;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS verify_candidate AFTER INSERT ON membership
|
||||
CREATE TRIGGER IF NOT EXISTS verify_candidate AFTER INSERT ON subscription
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE membership SET verified = 0, last_modified = unixepoch()
|
||||
WHERE membership.pk = NEW.pk AND EXISTS (SELECT 1 FROM list WHERE pk = NEW.list AND verify = 1);
|
||||
UPDATE subscription SET verified = 0, last_modified = unixepoch()
|
||||
WHERE subscription.pk = NEW.pk AND EXISTS (SELECT 1 FROM list WHERE pk = NEW.list AND verify = 1);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS add_account AFTER INSERT ON account
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE subscription SET account = NEW.pk, last_modified = unixepoch()
|
||||
WHERE subscription.address = NEW.address;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS add_account_to_subscription AFTER INSERT ON subscription
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE subscription
|
||||
SET account = acc.pk,
|
||||
last_modified = unixepoch()
|
||||
FROM (SELECT * FROM account) AS acc
|
||||
WHERE subscription.account = acc.address;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS last_modified_list AFTER UPDATE ON list
|
||||
|
@ -193,42 +210,49 @@ BEGIN
|
|||
UPDATE list SET last_modified = unixepoch()
|
||||
WHERE pk = NEW.pk;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS last_modified_owner AFTER UPDATE ON owner
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE owner SET last_modified = unixepoch()
|
||||
WHERE pk = NEW.pk;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS last_modified_post_policy AFTER UPDATE ON post_policy
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE post_policy SET last_modified = unixepoch()
|
||||
WHERE pk = NEW.pk;
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS last_modified_subscribe_policy AFTER UPDATE ON subscribe_policy
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS last_modified_subscription_policy AFTER UPDATE ON subscription_policy
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE subscribe_policy SET last_modified = unixepoch()
|
||||
UPDATE subscription_policy SET last_modified = unixepoch()
|
||||
WHERE pk = NEW.pk;
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS last_modified_membership AFTER UPDATE ON membership
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS last_modified_subscription AFTER UPDATE ON subscription
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE membership SET last_modified = unixepoch()
|
||||
UPDATE subscription SET last_modified = unixepoch()
|
||||
WHERE pk = NEW.pk;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS last_modified_account AFTER UPDATE ON account
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE account SET last_modified = unixepoch()
|
||||
WHERE pk = NEW.pk;
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS last_modified_candidate_membership AFTER UPDATE ON candidate_membership
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS last_modified_candidate_subscription AFTER UPDATE ON candidate_subscription
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE candidate_membership SET last_modified = unixepoch()
|
||||
UPDATE candidate_subscription SET last_modified = unixepoch()
|
||||
WHERE pk = NEW.pk;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS last_modified_templates AFTER UPDATE ON templates
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
|
|
|
@ -41,17 +41,17 @@ CREATE TABLE IF NOT EXISTS post_policy (
|
|||
pk INTEGER PRIMARY KEY NOT NULL,
|
||||
list INTEGER NOT NULL UNIQUE,
|
||||
BOOLEAN_TYPE(announce_only) DEFAULT BOOLEAN_FALSE(),
|
||||
BOOLEAN_TYPE(subscriber_only) DEFAULT BOOLEAN_FALSE(),
|
||||
BOOLEAN_TYPE(subscription_only) DEFAULT BOOLEAN_FALSE(),
|
||||
BOOLEAN_TYPE(approval_needed) DEFAULT BOOLEAN_FALSE(),
|
||||
BOOLEAN_TYPE(open) DEFAULT BOOLEAN_FALSE(),
|
||||
BOOLEAN_TYPE(custom) DEFAULT BOOLEAN_FALSE(),
|
||||
created INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
last_modified INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
CHECK(xor(custom, xor(open, xor(approval_needed, xor(announce_only, subscriber_only))))),
|
||||
CHECK(xor(custom, xor(open, xor(approval_needed, xor(announce_only, subscription_only))))),
|
||||
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subscribe_policy (
|
||||
CREATE TABLE IF NOT EXISTS subscription_policy (
|
||||
pk INTEGER PRIMARY KEY NOT NULL,
|
||||
list INTEGER NOT NULL UNIQUE,
|
||||
BOOLEAN_TYPE(send_confirmation) DEFAULT BOOLEAN_TRUE(),
|
||||
|
@ -65,7 +65,7 @@ CREATE TABLE IF NOT EXISTS subscribe_policy (
|
|||
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS membership (
|
||||
CREATE TABLE IF NOT EXISTS subscription (
|
||||
pk INTEGER PRIMARY KEY NOT NULL,
|
||||
list INTEGER NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
|
@ -96,7 +96,7 @@ CREATE TABLE IF NOT EXISTS account (
|
|||
last_modified INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS candidate_membership (
|
||||
CREATE TABLE IF NOT EXISTS candidate_subscription (
|
||||
pk INTEGER PRIMARY KEY NOT NULL,
|
||||
list INTEGER NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
|
@ -105,7 +105,7 @@ CREATE TABLE IF NOT EXISTS candidate_membership (
|
|||
created INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
|
||||
FOREIGN KEY (accepted) REFERENCES membership(pk) ON DELETE CASCADE,
|
||||
FOREIGN KEY (accepted) REFERENCES subscription(pk) ON DELETE CASCADE,
|
||||
UNIQUE (list, address) ON CONFLICT ROLLBACK
|
||||
);
|
||||
|
||||
|
@ -172,36 +172,60 @@ CREATE TABLE IF NOT EXISTS queue (
|
|||
|
||||
CREATE TABLE IF NOT EXISTS bounce (
|
||||
pk INTEGER PRIMARY KEY NOT NULL,
|
||||
member INTEGER NOT NULL UNIQUE,
|
||||
subscription INTEGER NOT NULL UNIQUE,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
last_bounce TEXT NOT NULL DEFAULT (datetime()),
|
||||
FOREIGN KEY (member) REFERENCES membership(pk) ON DELETE CASCADE
|
||||
FOREIGN KEY (subscription) REFERENCES subscription(pk) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS post_listpk_idx ON post(list);
|
||||
CREATE INDEX IF NOT EXISTS post_msgid_idx ON post(message_id);
|
||||
CREATE INDEX IF NOT EXISTS list_idx ON list(id);
|
||||
CREATE INDEX IF NOT EXISTS membership_idx ON membership(address);
|
||||
CREATE INDEX IF NOT EXISTS subscription_idx ON subscription(address);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS accept_candidate AFTER INSERT ON membership
|
||||
CREATE TRIGGER IF NOT EXISTS accept_candidate AFTER INSERT ON subscription
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE candidate_membership SET accepted = NEW.pk, last_modified = unixepoch()
|
||||
WHERE candidate_membership.list = NEW.list AND candidate_membership.address = NEW.address;
|
||||
UPDATE candidate_subscription SET accepted = NEW.pk, last_modified = unixepoch()
|
||||
WHERE candidate_subscription.list = NEW.list AND candidate_subscription.address = NEW.address;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS verify_candidate AFTER INSERT ON membership
|
||||
CREATE TRIGGER IF NOT EXISTS verify_candidate AFTER INSERT ON subscription
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE membership SET verified = BOOLEAN_FALSE(), last_modified = unixepoch()
|
||||
WHERE membership.pk = NEW.pk AND EXISTS (SELECT 1 FROM list WHERE pk = NEW.list AND verify = BOOLEAN_TRUE());
|
||||
UPDATE subscription SET verified = BOOLEAN_FALSE(), last_modified = unixepoch()
|
||||
WHERE subscription.pk = NEW.pk AND EXISTS (SELECT 1 FROM list WHERE pk = NEW.list AND verify = BOOLEAN_TRUE());
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS add_account AFTER INSERT ON account
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE subscription SET account = NEW.pk, last_modified = unixepoch()
|
||||
WHERE subscription.address = NEW.address;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS add_account_to_subscription AFTER INSERT ON subscription
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE subscription
|
||||
SET account = acc.pk,
|
||||
last_modified = unixepoch()
|
||||
FROM (SELECT * FROM account) AS acc
|
||||
WHERE subscription.account = acc.address;
|
||||
END;
|
||||
|
||||
update_last_modified(`list')
|
||||
|
||||
update_last_modified(`owner')
|
||||
|
||||
update_last_modified(`post_policy')
|
||||
update_last_modified(`subscribe_policy')
|
||||
update_last_modified(`membership')
|
||||
|
||||
update_last_modified(`subscription_policy')
|
||||
|
||||
update_last_modified(`subscription')
|
||||
|
||||
update_last_modified(`account')
|
||||
update_last_modified(`candidate_membership')
|
||||
|
||||
update_last_modified(`candidate_subscription')
|
||||
|
||||
update_last_modified(`templates')
|
||||
|
|
|
@ -54,7 +54,7 @@ fn test_authorizer() {
|
|||
pk: 0,
|
||||
list: 1,
|
||||
announce_only: false,
|
||||
subscriber_only: true,
|
||||
subscription_only: true,
|
||||
approval_needed: false,
|
||||
open: false,
|
||||
custom: false,
|
||||
|
@ -97,7 +97,7 @@ fn test_authorizer() {
|
|||
pk: 0,
|
||||
list: 1,
|
||||
announce_only: false,
|
||||
subscriber_only: true,
|
||||
subscription_only: true,
|
||||
approval_needed: false,
|
||||
open: false,
|
||||
custom: false,
|
||||
|
|
|
@ -65,7 +65,7 @@ fn test_error_queue() {
|
|||
pk: 0,
|
||||
list: foo_chat.pk(),
|
||||
announce_only: false,
|
||||
subscriber_only: true,
|
||||
subscription_only: true,
|
||||
approval_needed: false,
|
||||
open: false,
|
||||
custom: false,
|
||||
|
|
|
@ -225,7 +225,7 @@ fn test_smtp() {
|
|||
pk: 0,
|
||||
list: foo_chat.pk(),
|
||||
announce_only: false,
|
||||
subscriber_only: true,
|
||||
subscription_only: true,
|
||||
approval_needed: false,
|
||||
open: false,
|
||||
custom: false,
|
||||
|
@ -244,18 +244,19 @@ fn test_smtp() {
|
|||
.kind()
|
||||
{
|
||||
mailpot::ErrorKind::PostRejected(reason) => {
|
||||
trace!("Non-member post succesfully rejected: '{reason}'");
|
||||
trace!("Non-subscription post succesfully rejected: '{reason}'");
|
||||
}
|
||||
other => panic!("Got unexpected error: {}", other),
|
||||
}
|
||||
|
||||
db.add_member(
|
||||
db.add_subscription(
|
||||
foo_chat.pk(),
|
||||
ListMembership {
|
||||
ListSubscription {
|
||||
pk: 0,
|
||||
list: foo_chat.pk(),
|
||||
address: "japoeunp@hotmail.com".into(),
|
||||
name: Some("Jamaica Poe".into()),
|
||||
account: None,
|
||||
digest: false,
|
||||
verified: true,
|
||||
hide_address: false,
|
||||
|
@ -266,13 +267,14 @@ fn test_smtp() {
|
|||
},
|
||||
)
|
||||
.unwrap();
|
||||
db.add_member(
|
||||
db.add_subscription(
|
||||
foo_chat.pk(),
|
||||
ListMembership {
|
||||
ListSubscription {
|
||||
pk: 0,
|
||||
list: foo_chat.pk(),
|
||||
address: "manos@example.com".into(),
|
||||
name: Some("Manos Hands".into()),
|
||||
account: None,
|
||||
digest: false,
|
||||
verified: true,
|
||||
hide_address: false,
|
||||
|
@ -345,7 +347,7 @@ fn test_smtp_mailcrab() {
|
|||
pk: 0,
|
||||
list: foo_chat.pk(),
|
||||
announce_only: false,
|
||||
subscriber_only: true,
|
||||
subscription_only: true,
|
||||
approval_needed: false,
|
||||
open: false,
|
||||
custom: false,
|
||||
|
@ -363,17 +365,18 @@ fn test_smtp_mailcrab() {
|
|||
.kind()
|
||||
{
|
||||
mailpot::ErrorKind::PostRejected(reason) => {
|
||||
trace!("Non-member post succesfully rejected: '{reason}'");
|
||||
trace!("Non-subscription post succesfully rejected: '{reason}'");
|
||||
}
|
||||
other => panic!("Got unexpected error: {}", other),
|
||||
}
|
||||
db.add_member(
|
||||
db.add_subscription(
|
||||
foo_chat.pk(),
|
||||
ListMembership {
|
||||
ListSubscription {
|
||||
pk: 0,
|
||||
list: foo_chat.pk(),
|
||||
address: "japoeunp@hotmail.com".into(),
|
||||
name: Some("Jamaica Poe".into()),
|
||||
account: None,
|
||||
digest: false,
|
||||
verified: true,
|
||||
hide_address: false,
|
||||
|
@ -384,13 +387,14 @@ fn test_smtp_mailcrab() {
|
|||
},
|
||||
)
|
||||
.unwrap();
|
||||
db.add_member(
|
||||
db.add_subscription(
|
||||
foo_chat.pk(),
|
||||
ListMembership {
|
||||
ListSubscription {
|
||||
pk: 0,
|
||||
list: foo_chat.pk(),
|
||||
address: "manos@example.com".into(),
|
||||
name: Some("Manos Hands".into()),
|
||||
account: None,
|
||||
digest: false,
|
||||
verified: true,
|
||||
hide_address: false,
|
||||
|
|
|
@ -57,7 +57,7 @@ fn test_list_subscription() {
|
|||
pk: 0,
|
||||
list: foo_chat.pk(),
|
||||
announce_only: false,
|
||||
subscriber_only: true,
|
||||
subscription_only: true,
|
||||
approval_needed: false,
|
||||
open: false,
|
||||
custom: false,
|
||||
|
@ -66,7 +66,7 @@ fn test_list_subscription() {
|
|||
|
||||
assert_eq!(post_policy.pk(), 1);
|
||||
assert_eq!(db.error_queue().unwrap().len(), 0);
|
||||
assert_eq!(db.list_members(foo_chat.pk()).unwrap().len(), 0);
|
||||
assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 0);
|
||||
|
||||
let mut db = db.untrusted();
|
||||
|
||||
|
@ -114,7 +114,7 @@ MIME-Version: 1.0
|
|||
melib::Envelope::from_bytes(input_bytes_2, None).expect("Could not parse message");
|
||||
db.post(&envelope, input_bytes_2, /* dry_run */ false)
|
||||
.unwrap();
|
||||
assert_eq!(db.list_members(foo_chat.pk()).unwrap().len(), 1);
|
||||
assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 1);
|
||||
assert_eq!(db.error_queue().unwrap().len(), 1);
|
||||
let envelope =
|
||||
melib::Envelope::from_bytes(input_bytes_1, None).expect("Could not parse message");
|
||||
|
|
160
docs/mpot.1
160
docs/mpot.1
|
@ -134,26 +134,26 @@ Selects mailing list to operate on.
|
|||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.\fB
|
||||
.SS mpot list members
|
||||
.SS mpot list subscriptions
|
||||
.\fR
|
||||
.br
|
||||
|
||||
.br
|
||||
|
||||
List members of list.
|
||||
List subscriptions of list.
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.\fB
|
||||
.SS mpot list add-member
|
||||
.SS mpot list add-subscription
|
||||
.\fR
|
||||
.br
|
||||
|
||||
.br
|
||||
|
||||
mpot list add\-member \-\-address \fIADDRESS\fR [\-\-name \fINAME\fR] [\-\-digest \fIDIGEST\fR] [\-\-hide\-address \fIHIDE_ADDRESS\fR] [\-\-verified \fIVERIFIED\fR] [\-\-receive\-confirmation \fIRECEIVE_CONFIRMATION\fR] [\-\-receive\-duplicates \fIRECEIVE_DUPLICATES\fR] [\-\-receive\-own\-posts \fIRECEIVE_OWN_POSTS\fR] [\-\-enabled \fIENABLED\fR]
|
||||
mpot list add\-subscription \-\-address \fIADDRESS\fR [\-\-name \fINAME\fR] [\-\-digest \fIDIGEST\fR] [\-\-hide\-address \fIHIDE_ADDRESS\fR] [\-\-verified \fIVERIFIED\fR] [\-\-receive\-confirmation \fIRECEIVE_CONFIRMATION\fR] [\-\-receive\-duplicates \fIRECEIVE_DUPLICATES\fR] [\-\-receive\-own\-posts \fIRECEIVE_OWN_POSTS\fR] [\-\-enabled \fIENABLED\fR]
|
||||
.br
|
||||
|
||||
Add member to list.
|
||||
Add subscription to list.
|
||||
.TP
|
||||
\-\-address \fIADDRESS\fR
|
||||
E\-mail address.
|
||||
|
@ -226,32 +226,32 @@ Is subscription enabled.
|
|||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.\fB
|
||||
.SS mpot list remove-member
|
||||
.SS mpot list remove-subscription
|
||||
.\fR
|
||||
.br
|
||||
|
||||
.br
|
||||
|
||||
mpot list remove\-member \-\-address \fIADDRESS\fR
|
||||
mpot list remove\-subscription \-\-address \fIADDRESS\fR
|
||||
.br
|
||||
|
||||
Remove member from list.
|
||||
Remove subscription from list.
|
||||
.TP
|
||||
\-\-address \fIADDRESS\fR
|
||||
E\-mail address.
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.\fB
|
||||
.SS mpot list update-membership
|
||||
.SS mpot list update-subscription
|
||||
.\fR
|
||||
.br
|
||||
|
||||
.br
|
||||
|
||||
mpot list update\-membership [\-\-name \fINAME\fR] [\-\-digest \fIDIGEST\fR] [\-\-hide\-address \fIHIDE_ADDRESS\fR] [\-\-verified \fIVERIFIED\fR] [\-\-receive\-confirmation \fIRECEIVE_CONFIRMATION\fR] [\-\-receive\-duplicates \fIRECEIVE_DUPLICATES\fR] [\-\-receive\-own\-posts \fIRECEIVE_OWN_POSTS\fR] [\-\-enabled \fIENABLED\fR] \fIADDRESS\fR
|
||||
mpot list update\-subscription [\-\-name \fINAME\fR] [\-\-digest \fIDIGEST\fR] [\-\-hide\-address \fIHIDE_ADDRESS\fR] [\-\-verified \fIVERIFIED\fR] [\-\-receive\-confirmation \fIRECEIVE_CONFIRMATION\fR] [\-\-receive\-duplicates \fIRECEIVE_DUPLICATES\fR] [\-\-receive\-own\-posts \fIRECEIVE_OWN_POSTS\fR] [\-\-enabled \fIENABLED\fR] \fIADDRESS\fR
|
||||
.br
|
||||
|
||||
Update membership info.
|
||||
Update subscription info.
|
||||
.TP
|
||||
\fIADDRESS\fR
|
||||
Address to edit.
|
||||
|
@ -330,7 +330,7 @@ Is subscription enabled.
|
|||
|
||||
.br
|
||||
|
||||
mpot list add\-policy [\-\-announce\-only \fIANNOUNCE_ONLY\fR] [\-\-subscriber\-only \fISUBSCRIBER_ONLY\fR] [\-\-approval\-needed \fIAPPROVAL_NEEDED\fR] [\-\-open \fIOPEN\fR] [\-\-custom \fICUSTOM\fR]
|
||||
mpot list add\-policy [\-\-announce\-only \fIANNOUNCE_ONLY\fR] [\-\-subscription\-only \fISUBSCRIPTION_ONLY\fR] [\-\-approval\-needed \fIAPPROVAL_NEEDED\fR] [\-\-open \fIOPEN\fR] [\-\-custom \fICUSTOM\fR]
|
||||
.br
|
||||
|
||||
Add a new post policy.
|
||||
|
@ -338,11 +338,11 @@ Add a new post policy.
|
|||
\-\-announce\-only
|
||||
Only list owners can post.
|
||||
.TP
|
||||
\-\-subscriber\-only
|
||||
Only subscribers can post.
|
||||
\-\-subscription\-only
|
||||
Only subscriptions can post.
|
||||
.TP
|
||||
\-\-approval\-needed
|
||||
Subscribers can post. Other posts must be approved by list owners.
|
||||
Subscriptions can post. Other posts must be approved by list owners.
|
||||
.TP
|
||||
\-\-open
|
||||
Anyone can post without restrictions.
|
||||
|
@ -385,7 +385,7 @@ Send confirmation e\-mail when subscription is finalized.
|
|||
Anyone can subscribe without restrictions.
|
||||
.TP
|
||||
\-\-manual
|
||||
Only list owners can manually add subscribers.
|
||||
Only list owners can manually add subscriptions.
|
||||
.TP
|
||||
\-\-request
|
||||
Anyone can request to subscribe.
|
||||
|
@ -444,35 +444,35 @@ List owner primary key.
|
|||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.\fB
|
||||
.SS mpot list enable-membership
|
||||
.SS mpot list enable-subscription
|
||||
.\fR
|
||||
.br
|
||||
|
||||
.br
|
||||
|
||||
mpot list enable\-membership \fIADDRESS\fR
|
||||
mpot list enable\-subscription \fIADDRESS\fR
|
||||
.br
|
||||
|
||||
Alias for update\-membership \-\-enabled true.
|
||||
Alias for update\-subscription \-\-enabled true.
|
||||
.TP
|
||||
\fIADDRESS\fR
|
||||
Member address.
|
||||
Subscription address.
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.\fB
|
||||
.SS mpot list disable-membership
|
||||
.SS mpot list disable-subscription
|
||||
.\fR
|
||||
.br
|
||||
|
||||
.br
|
||||
|
||||
mpot list disable\-membership \fIADDRESS\fR
|
||||
mpot list disable\-subscription \fIADDRESS\fR
|
||||
.br
|
||||
|
||||
Alias for update\-membership \-\-enabled false.
|
||||
Alias for update\-subscription \-\-enabled false.
|
||||
.TP
|
||||
\fIADDRESS\fR
|
||||
Member address.
|
||||
Subscription address.
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.\fB
|
||||
|
@ -511,7 +511,7 @@ New request address local part. If empty, it defaults to \*(Aq+request\*(Aq.
|
|||
\-\-verify \fIVERIFY\fR
|
||||
Require verification of e\-mails for new subscriptions.
|
||||
|
||||
Subscriptions that are initiated from the member\*(Aqs address are verified automatically.
|
||||
Subscriptions that are initiated from the subscription\*(Aqs address are verified automatically.
|
||||
.br
|
||||
|
||||
.br
|
||||
|
@ -762,5 +762,115 @@ A postfix service is a daemon managed by the postfix process. Each entry in the
|
|||
The `master.cf` file is documented in [`master(5)`](https://www.postfix.org/master.5.html): <https://www.postfix.org/master.5.html>.
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.\fB
|
||||
.SS mpot accounts
|
||||
.\fR
|
||||
.br
|
||||
|
||||
.br
|
||||
|
||||
All Accounts.
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.\fB
|
||||
.SS mpot account-info
|
||||
.\fR
|
||||
.br
|
||||
|
||||
.br
|
||||
|
||||
mpot account\-info \fIADDRESS\fR
|
||||
.br
|
||||
|
||||
Account info.
|
||||
.TP
|
||||
\fIADDRESS\fR
|
||||
Account address.
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.\fB
|
||||
.SS mpot add-account
|
||||
.\fR
|
||||
.br
|
||||
|
||||
.br
|
||||
|
||||
mpot add\-account \-\-address \fIADDRESS\fR \-\-password \fIPASSWORD\fR [\-\-name \fINAME\fR] [\-\-public\-key \fIPUBLIC_KEY\fR] [\-\-enabled \fIENABLED\fR]
|
||||
.br
|
||||
|
||||
Add account.
|
||||
.TP
|
||||
\-\-address \fIADDRESS\fR
|
||||
E\-mail address.
|
||||
.TP
|
||||
\-\-password \fIPASSWORD\fR
|
||||
SSH public key for authentication.
|
||||
.TP
|
||||
\-\-name \fINAME\fR
|
||||
Name.
|
||||
.TP
|
||||
\-\-public\-key \fIPUBLIC_KEY\fR
|
||||
Public key.
|
||||
.TP
|
||||
\-\-enabled \fIENABLED\fR
|
||||
Is account enabled.
|
||||
.br
|
||||
|
||||
.br
|
||||
|
||||
.br
|
||||
[\fIpossible values: \fRtrue, false]
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.\fB
|
||||
.SS mpot remove-account
|
||||
.\fR
|
||||
.br
|
||||
|
||||
.br
|
||||
|
||||
mpot remove\-account \-\-address \fIADDRESS\fR
|
||||
.br
|
||||
|
||||
Remove account.
|
||||
.TP
|
||||
\-\-address \fIADDRESS\fR
|
||||
E\-mail address.
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.\fB
|
||||
.SS mpot update-account
|
||||
.\fR
|
||||
.br
|
||||
|
||||
.br
|
||||
|
||||
mpot update\-account [\-\-password \fIPASSWORD\fR] [\-\-name \fINAME\fR] [\-\-public\-key \fIPUBLIC_KEY\fR] [\-\-enabled \fIENABLED\fR] \fIADDRESS\fR
|
||||
.br
|
||||
|
||||
Update account info.
|
||||
.TP
|
||||
\fIADDRESS\fR
|
||||
Address to edit.
|
||||
.TP
|
||||
\-\-password \fIPASSWORD\fR
|
||||
Public key for authentication.
|
||||
.TP
|
||||
\-\-name \fINAME\fR
|
||||
Name.
|
||||
.TP
|
||||
\-\-public\-key \fIPUBLIC_KEY\fR
|
||||
Public key.
|
||||
.TP
|
||||
\-\-enabled \fIENABLED\fR
|
||||
Is account enabled.
|
||||
.br
|
||||
|
||||
.br
|
||||
|
||||
.br
|
||||
[\fIpossible values: \fRtrue, false]
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.SH AUTHORS
|
||||
Manos Pitsidianakis <el13635@mail.ntua.gr>
|
||||
|
|
|
@ -58,10 +58,10 @@ async fn main() {
|
|||
});
|
||||
|
||||
let conf4 = conf.clone();
|
||||
//get("/lists/<num>/members")]
|
||||
let lists_members = warp::path!("lists" / i64 / "members").map(move |list_pk| {
|
||||
//get("/lists/<num>/subscriptions")]
|
||||
let lists_subscriptions = warp::path!("lists" / i64 / "subscriptions").map(move |list_pk| {
|
||||
let db = Connection::open_db(conf4.clone()).unwrap();
|
||||
db.list_members(list_pk)
|
||||
db.list_subscriptions(list_pk)
|
||||
.ok()
|
||||
.map(|l| warp::reply::json(&l))
|
||||
.unwrap()
|
||||
|
@ -84,7 +84,7 @@ async fn main() {
|
|||
lists
|
||||
.or(policy)
|
||||
.or(lists_num)
|
||||
.or(lists_members)
|
||||
.or(lists_subscriptions)
|
||||
.or(lists_owners)
|
||||
.or(lists_owner_add),
|
||||
);
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
[package]
|
||||
name = "mailpot-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,422 @@
|
|||
/*
|
||||
* 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 {
|
||||
/// SSH signature.
|
||||
pub ssh_signature: String,
|
||||
/// User role.
|
||||
pub role: Role,
|
||||
/// Database primary key.
|
||||
pub pk: i64,
|
||||
/// Accounts's display name, optional.
|
||||
pub name: Option<String>,
|
||||
/// Account's e-mail address.
|
||||
pub address: String,
|
||||
/// GPG public key.
|
||||
pub public_key: Option<String>,
|
||||
/// SSH public key.
|
||||
pub password: String,
|
||||
/// Whether this account is enabled.
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl AuthUser<i64, Role> for User {
|
||||
fn get_id(&self) -> i64 {
|
||||
self.pk
|
||||
}
|
||||
|
||||
fn get_password_hash(&self) -> SecretVec<u8> {
|
||||
SecretVec::new(self.ssh_signature.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() {
|
||||
if let Err(err) = session.add_message(Message {
|
||||
message: "You are already logged in.".into(),
|
||||
level: Level::Info,
|
||||
}) {
|
||||
return err.into_response();
|
||||
}
|
||||
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 => state.site_title.as_ref(),
|
||||
page_title => "Log in",
|
||||
description => "",
|
||||
root_url_prefix => &root_url_prefix,
|
||||
ssh_challenge => token,
|
||||
timeout_left => timeout_left,
|
||||
current_user => auth.current_user,
|
||||
messages => session.drain_messages(),
|
||||
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(
|
||||
mut session: WritableSession,
|
||||
mut auth: AuthContext,
|
||||
Form(payload): Form<AuthFormPayload>,
|
||||
state: Arc<AppState>,
|
||||
) -> Result<Redirect, ResponseError> {
|
||||
if auth.current_user.as_ref().is_some() {
|
||||
session.add_message(Message {
|
||||
message: "You are already logged in.".into(),
|
||||
level: Level::Info,
|
||||
})?;
|
||||
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) {
|
||||
session.add_message(Message {
|
||||
message: "The token has expired. Please retry.".into(),
|
||||
level: Level::Error,
|
||||
})?;
|
||||
return Ok(Redirect::to("/login/"));
|
||||
} else {
|
||||
tok
|
||||
}
|
||||
} else {
|
||||
session.add_message(Message {
|
||||
message: "The token has expired. Please retry.".into(),
|
||||
level: Level::Error,
|
||||
})?;
|
||||
return Ok(Redirect::to("/login/"));
|
||||
};
|
||||
|
||||
drop(session);
|
||||
let db = Connection::open_db(state.conf.clone())?;
|
||||
let mut 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,
|
||||
};
|
||||
ssh_keygen(sig).await?;
|
||||
|
||||
let user = User {
|
||||
pk: acc.pk(),
|
||||
ssh_signature: payload.password,
|
||||
role: Role::User,
|
||||
public_key: std::mem::take(&mut acc.public_key),
|
||||
password: std::mem::take(&mut acc.password),
|
||||
name: std::mem::take(&mut acc.name),
|
||||
address: payload.address,
|
||||
enabled: acc.enabled,
|
||||
};
|
||||
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 mailpot_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<(), Box<dyn std::error::Error>> {
|
||||
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(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)
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
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 mailpot_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 mailpot_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,169 @@
|
|||
/*
|
||||
* 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::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ResponseError {
|
||||
pub inner: Box<dyn std::error::Error>,
|
||||
pub status: StatusCode,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ResponseError {
|
||||
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(fmt, "Inner: {}, status: {}", self.inner, self.status)
|
||||
}
|
||||
}
|
||||
|
||||
impl ResponseError {
|
||||
pub fn new(msg: String, status: StatusCode) -> Self {
|
||||
Self {
|
||||
inner: Box::<dyn std::error::Error + Send + Sync>::from(msg),
|
||||
status,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: Into<Box<dyn std::error::Error>>> From<E> for ResponseError {
|
||||
fn from(err: E) -> Self {
|
||||
Self {
|
||||
inner: err.into(),
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait IntoResponseError {
|
||||
fn with_status(self, status: StatusCode) -> ResponseError;
|
||||
}
|
||||
|
||||
impl<E: Into<Box<dyn std::error::Error>>> 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> {
|
||||
fn with_status(self, status: StatusCode) -> std::result::Result<R, ResponseError>;
|
||||
}
|
||||
|
||||
impl<R, 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 site_title: Cow<'static, str>,
|
||||
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,252 @@
|
|||
/*
|
||||
* 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 mailpot_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(),
|
||||
site_title: "mailing list archive".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 |session, user| settings(session, user, shared_state)
|
||||
}
|
||||
.layer(RequireAuth::login()))
|
||||
.post(
|
||||
{
|
||||
let shared_state = Arc::clone(&shared_state);
|
||||
move |session, auth, body| settings_post(session, auth, body, 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 |session, path, user, body| {
|
||||
user_list_subscription_post(session, 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(
|
||||
mut session: WritableSession,
|
||||
auth: AuthContext,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Html<String>, ResponseError> {
|
||||
let db = Connection::open_db(state.conf.clone())?;
|
||||
let lists_values = db.lists()?;
|
||||
let lists = lists_values
|
||||
.iter()
|
||||
.map(|list| {
|
||||
let months = db.months(list.pk)?;
|
||||
let posts = db.list_posts(list.pk, None)?;
|
||||
Ok(minijinja::context! {
|
||||
name => &list.name,
|
||||
posts => &posts,
|
||||
months => &months,
|
||||
body => &list.description.as_deref().unwrap_or_default(),
|
||||
root_url_prefix => &state.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 => state.site_title.as_ref(),
|
||||
page_title => Option::<&'static str>::None,
|
||||
description => "",
|
||||
lists => &lists,
|
||||
root_url_prefix => &state.root_url_prefix,
|
||||
current_user => auth.current_user,
|
||||
messages => session.drain_messages(),
|
||||
crumbs => crumbs,
|
||||
};
|
||||
Ok(Html(TEMPLATES.get_template("lists.html")?.render(context)?))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
mut session: WritableSession,
|
||||
Path(id): Path<i64>,
|
||||
auth: AuthContext,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Html<String>, ResponseError> {
|
||||
let db = Connection::open_db(state.conf.clone())?;
|
||||
let list = db.list(id)?;
|
||||
let post_policy = db.list_policy(list.pk)?;
|
||||
let subscription_policy = db.list_subscription_policy(list.pk)?;
|
||||
let months = db.months(list.pk)?;
|
||||
let user_context = auth
|
||||
.current_user
|
||||
.as_ref()
|
||||
.map(|user| db.list_subscription_by_address(id, &user.address).ok());
|
||||
|
||||
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 => &state.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 => state.site_title.as_ref(),
|
||||
page_title => &list.name,
|
||||
description => &list.description,
|
||||
post_policy => &post_policy,
|
||||
subscription_policy => &subscription_policy,
|
||||
preamble => true,
|
||||
months => &months,
|
||||
hists => &hist,
|
||||
posts => posts_ctx,
|
||||
body => &list.description.clone().unwrap_or_default(),
|
||||
root_url_prefix => &state.root_url_prefix,
|
||||
list => Value::from_object(MailingList::from(list)),
|
||||
current_user => auth.current_user,
|
||||
user_context => user_context,
|
||||
messages => session.drain_messages(),
|
||||
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(
|
||||
mut session: WritableSession,
|
||||
auth: AuthContext,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Html<String>, ResponseError> {
|
||||
let crumbs = vec![
|
||||
Crumb {
|
||||
label: "Lists".into(),
|
||||
url: "/".into(),
|
||||
},
|
||||
Crumb {
|
||||
label: "Help".into(),
|
||||
url: "/help/".into(),
|
||||
},
|
||||
];
|
||||
let context = minijinja::context! {
|
||||
title => state.site_title.as_ref(),
|
||||
page_title => "Help & Documentation",
|
||||
description => "",
|
||||
root_url_prefix => &state.root_url_prefix,
|
||||
current_user => auth.current_user,
|
||||
messages => session.drain_messages(),
|
||||
crumbs => crumbs,
|
||||
};
|
||||
Ok(Html(TEMPLATES.get_template("help.html")?.render(context)?))
|
||||
}
|
|
@ -0,0 +1,392 @@
|
|||
/*
|
||||
* 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::{AccountChangeset, ListSubscriptionChangeset},
|
||||
ListSubscription,
|
||||
};
|
||||
|
||||
pub async fn settings(
|
||||
mut session: WritableSession,
|
||||
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)?
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let list = db.list(s.list)?;
|
||||
|
||||
Ok((s, list))
|
||||
})
|
||||
.collect::<Result<
|
||||
Vec<(
|
||||
DbVal<mailpot::models::ListSubscription>,
|
||||
DbVal<mailpot::models::MailingList>,
|
||||
)>,
|
||||
mailpot::Error,
|
||||
>>()?;
|
||||
|
||||
let context = minijinja::context! {
|
||||
title => state.site_title.as_ref(),
|
||||
page_title => "Account settings",
|
||||
description => "",
|
||||
root_url_prefix => &root_url_prefix,
|
||||
user => user,
|
||||
subscriptions => subscriptions,
|
||||
current_user => user,
|
||||
messages => session.drain_messages(),
|
||||
crumbs => crumbs,
|
||||
};
|
||||
Ok(Html(
|
||||
TEMPLATES.get_template("settings.html")?.render(context)?,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||
pub enum ChangeSetting {
|
||||
Subscribe { list_pk: IntPOST },
|
||||
Unsubscribe { list_pk: IntPOST },
|
||||
ChangePassword { new: String },
|
||||
ChangePublicKey { new: String },
|
||||
// RemovePassword,
|
||||
RemovePublicKey,
|
||||
ChangeName { new: String },
|
||||
}
|
||||
|
||||
pub async fn settings_post(
|
||||
mut session: WritableSession,
|
||||
Extension(user): Extension<User>,
|
||||
Form(payload): Form<ChangeSetting>,
|
||||
state: Arc<AppState>,
|
||||
) -> Result<Redirect, ResponseError> {
|
||||
let mut 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)
|
||||
})?;
|
||||
|
||||
match payload {
|
||||
ChangeSetting::Subscribe {
|
||||
list_pk: IntPOST(list_pk),
|
||||
} => {
|
||||
let subscriptions = db
|
||||
.account_subscriptions(acc.pk())
|
||||
.with_status(StatusCode::BAD_REQUEST)?;
|
||||
if subscriptions.iter().any(|s| s.list == list_pk) {
|
||||
session.add_message(Message {
|
||||
message: "You are already subscribed to this list.".into(),
|
||||
level: Level::Info,
|
||||
})?;
|
||||
} else {
|
||||
db.add_subscription(
|
||||
list_pk,
|
||||
ListSubscription {
|
||||
pk: 0,
|
||||
list: list_pk,
|
||||
account: Some(acc.pk()),
|
||||
address: acc.address.clone(),
|
||||
name: acc.name.clone(),
|
||||
digest: false,
|
||||
enabled: true,
|
||||
verified: true,
|
||||
hide_address: false,
|
||||
receive_duplicates: false,
|
||||
receive_own_posts: false,
|
||||
receive_confirmation: false,
|
||||
},
|
||||
)?;
|
||||
session.add_message(Message {
|
||||
message: "You have subscribed to this list.".into(),
|
||||
level: Level::Success,
|
||||
})?;
|
||||
}
|
||||
}
|
||||
ChangeSetting::Unsubscribe {
|
||||
list_pk: IntPOST(list_pk),
|
||||
} => {
|
||||
let subscriptions = db
|
||||
.account_subscriptions(acc.pk())
|
||||
.with_status(StatusCode::BAD_REQUEST)?;
|
||||
if !subscriptions.iter().any(|s| s.list == list_pk) {
|
||||
session.add_message(Message {
|
||||
message: "You are already not subscribed to this list.".into(),
|
||||
level: Level::Info,
|
||||
})?;
|
||||
} else {
|
||||
let db = db.trusted();
|
||||
db.remove_subscription(list_pk, &acc.address)?;
|
||||
session.add_message(Message {
|
||||
message: "You have unsubscribed from this list.".into(),
|
||||
level: Level::Success,
|
||||
})?;
|
||||
}
|
||||
}
|
||||
ChangeSetting::ChangePassword { new } => {
|
||||
db.update_account(AccountChangeset {
|
||||
address: acc.address.clone(),
|
||||
name: None,
|
||||
public_key: None,
|
||||
password: Some(new.clone()),
|
||||
enabled: None,
|
||||
})
|
||||
.with_status(StatusCode::BAD_REQUEST)?;
|
||||
session.add_message(Message {
|
||||
message: "You have successfully updated your SSH public key.".into(),
|
||||
level: Level::Success,
|
||||
})?;
|
||||
let mut user = user.clone();
|
||||
user.password = new;
|
||||
state.insert_user(acc.pk(), user).await;
|
||||
}
|
||||
ChangeSetting::ChangePublicKey { new } => {
|
||||
db.update_account(AccountChangeset {
|
||||
address: acc.address.clone(),
|
||||
name: None,
|
||||
public_key: Some(Some(new.clone())),
|
||||
password: None,
|
||||
enabled: None,
|
||||
})
|
||||
.with_status(StatusCode::BAD_REQUEST)?;
|
||||
session.add_message(Message {
|
||||
message: "You have successfully updated your PGP public key.".into(),
|
||||
level: Level::Success,
|
||||
})?;
|
||||
let mut user = user.clone();
|
||||
user.public_key = Some(new);
|
||||
state.insert_user(acc.pk(), user).await;
|
||||
}
|
||||
ChangeSetting::RemovePublicKey => {
|
||||
db.update_account(AccountChangeset {
|
||||
address: acc.address.clone(),
|
||||
name: None,
|
||||
public_key: Some(None),
|
||||
password: None,
|
||||
enabled: None,
|
||||
})
|
||||
.with_status(StatusCode::BAD_REQUEST)?;
|
||||
session.add_message(Message {
|
||||
message: "You have successfully removed your PGP public key.".into(),
|
||||
level: Level::Success,
|
||||
})?;
|
||||
let mut user = user.clone();
|
||||
user.public_key = None;
|
||||
state.insert_user(acc.pk(), user).await;
|
||||
}
|
||||
ChangeSetting::ChangeName { new } => {
|
||||
let new = if new.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(new)
|
||||
};
|
||||
db.update_account(AccountChangeset {
|
||||
address: acc.address.clone(),
|
||||
name: Some(new.clone()),
|
||||
public_key: None,
|
||||
password: None,
|
||||
enabled: None,
|
||||
})
|
||||
.with_status(StatusCode::BAD_REQUEST)?;
|
||||
session.add_message(Message {
|
||||
message: "You have successfully updated your name.".into(),
|
||||
level: Level::Success,
|
||||
})?;
|
||||
let mut user = user.clone();
|
||||
user.name = new.clone();
|
||||
state.insert_user(acc.pk(), user).await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Redirect::to(&format!(
|
||||
"{}/settings/",
|
||||
&state.root_url_prefix
|
||||
)))
|
||||
}
|
||||
|
||||
pub async fn user_list_subscription(
|
||||
mut session: WritableSession,
|
||||
Extension(user): Extension<User>,
|
||||
Path(id): Path<i64>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Html<String>, ResponseError> {
|
||||
let root_url_prefix = &state.root_url_prefix;
|
||||
let db = Connection::open_db(state.conf.clone())?;
|
||||
let crumbs = vec![
|
||||
Crumb {
|
||||
label: "Lists".into(),
|
||||
url: "/".into(),
|
||||
},
|
||||
Crumb {
|
||||
label: "Settings".into(),
|
||||
url: "/settings/".into(),
|
||||
},
|
||||
Crumb {
|
||||
label: "List Subscription".into(),
|
||||
url: format!("/settings/list/{}/", id).into(),
|
||||
},
|
||||
];
|
||||
let list = db.list(id)?;
|
||||
let 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_subscription(
|
||||
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 => state.site_title.as_ref(),
|
||||
page_title => "Subscription settings",
|
||||
description => "",
|
||||
root_url_prefix => &root_url_prefix,
|
||||
user => user,
|
||||
list => list,
|
||||
subscription => subscription,
|
||||
current_user => user,
|
||||
messages => session.drain_messages(),
|
||||
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(
|
||||
mut session: WritableSession,
|
||||
Path(id): Path<i64>,
|
||||
Extension(user): Extension<User>,
|
||||
Form(payload): Form<SubscriptionFormPayload>,
|
||||
state: Arc<AppState>,
|
||||
) -> Result<Redirect, ResponseError> {
|
||||
let mut db = Connection::open_db(state.conf.clone())?;
|
||||
|
||||
let _list = db.list(id).with_status(StatusCode::NOT_FOUND)?;
|
||||
|
||||
let 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_subscription(id, subscriptions[0].pk())
|
||||
.with_status(StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let SubscriptionFormPayload {
|
||||
digest,
|
||||
hide_address,
|
||||
receive_duplicates,
|
||||
receive_own_posts,
|
||||
receive_confirmation,
|
||||
} = payload;
|
||||
|
||||
let cset = ListSubscriptionChangeset {
|
||||
list: s.list,
|
||||
address: std::mem::take(&mut s.address),
|
||||
account: None,
|
||||
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_subscription(cset)
|
||||
.with_status(StatusCode::BAD_REQUEST)?;
|
||||
|
||||
session.add_message(Message {
|
||||
message: "Settings saved successfully.".into(),
|
||||
level: Level::Success,
|
||||
})?;
|
||||
|
||||
Ok(Redirect::to(&format!(
|
||||
"{}/settings/list/{id}/",
|
||||
&state.root_url_prefix
|
||||
)))
|
||||
}
|
|
@ -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 class="key-or-sig-input" 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,721 @@
|
|||
<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;
|
||||
}
|
||||
|
||||
code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
input {
|
||||
border: none;
|
||||
}
|
||||
|
||||
input, button, textarea, select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* Create a root stacking context (only when using frameworks like Next.js) */
|
||||
#__next {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--text-primary: #1b1b1b;
|
||||
--text-secondary: #4e4e4e;
|
||||
--text-inactive: #9e9e9ea6;
|
||||
--text-link: #0069c2;
|
||||
--text-invert: #fff;
|
||||
--background-primary: #fff;
|
||||
--background-secondary: #f9f9fb;
|
||||
--background-tertiary: #fff;
|
||||
--background-toc-active: #ebeaea;
|
||||
--background-mark-yellow: #c7b70066;
|
||||
--background-mark-green: #00d06166;
|
||||
--background-information: #0085f21a;
|
||||
--background-warning: #ff2a511a;
|
||||
--background-critical: #d300381a;
|
||||
--background-success: #0079361a;
|
||||
--border-primary: #cdcdcd;
|
||||
--border-secondary: #cdcdcd;
|
||||
--button-primary-default: #1b1b1b;
|
||||
--button-primary-hover: #696969;
|
||||
--button-primary-active: #9e9e9e;
|
||||
--button-primary-inactive: #1b1b1b;
|
||||
--button-secondary-default: #fff;
|
||||
--button-secondary-hover: #cdcdcd;
|
||||
--button-secondary-active: #cdcdcd;
|
||||
--button-secondary-inactive: #f9f9fb;
|
||||
--button-secondary-border-focus: #0085f2;
|
||||
--button-secondary-border-red: #ff97a0;
|
||||
--button-secondary-border-red-focus: #ffd9dc;
|
||||
--icon-primary: #696969;
|
||||
--icon-secondary: #b3b3b3;
|
||||
--icon-information: #0085f2;
|
||||
--icon-warning: #ff2a51;
|
||||
--icon-critical: #d30038;
|
||||
--icon-success: #007936;
|
||||
--accent-primary: #0085f2;
|
||||
--accent-primary-engage: #0085f21a;
|
||||
--accent-secondary: #0085f2;
|
||||
--accent-tertiary: #0085f21a;
|
||||
--shadow-01: 0 1px 2px rgba(43,42,51,.05);
|
||||
--shadow-02: 0 1px 6px rgba(43,42,51,.1);
|
||||
--focus-01: 0 0 0 3px rgba(0,144,237,.4);
|
||||
--field-focus-border: #0085f2;
|
||||
--code-token-tag: #0069c2;
|
||||
--code-token-punctuation: #858585;
|
||||
--code-token-attribute-name: #d30038;
|
||||
--code-token-attribute-value: #007936;
|
||||
--code-token-comment: #858585;
|
||||
--code-token-default: #1b1b1b;
|
||||
--code-token-selector: #872bff;
|
||||
--code-background-inline: #f2f1f1;
|
||||
--code-background-block: #f2f1f1;
|
||||
--notecard-link-color: #343434;
|
||||
--scrollbar-bg: transparent;
|
||||
--scrollbar-color: #00000040;
|
||||
--category-color: #0085f2;
|
||||
--category-color-background: #0085f210;
|
||||
--code-color: #5e9eff;
|
||||
--mark-color: #dce2f2;
|
||||
--blend-color: #fff80;
|
||||
--text-primary-red: #d30038;
|
||||
--text-primary-green: #007936;
|
||||
--text-primary-blue: #0069c2;
|
||||
--text-primary-yellow: #746a00;
|
||||
--form-invalid-color: #d30038;
|
||||
--form-invalid-focus-color: #ff2a51;
|
||||
--form-invalid-focus-effect-color: #ff2a5133;
|
||||
color-scheme: light;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text-primary: #fff;
|
||||
--text-secondary: #cdcdcd;
|
||||
--text-inactive: #cdcdcda6;
|
||||
--text-link: #8cb4ff;
|
||||
--text-invert: #1b1b1b;
|
||||
--background-primary: #1b1b1b;
|
||||
--background-secondary: #343434;
|
||||
--background-tertiary: #4e4e4e;
|
||||
--background-toc-active: #343434;
|
||||
--background-mark-yellow: #c7b70066;
|
||||
--background-mark-green: #00d06166;
|
||||
--background-information: #0085f21a;
|
||||
--background-warning: #ff2a511a;
|
||||
--background-critical: #d300381a;
|
||||
--background-success: #0079361a;
|
||||
--border-primary: #858585;
|
||||
--border-secondary: #696969;
|
||||
--button-primary-default: #fff;
|
||||
--button-primary-hover: #cdcdcd;
|
||||
--button-primary-active: #9e9e9e;
|
||||
--button-primary-inactive: #fff;
|
||||
--button-secondary-default: #4e4e4e;
|
||||
--button-secondary-hover: #858585;
|
||||
--button-secondary-active: #9e9e9e;
|
||||
--button-secondary-inactive: #4e4e4e;
|
||||
--button-secondary-border-focus: #0085f2;
|
||||
--button-secondary-border-red: #ff97a0;
|
||||
--button-secondary-border-red-focus: #ffd9dc;
|
||||
--icon-primary: #fff;
|
||||
--icon-secondary: #b3b3b3;
|
||||
--icon-information: #5e9eff;
|
||||
--icon-warning: #afa100;
|
||||
--icon-critical: #ff707f;
|
||||
--icon-success: #00b755;
|
||||
--accent-primary: #5e9eff;
|
||||
--accent-primary-engage: #5e9eff1a;
|
||||
--accent-secondary: #5e9eff;
|
||||
--accent-tertiary: #0085f21a;
|
||||
--shadow-01: 0 1px 2px rgba(251,251,254,.2);
|
||||
--shadow-02: 0 1px 6px rgba(251,251,254,.2);
|
||||
--focus-01: 0 0 0 3px rgba(251,251,254,.5);
|
||||
--field-focus-border: #fff;
|
||||
--notecard-link-color: #e2e2e2;
|
||||
--scrollbar-bg: transparent;
|
||||
--scrollbar-color: #ffffff40;
|
||||
--category-color: #8cb4ff;
|
||||
--category-color-background: #8cb4ff70;
|
||||
--code-color: #c1cff1;
|
||||
--mark-color: #004d92;
|
||||
--blend-color: #00080;
|
||||
--text-primary-red: #ff97a0;
|
||||
--text-primary-green: #00d061;
|
||||
--text-primary-blue: #8cb4ff;
|
||||
--text-primary-yellow: #c7b700;
|
||||
--collections-link: #ff97a0;
|
||||
--collections-header: #40000a;
|
||||
--collections-mandala: #9e0027;
|
||||
--collections-icon: #d30038;
|
||||
--updates-link: #8cb4ff;
|
||||
--updates-header: #000;
|
||||
--updates-mandala: #c1cff1;
|
||||
--updates-icon: #8cb4ff;
|
||||
--form-limit-color: #9e9e9e;
|
||||
--form-limit-color-emphasis: #b3b3b3;
|
||||
--form-invalid-color: #ff97a0;
|
||||
--form-invalid-focus-color: #ff707f;
|
||||
--form-invalid-focus-effect-color: #ff707f33;
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
body>main.layout {
|
||||
width: 100%;
|
||||
height: 99%;
|
||||
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;
|
||||
border-top: 2px inset;
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
|
||||
main.layout>footer>* {
|
||||
margin-block-start: 1rem;
|
||||
margin-inline-start: 1rem;
|
||||
margin-block-end: 1rem;
|
||||
}
|
||||
|
||||
main.layout>div.header>h1 {
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
main.layout>div.header>h2 {
|
||||
margin-inline-start: 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 0px;
|
||||
margin-inline-start: 1rem;
|
||||
}
|
||||
|
||||
nav.breadcrumbs ol {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.crumb, .crumb>a {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.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;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
ul.messagelist {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
ul.messagelist:not(:empty) {
|
||||
margin-block-end: 0.5rem;
|
||||
}
|
||||
|
||||
ul.messagelist>li {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--message-background);
|
||||
border: .1rem solid var(--border-secondary);
|
||||
border-radius: 0.2rem;
|
||||
font-weight: medium;
|
||||
margin-block-end: 1.0rem;
|
||||
}
|
||||
|
||||
ul.messagelist>li>span.label {
|
||||
text-transform: capitalize;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
ul.messagelist>li.error {
|
||||
--message-background: var(--background-critical);
|
||||
}
|
||||
|
||||
ul.messagelist>li.success {
|
||||
--message-background: var(--background-success);
|
||||
}
|
||||
|
||||
ul.messagelist>li.warning {
|
||||
--message-background: var(--background-warning);
|
||||
}
|
||||
|
||||
ul.messagelist>li.info {
|
||||
--message-background: var(--background-information);
|
||||
}
|
||||
|
||||
div.preamble {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
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.entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
div.entries>div.entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
div.posts>div.entry>span.subject {
|
||||
font-size: larger;
|
||||
}
|
||||
|
||||
div.entries>div.entry>span.metadata {
|
||||
color: GrayText;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
div.entries>div.entry span.value {
|
||||
max-width: 44ch;
|
||||
display: inline-block;
|
||||
white-space: break-spaces;
|
||||
word-wrap: anywhere;
|
||||
word-break: break-all;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
div.entries>div.entry span.value.empty {
|
||||
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;
|
||||
}
|
||||
|
||||
textarea.key-or-sig-input {
|
||||
font-family: monospace;
|
||||
font-size: 0.5rem;
|
||||
font-weight: medium;
|
||||
width: auto;
|
||||
height: 29rem;
|
||||
max-width: min(71ch, 75vw);
|
||||
overflow-wrap: break-word;
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
line-height: 1rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
textarea.key-or-sig-input.wrap {
|
||||
word-wrap: anywhere;
|
||||
word-break: break-all;
|
||||
white-space: break-spaces;
|
||||
}
|
||||
|
||||
.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, 1rem);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
form.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 98vw;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
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: auto;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
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;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
form.settings-form>fieldset>div>label:last-child {
|
||||
padding: 1rem 0 1rem 1rem;
|
||||
flex-grow: 2;
|
||||
max-width: max-content;
|
||||
}
|
||||
|
||||
form.settings-form>fieldset>div>label:first-child {
|
||||
padding: 1rem 1rem 1rem 0rem;
|
||||
flex-grow: 2;
|
||||
max-width: max-content;
|
||||
}
|
||||
|
||||
form.settings-form>fieldset>div>:not(label):not(input) {
|
||||
flex-grow: 8;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
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: 3px;
|
||||
background-color: ButtonFace;
|
||||
cursor: default;
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
padding: .5rem;
|
||||
min-width: 10rem;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
ol.list {
|
||||
list-style: decimal outside;
|
||||
padding-inline-start: 4rem;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,6 @@
|
|||
<footer>
|
||||
<p>Generated by <a href="https://github.com/meli/mailpot">mailpot</a>.</p>
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,26 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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" %}
|
||||
{% if messages %}
|
||||
<ul class="messagelist">
|
||||
{% for message in messages %}
|
||||
<li class="{{ message.level|lower }}">
|
||||
<span class="label">{{ message.level }}: </span>{{ message.message }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
|
@ -0,0 +1,5 @@
|
|||
{% include "header.html" %}
|
||||
<div class="body body-grid">
|
||||
{{ body }}
|
||||
</div>
|
||||
{% include "footer.html" %}
|
|
@ -0,0 +1,11 @@
|
|||
{% include "header.html" %}
|
||||
<div class="entry">
|
||||
<div class="body">
|
||||
<ul>
|
||||
{% for l in lists %}
|
||||
<li><a href="{{ root_url_prefix|safe }}/lists/{{ l.list.pk }}/">{{ l.list.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% include "footer.html" %}
|
|
@ -0,0 +1,102 @@
|
|||
{% include "header.html" %}
|
||||
<div class="body">
|
||||
<p>List Description: {{ list.description if list.description else "None" }}</p>
|
||||
<br />
|
||||
{% if current_user and not post_policy.no_subscriptions and subscription_policy.open %}
|
||||
{% if user_context %}
|
||||
<form method="post" action="{{ root_url_prefix }}/settings/" class="settings-form">
|
||||
<input type="hidden" name="type", value="unsubscribe">
|
||||
<input type="hidden" name="list_pk", value="{{ list.pk }}">
|
||||
<input type="submit" name="unsubscribe" value="Unsubscribe as {{ current_user.address }}">
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{{ root_url_prefix }}/settings/" class="settings-form">
|
||||
<input type="hidden" name="type", value="subscribe">
|
||||
<input type="hidden" name="list_pk", value="{{ list.pk }}">
|
||||
<input type="submit" name="subscribe" value="Subscribe as {{ current_user.address }}">
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if preamble %}
|
||||
<hr />
|
||||
<div id="preamble" class="preamble">
|
||||
{% if preamble.custom %}
|
||||
{{ preamble.custom|safe }}
|
||||
{% else %}
|
||||
{% if not post_policy.no_subscriptions %}
|
||||
<h3 id="subscribe">Subscribe</h3>
|
||||
{% set subscription_mailto=list.subscription_mailto() %}
|
||||
{% if subscription_mailto %}
|
||||
{% if subscription_mailto.subject %}
|
||||
<p>
|
||||
<a href="mailto:{{ subscription_mailto.address|safe }}?subject={{ subscription_mailto.subject|safe }}"><code>{{ subscription_mailto.address }}</code></a> with the following subject: <code>{{ subscription_mailto.subject}}</code>
|
||||
</p>
|
||||
{% else %}
|
||||
<p>
|
||||
<a href="mailto:{{ subscription_mailto.address|safe }}"><code>{{ subscription_mailto.address }}</code></a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p>List is not open for subscriptions.</p>
|
||||
{% endif %}
|
||||
|
||||
{% set unsubscription_mailto=list.unsubscription_mailto() %}
|
||||
{% if unsubscription_mailto %}
|
||||
<h3 id="unsubscribe">Unsubscribe</h3>
|
||||
{% if unsubscription_mailto.subject %}
|
||||
<p>
|
||||
<a href="mailto:{{ unsubscription_mailto.address|safe }}?subject={{ unsubscription_mailto.subject|safe }}"><code>{{ unsubscription_mailto.address }}</code></a> with the following subject: <code>{{unsubscription_mailto.subject}}</code>
|
||||
</p>
|
||||
{% else %}
|
||||
<p>
|
||||
<a href="mailto:{{ unsubscription_mailto.address|safe }}"><code>{{ unsubscription_mailto.address }}</code></a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<h3 id="post">Post</h3>
|
||||
{% 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.subscription_only %}
|
||||
<p>List is <em>subscription-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 />
|
||||
{% else %}
|
||||
<hr />
|
||||
{% endif %}
|
||||
<div class="list">
|
||||
<h3 id="calendar">Calendar</h3>
|
||||
<div class="calendar">
|
||||
{%- from "calendar.html" import cal %}
|
||||
{% for date in months %}
|
||||
{{ cal(date, hists, root_url_prefix, list.pk) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<hr />
|
||||
<h3 id="posts">Posts</h3>
|
||||
<div class="posts entries">
|
||||
<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.list.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% include "footer.html" %}
|
|
@ -0,0 +1,17 @@
|
|||
<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>
|
||||
{% if page_title %}
|
||||
<h2>{{ page_title }}</h2>
|
||||
{% endif %}
|
||||
<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">
|
||||
<h3>{{ trimmed_subject }}</h3>
|
||||
<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,83 @@
|
|||
{% include "header.html" %}
|
||||
<div class="body body-grid">
|
||||
<h3>Your account</h3>
|
||||
<div class="entries">
|
||||
<div class="entry">
|
||||
<span>Display name: <span class="value{% if not user.name %} empty{% endif %}">{{ user.name if user.name else "None" }}</span></span>
|
||||
</div>
|
||||
<div class="entry">
|
||||
<span>Address: <span class="value">{{ user.address }}</span></span>
|
||||
</div>
|
||||
<div class="entry">
|
||||
<span>PGP public key: <span class="value{% if not user.public_key %} empty{% endif %}">{{ user.public_key if user.public_key else "None." }}</span></span>
|
||||
</div>
|
||||
<div class="entry">
|
||||
<span>SSH public key: <span class="value{% if not user.password %} empty{% endif %}">{{ user.password if user.password else "None." }}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>List Subscriptions</h4>
|
||||
<div class="entries">
|
||||
<p>{{ subscriptions | length }} subscription(s)</p>
|
||||
{% for (s, list) in subscriptions %}
|
||||
<div class="entry">
|
||||
<span class="subject"><a href="{{ root_url_prefix }}/settings/list/{{ s.list }}/">{{ list.name }}</a></span>
|
||||
<!-- span class="metadata">📆 <span>{{ s.created }}</span></span -->
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<h4>Account Settings</h4>
|
||||
<form method="post" action="{{ root_url_prefix }}/settings/" class="settings-form">
|
||||
<input type="hidden" name="type", value="change-name">
|
||||
<fieldset>
|
||||
<legend>Change display name</legend>
|
||||
|
||||
<div>
|
||||
<label for="id_name">New name:</label>
|
||||
<input type="text" name="new" id="id_name" value="{{ user.name if user.name else "" }}">
|
||||
</div>
|
||||
</fieldset>
|
||||
<input type="submit" name="change" value="Change">
|
||||
</form>
|
||||
|
||||
<form method="post" action="{{ root_url_prefix }}/settings/" class="settings-form">
|
||||
<input type="hidden" name="type", value="change-password">
|
||||
<fieldset>
|
||||
<legend>Change SSH public key</legend>
|
||||
|
||||
<div>
|
||||
<label for="id_ssh_public_key">New SSH public key:</label>
|
||||
<textarea class="key-or-sig-input wrap" required="" cols="15" rows="5" name="new" id="id_ssh_public_key">{{ user.password if user.password else "" }}</textarea>
|
||||
</div>
|
||||
</fieldset>
|
||||
<input type="submit" name="change" value="Change">
|
||||
</form>
|
||||
|
||||
<form method="post" action="{{ root_url_prefix }}/settings/" class="settings-form">
|
||||
<input type="hidden" name="type", value="change-public-key">
|
||||
<fieldset>
|
||||
<legend>Change PGP public key</legend>
|
||||
|
||||
<div>
|
||||
<label for="id_public_key">New PGP public key:</label>
|
||||
<textarea class="key-or-sig-input wrap" required="" cols="15" rows="5" name="new" id="id_public_key">{{ user.public_key if user.public_key else "" }}</textarea>
|
||||
</div>
|
||||
</fieldset>
|
||||
<input type="submit" name="change-public-key" value="Change">
|
||||
</form>
|
||||
|
||||
<form method="post" action="{{ root_url_prefix }}/settings/" class="settings-form">
|
||||
<input type="hidden" name="type", value="remove-public-key">
|
||||
<fieldset>
|
||||
<legend>Remove PGP public key</legend>
|
||||
|
||||
<div>
|
||||
<input type="checkbox" required="" name="remove-public-keyim-sure" id="remove-public-key-im-sure">
|
||||
<label for="remove-public-key-im-sure">I am certain I want to remove my PGP public key.</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<input type="submit" name="remove-public-key" value="Remove">
|
||||
</form>
|
||||
</div>
|
||||
{% include "footer.html" %}
|
|
@ -0,0 +1,57 @@
|
|||
{% 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>
|
||||
<form method="post" action="{{ root_url_prefix }}/settings/" class="settings-form">
|
||||
<fieldset>
|
||||
<input type="hidden" name="type", value="unsubscribe">
|
||||
<input type="hidden" name="list_pk", value="{{ list.pk }}">
|
||||
<legend>Unsubscribe</legend>
|
||||
<input type="checkbox" required="" name="im-sure" id="unsubscribe-im-sure">
|
||||
<label for="unsubscribe-im-sure">I am certain I want to unsubscribe.</label>
|
||||
</fieldset>
|
||||
<input type="submit" name="subscribe" value="Unsubscribe">
|
||||
</form>
|
||||
</div>
|
||||
{% include "footer.html" %}
|
|
@ -0,0 +1,278 @@
|
|||
/*
|
||||
* 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 {
|
||||
"subscription_mailto" => {
|
||||
Ok(Value::from_serializable(&self.inner.subscription_mailto()))
|
||||
}
|
||||
"unsubscription_mailto" => Ok(Value::from_serializable(
|
||||
&self.inner.unsubscription_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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Hash, Copy, Clone, serde::Deserialize, serde::Serialize)]
|
||||
pub enum Level {
|
||||
Success,
|
||||
#[default]
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Hash, Clone, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Message {
|
||||
pub message: Cow<'static, str>,
|
||||
#[serde(default)]
|
||||
pub level: Level,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
const MESSAGE_KEY: &str = "session-message";
|
||||
}
|
||||
|
||||
pub trait SessionMessages {
|
||||
fn drain_messages(&mut self) -> Vec<Message>;
|
||||
fn add_message(&mut self, _: Message) -> Result<(), ResponseError>;
|
||||
}
|
||||
|
||||
impl SessionMessages for WritableSession {
|
||||
fn drain_messages(&mut self) -> Vec<Message> {
|
||||
let ret = self.get(Message::MESSAGE_KEY).unwrap_or_default();
|
||||
self.remove(Message::MESSAGE_KEY);
|
||||
ret
|
||||
}
|
||||
|
||||
fn add_message(&mut self, message: Message) -> Result<(), ResponseError> {
|
||||
let mut messages: Vec<Message> = self.get(Message::MESSAGE_KEY).unwrap_or_default();
|
||||
messages.push(message);
|
||||
self.insert(Message::MESSAGE_KEY, messages)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Hash)]
|
||||
#[repr(transparent)]
|
||||
pub struct IntPOST(pub i64);
|
||||
|
||||
impl serde::Serialize for IntPOST {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_i64(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for IntPOST {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct IntVisitor;
|
||||
|
||||
impl<'de> serde::de::Visitor<'de> for IntVisitor {
|
||||
type Value = IntPOST;
|
||||
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("Int as a number or string")
|
||||
}
|
||||
|
||||
fn visit_i64<E>(self, int: i64) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(IntPOST(int))
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, int: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
int.parse().map(IntPOST).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(IntVisitor)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue