From f3c05dae62ffe6cf73b74e3cbf7be1116be8760c Mon Sep 17 00:00:00 2001 From: Violet White Date: Sun, 12 Jan 2020 13:41:35 -0500 Subject: [PATCH] Email blocklisting (#718) * Interface complete for the email blacklisting * Everything seems to work * Neutralize language * fix clippy warnings * Add missing spaces * Added matching test * Correct primary key datatype for postgresql * Address review comments * Add placeholder when empty. Fix missing 'i' --- Cargo.lock | 1 + .../2020-01-05-232816_add_blocklist/down.sql | 3 + .../2020-01-05-232816_add_blocklist/up.sql | 6 + .../2020-01-05-232816_add_blocklist/down.sql | 3 + .../2020-01-05-232816_add_blocklist/up.sql | 6 + plume-models/Cargo.toml | 1 + plume-models/src/blocklisted_emails.rs | 142 ++++++++++++++++++ plume-models/src/lib.rs | 4 + plume-models/src/schema.rs | 9 ++ plume-models/src/users.rs | 9 +- src/main.rs | 3 + src/routes/instance.rs | 69 ++++++++- src/routes/user.rs | 17 ++- templates/instance/admin.rs.html | 1 + templates/instance/emailblocklist.rs.html | 71 +++++++++ templates/instance/list.rs.html | 1 + templates/instance/users.rs.html | 1 + 17 files changed, 341 insertions(+), 6 deletions(-) create mode 100644 migrations/postgres/2020-01-05-232816_add_blocklist/down.sql create mode 100644 migrations/postgres/2020-01-05-232816_add_blocklist/up.sql create mode 100644 migrations/sqlite/2020-01-05-232816_add_blocklist/down.sql create mode 100644 migrations/sqlite/2020-01-05-232816_add_blocklist/up.sql create mode 100644 plume-models/src/blocklisted_emails.rs create mode 100644 templates/instance/emailblocklist.rs.html diff --git a/Cargo.lock b/Cargo.lock index 8a02b823..3a164602 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2221,6 +2221,7 @@ dependencies = [ "diesel 1.4.2 (registry+https://github.com/rust-lang/crates.io-index)", "diesel-derive-newtype 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", "diesel_migrations 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "glob 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", "guid-create 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)", "heck 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", "itertools 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)", diff --git a/migrations/postgres/2020-01-05-232816_add_blocklist/down.sql b/migrations/postgres/2020-01-05-232816_add_blocklist/down.sql new file mode 100644 index 00000000..96eb27da --- /dev/null +++ b/migrations/postgres/2020-01-05-232816_add_blocklist/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` + +drop table email_blocklist; diff --git a/migrations/postgres/2020-01-05-232816_add_blocklist/up.sql b/migrations/postgres/2020-01-05-232816_add_blocklist/up.sql new file mode 100644 index 00000000..57ba05a2 --- /dev/null +++ b/migrations/postgres/2020-01-05-232816_add_blocklist/up.sql @@ -0,0 +1,6 @@ +-- Your SQL goes here +CREATE TABLE email_blocklist(id SERIAL PRIMARY KEY, + email_address TEXT UNIQUE, + note TEXT, + notify_user BOOLEAN DEFAULT FALSE, + notification_text TEXT); diff --git a/migrations/sqlite/2020-01-05-232816_add_blocklist/down.sql b/migrations/sqlite/2020-01-05-232816_add_blocklist/down.sql new file mode 100644 index 00000000..96eb27da --- /dev/null +++ b/migrations/sqlite/2020-01-05-232816_add_blocklist/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` + +drop table email_blocklist; diff --git a/migrations/sqlite/2020-01-05-232816_add_blocklist/up.sql b/migrations/sqlite/2020-01-05-232816_add_blocklist/up.sql new file mode 100644 index 00000000..dadd1446 --- /dev/null +++ b/migrations/sqlite/2020-01-05-232816_add_blocklist/up.sql @@ -0,0 +1,6 @@ +-- Your SQL goes here +CREATE TABLE email_blocklist(id INTEGER PRIMARY KEY, + email_address TEXT UNIQUE, + note TEXT, + notify_user BOOLEAN DEFAULT FALSE, + notification_text TEXT); diff --git a/plume-models/Cargo.toml b/plume-models/Cargo.toml index b760349e..f1ef4faf 100644 --- a/plume-models/Cargo.toml +++ b/plume-models/Cargo.toml @@ -28,6 +28,7 @@ webfinger = "0.4.1" whatlang = "0.7.1" shrinkwraprs = "0.2.1" diesel-derive-newtype = "0.1.2" +glob = "0.3.0" [dependencies.chrono] features = ["serde"] diff --git a/plume-models/src/blocklisted_emails.rs b/plume-models/src/blocklisted_emails.rs new file mode 100644 index 00000000..71d1ec79 --- /dev/null +++ b/plume-models/src/blocklisted_emails.rs @@ -0,0 +1,142 @@ +use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl, TextExpressionMethods}; +use glob::Pattern; + +use schema::email_blocklist; +use {Connection, Error, Result}; + +#[derive(Clone, Queryable, Identifiable)] +#[table_name = "email_blocklist"] +pub struct BlocklistedEmail { + pub id: i32, + pub email_address: String, + pub note: String, + pub notify_user: bool, + pub notification_text: String, +} + +#[derive(Insertable, FromForm)] +#[table_name = "email_blocklist"] +pub struct NewBlocklistedEmail { + pub email_address: String, + pub note: String, + pub notify_user: bool, + pub notification_text: String, +} + +impl BlocklistedEmail { + insert!(email_blocklist, NewBlocklistedEmail); + get!(email_blocklist); + find_by!(email_blocklist, find_by_id, id as i32); + pub fn delete_entries(conn: &Connection, ids: Vec) -> Result { + use diesel::delete; + for i in ids { + let be: BlocklistedEmail = BlocklistedEmail::find_by_id(&conn, i)?; + delete(&be).execute(conn)?; + } + Ok(true) + } + pub fn find_for_domain(conn: &Connection, domain: &str) -> Result> { + let effective = format!("%@{}", domain); + email_blocklist::table + .filter(email_blocklist::email_address.like(effective)) + .load::(conn) + .map_err(Error::from) + } + pub fn matches_blocklist(conn: &Connection, email: &str) -> Result> { + let mut result = email_blocklist::table.load::(conn)?; + for i in result.drain(..) { + if let Ok(x) = Pattern::new(&i.email_address) { + if x.matches(email) { + return Ok(Some(i)); + } + } + } + Ok(None) + } + pub fn page(conn: &Connection, (min, max): (i32, i32)) -> Result> { + email_blocklist::table + .offset(min.into()) + .limit((max - min).into()) + .load::(conn) + .map_err(Error::from) + } + pub fn count(conn: &Connection) -> Result { + email_blocklist::table + .count() + .get_result(conn) + .map_err(Error::from) + } + pub fn pattern_errors(pat: &str) -> Option { + let c = Pattern::new(pat); + c.err() + } + pub fn new( + conn: &Connection, + pattern: &str, + note: &str, + show_notification: bool, + notification_text: &str, + ) -> Result { + let c = NewBlocklistedEmail { + email_address: pattern.to_owned(), + note: note.to_owned(), + notify_user: show_notification, + notification_text: notification_text.to_owned(), + }; + BlocklistedEmail::insert(conn, c) + } +} +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use diesel::Connection; + use instance::tests as instance_tests; + use tests::rockets; + use Connection as Conn; + pub(crate) fn fill_database(conn: &Conn) -> Vec { + instance_tests::fill_database(conn); + let domainblock = + BlocklistedEmail::new(conn, "*@bad-actor.com", "Mean spammers", false, "").unwrap(); + let userblock = BlocklistedEmail::new( + conn, + "spammer@lax-administration.com", + "Decent enough domain, but this user is a problem.", + true, + "Stop it please", + ) + .unwrap(); + vec![domainblock, userblock] + } + #[test] + fn test_match() { + let r = rockets(); + let conn = &*r.conn; + conn.test_transaction::<_, (), _>(|| { + let various = fill_database(conn); + let match1 = "user1@bad-actor.com"; + let match2 = "spammer@lax-administration.com"; + let no_match = "happy-user@lax-administration.com"; + assert_eq!( + BlocklistedEmail::matches_blocklist(conn, match1) + .unwrap() + .unwrap() + .id, + various[0].id + ); + assert_eq!( + BlocklistedEmail::matches_blocklist(conn, match2) + .unwrap() + .unwrap() + .id, + various[1].id + ); + assert_eq!( + BlocklistedEmail::matches_blocklist(conn, no_match) + .unwrap() + .is_none(), + true + ); + Ok(()) + }); + } +} diff --git a/plume-models/src/lib.rs b/plume-models/src/lib.rs index 3e2d4f93..29810fca 100644 --- a/plume-models/src/lib.rs +++ b/plume-models/src/lib.rs @@ -22,6 +22,7 @@ extern crate plume_common; #[macro_use] extern crate plume_macro; extern crate reqwest; +#[macro_use] extern crate rocket; extern crate rocket_i18n; extern crate scheduled_thread_pool; @@ -32,6 +33,7 @@ extern crate serde_derive; extern crate serde_json; #[macro_use] extern crate tantivy; +extern crate glob; extern crate url; extern crate walkdir; extern crate webfinger; @@ -53,6 +55,7 @@ pub type Connection = diesel::PgConnection; /// All the possible errors that can be encoutered in this crate #[derive(Debug)] pub enum Error { + Blocklisted(bool, String), Db(diesel::result::Error), Inbox(Box>), InvalidValue, @@ -351,6 +354,7 @@ mod tests { pub mod admin; pub mod api_tokens; pub mod apps; +pub mod blocklisted_emails; pub mod blog_authors; pub mod blogs; pub mod comment_seers; diff --git a/plume-models/src/schema.rs b/plume-models/src/schema.rs index ef1c5b71..dc6d77fd 100644 --- a/plume-models/src/schema.rs +++ b/plume-models/src/schema.rs @@ -73,6 +73,15 @@ table! { user_id -> Int4, } } +table! { + email_blocklist(id){ + id -> Int4, + email_address -> VarChar, + note -> Text, + notify_user -> Bool, + notification_text -> Text, + } +} table! { follows (id) { diff --git a/plume-models/src/users.rs b/plume-models/src/users.rs index c8af1b83..37e27885 100644 --- a/plume-models/src/users.rs +++ b/plume-models/src/users.rs @@ -49,7 +49,10 @@ use safe_string::SafeString; use schema::users; use search::Searcher; use timeline::Timeline; -use {ap_url, Connection, Error, PlumeRocket, Result, ITEMS_PER_PAGE}; +use { + ap_url, blocklisted_emails::BlocklistedEmail, Connection, Error, PlumeRocket, Result, + ITEMS_PER_PAGE, +}; pub type CustomPerson = CustomObject; @@ -992,6 +995,10 @@ impl NewUser { ) -> Result { let (pub_key, priv_key) = gen_keypair(); let instance = Instance::get_local()?; + let blocklisted = BlocklistedEmail::matches_blocklist(conn, &email)?; + if let Some(x) = blocklisted { + return Err(Error::Blocklisted(x.notify_user, x.notification_text)); + } let res = User::insert( conn, diff --git a/src/main.rs b/src/main.rs index eb755a04..5915615c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -198,6 +198,9 @@ Then try to restart Plume routes::instance::admin_mod, routes::instance::admin_instances, routes::instance::admin_users, + routes::instance::admin_email_blocklist, + routes::instance::add_email_blocklist, + routes::instance::delete_email_blocklist, routes::instance::edit_users, routes::instance::toggle_block, routes::instance::update_settings, diff --git a/src/routes/instance.rs b/src/routes/instance.rs index ac0ca202..15be416e 100644 --- a/src/routes/instance.rs +++ b/src/routes/instance.rs @@ -1,5 +1,5 @@ use rocket::{ - request::{FormItems, FromForm, LenientForm}, + request::{Form, FormItems, FromForm, LenientForm}, response::{status, Flash, Redirect}, }; use rocket_contrib::json::Json; @@ -13,6 +13,7 @@ use inbox; use plume_common::activity_pub::{broadcast, inbox::FromId}; use plume_models::{ admin::*, + blocklisted_emails::*, comments::Comment, db_conn::DbConn, headers::Headers, @@ -174,6 +175,61 @@ pub fn admin_users( Page::total(User::count_local(&*rockets.conn)? as i32) ))) } +pub struct BlocklistEmailDeletion { + ids: Vec, +} +impl<'f> FromForm<'f> for BlocklistEmailDeletion { + type Error = (); + fn from_form(items: &mut FormItems<'f>, _strict: bool) -> Result { + let mut c: BlocklistEmailDeletion = BlocklistEmailDeletion { ids: Vec::new() }; + for item in items { + let key = item.key.parse::(); + if let Ok(i) = key { + c.ids.push(i); + } + } + Ok(c) + } +} +#[post("/admin/emails/delete", data = "
")] +pub fn delete_email_blocklist( + _mod: Moderator, + form: Form, + rockets: PlumeRocket, +) -> Result, ErrorPage> { + BlocklistedEmail::delete_entries(&*rockets.conn, form.0.ids)?; + Ok(Flash::success( + Redirect::to(uri!(admin_email_blocklist: page = None)), + i18n!(rockets.intl.catalog, "Blocks deleted"), + )) +} + +#[post("/admin/emails/new", data = "")] +pub fn add_email_blocklist( + _mod: Moderator, + form: LenientForm, + rockets: PlumeRocket, +) -> Result, ErrorPage> { + BlocklistedEmail::insert(&*rockets.conn, form.0)?; + Ok(Flash::success( + Redirect::to(uri!(admin_email_blocklist: page = None)), + i18n!(rockets.intl.catalog, "Email Blocked"), + )) +} +#[get("/admin/emails?")] +pub fn admin_email_blocklist( + _mod: Moderator, + page: Option, + rockets: PlumeRocket, +) -> Result { + let page = page.unwrap_or_default(); + Ok(render!(instance::emailblocklist( + &rockets.to_context(), + BlocklistedEmail::page(&*rockets.conn, page.limits())?, + page.0, + Page::total(BlocklistedEmail::count(&*rockets.conn)? as i32) + ))) +} /// A structure to handle forms that are a list of items on which actions are applied. /// @@ -307,11 +363,20 @@ fn ban( ) -> Result<(), ErrorPage> { let u = User::get(&*conn, id)?; u.delete(&*conn, searcher)?; - if Instance::get_local() .map(|i| u.instance_id == i.id) .unwrap_or(false) { + BlocklistedEmail::insert( + &conn, + NewBlocklistedEmail { + email_address: u.email.clone().unwrap(), + note: "Banned".to_string(), + notify_user: false, + notification_text: "".to_owned(), + }, + ) + .unwrap(); let target = User::one_by_instance(&*conn)?; let delete_act = u.delete_activity(&*conn)?; let u_clone = u.clone(); diff --git a/src/routes/user.rs b/src/routes/user.rs index 84815922..9980c0cf 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -484,8 +484,20 @@ pub fn validate_username(username: &str) -> Result<(), ValidationError> { } } -fn to_validation(_: Error) -> ValidationErrors { +fn to_validation(x: Error) -> ValidationErrors { let mut errors = ValidationErrors::new(); + if let Error::Blocklisted(show, msg) = x { + if show { + errors.add( + "email", + ValidationError { + code: Cow::from("blocklisted"), + message: Some(Cow::from(msg)), + params: HashMap::new(), + }, + ); + } + } errors.add( "", ValidationError { @@ -529,8 +541,7 @@ pub fn create( "", form.email.to_string(), User::hash_pass(&form.password).map_err(to_validation)?, - ) - .map_err(to_validation)?; + ).map_err(to_validation)?; Ok(Flash::success( Redirect::to(uri!(super::session::new: m = _)), i18n!( diff --git a/templates/instance/admin.rs.html b/templates/instance/admin.rs.html index db4441c3..77c49c08 100644 --- a/templates/instance/admin.rs.html +++ b/templates/instance/admin.rs.html @@ -14,6 +14,7 @@ (&uri!(instance::admin).to_string(), i18n!(ctx.1, "Configuration"), true), (&uri!(instance::admin_instances: page = _).to_string(), i18n!(ctx.1, "Instances"), false), (&uri!(instance::admin_users: page = _).to_string(), i18n!(ctx.1, "Users"), false), + (&uri!(instance::admin_email_blocklist: page=_).to_string(), i18n!(ctx.1, "Email blocklist"), false) ]) diff --git a/templates/instance/emailblocklist.rs.html b/templates/instance/emailblocklist.rs.html new file mode 100644 index 00000000..bd67bf56 --- /dev/null +++ b/templates/instance/emailblocklist.rs.html @@ -0,0 +1,71 @@ +@use templates::base; +@use plume_models::blocklisted_emails::BlocklistedEmail; +@use template_utils::*; +@use routes::*; + +@(ctx:BaseContext, emails: Vec, page:i32, n_pages:i32) + @:base(ctx, i18n!(ctx.1, "Blocklisted Emails"), {}, {}, { +

@i18n!(ctx.1,"Blocklisted Emails")

+ @tabs(&[ + (&uri!(instance::admin).to_string(), i18n!(ctx.1, "Configuration"), false), + (&uri!(instance::admin_instances: page = _).to_string(), i18n!(ctx.1, "Instances"), false), + (&uri!(instance::admin_users: page = _).to_string(), i18n!(ctx.1, "Users"), false), + (&uri!(instance::admin_email_blocklist:page=_).to_string(), i18n!(ctx.1, "Email blocklist"), true), + ]) + + @(Input::new("email_address", i18n!(ctx.1, "Email address")) + .details(i18n!(ctx.1, "The email address you wish to block. In order to block domains, you can use globbing syntax, for example '*@example.com' blocks all addresses from example.com")) + .set_prop("minlength", 1) + .html(ctx.1)) + @(Input::new("note", i18n!(ctx.1, "Note")).optional().html(ctx.1)) + + @(Input::new("notification_text", i18n!(ctx.1, "Blocklisting notification")) + .optional() + .details(i18n!(ctx.1, "The message to be shown when the user attempts to create an account with this email address")).html(ctx.1)) + + +
+
+ @if emails.is_empty() { + + } else { +

@i18n!(ctx.1, "There are no blocked emails on your instance")

+ } +
+
+ @for email in emails { +
+ +

+ + @i18n!(ctx.1, "Email address:") + @email.email_address +

+

+ + @i18n!(ctx.1, "Blocklisted for:") + @email.note +

+ +

+ @if email.notify_user { + + @i18n!(ctx.1, "Will notify them on account creation with this message:") + + @email.notification_text + } else { + @i18n!(ctx.1, "The user will be silently prevented from making an account") + } +

+ +
+ } +
+
+ @paginate(ctx.1, page, n_pages) +}) diff --git a/templates/instance/list.rs.html b/templates/instance/list.rs.html index 4cc1f5ad..33a8f008 100644 --- a/templates/instance/list.rs.html +++ b/templates/instance/list.rs.html @@ -12,6 +12,7 @@ (&uri!(instance::admin).to_string(), i18n!(ctx.1, "Configuration"), false), (&uri!(instance::admin_instances: page = _).to_string(), i18n!(ctx.1, "Instances"), true), (&uri!(instance::admin_users: page = _).to_string(), i18n!(ctx.1, "Users"), false), + (&uri!(instance::admin_email_blocklist:page=_).to_string(), i18n!(ctx.1, "Email blocklist"), false), ])
diff --git a/templates/instance/users.rs.html b/templates/instance/users.rs.html index 46e104f1..848a8aef 100644 --- a/templates/instance/users.rs.html +++ b/templates/instance/users.rs.html @@ -12,6 +12,7 @@ (&uri!(instance::admin).to_string(), i18n!(ctx.1, "Configuration"), false), (&uri!(instance::admin_instances: page = _).to_string(), i18n!(ctx.1, "Instances"), false), (&uri!(instance::admin_users: page = _).to_string(), i18n!(ctx.1, "Users"), true), + (&uri!(instance::admin_email_blocklist: page=_).to_string(), i18n!(ctx.1, "Email blocklist"), false) ])