From f8375ca188c3e602c946cd9d52406ab254625517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arma=C3=ABl=20Gu=C3=A9neau?= Date: Fri, 20 Dec 2024 21:14:43 +0100 Subject: [PATCH] Perform destructive actions only when ACTUALLY_BAN_USERS=true --- README.md | 16 ++++++++++++++-- src/main.rs | 32 ++++++++++++++++++++++--------- src/workers.rs | 51 +++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 79 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index b7dd423..bd0027b 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,22 @@ locally in `db.json`, no concrete action is taken. (Ultimately we will want to lock/delete accounts, etc.) +## Configuration + +Forgery reads the following environment variables: +- `ACTUALLY_BAN_USERS`: define it (e.g. to `true`) to actually lock user + accounts, send notification emails and eventually delete user accounts. If not + defined (the default), no actual action is taken, spammers are only listed in + the database. The variable should be set in production, but probably not for + testing. + +Environment variables that are relevant when `ACTUALLY_BAN_USERS=true`: +- `SMTP_ADDRESS`: address of the SMTP relay used to send email notifications +- `SMTP_USERNAME`: SMTP username +- `SMTP_PASSWORD`: SMTP password + ## Todos -- gate the actual account lock/email/deletion behind an environment variable - for easy testing and to limit bad surprises - discuss the current design choices for when locking the account/sending a notification email fails. (Current behavior is to periodically retry, avoid deleting if the account diff --git a/src/main.rs b/src/main.rs index d211ddf..b394a3a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,6 +42,13 @@ const GRACE_PERIOD: Duration = Duration::from_secs(30 * 24 * 3600); // 30 days const GUESS_SPAM_THRESHOLD: f32 = 0.8; const GUESS_LEGIT_THRESHOLD: f32 = 0.3; +// Whether we are actually banning users or are instead in "testing" mode where +// we don't do anything. (Defaults to "No".) +pub enum ActuallyBan { + Yes { smtp: SmtpConfig }, + No, +} + fn forge() -> anyhow::Result { let api_token = std::fs::read_to_string(Path::new("api_token"))? .trim() @@ -164,9 +171,9 @@ fn set_spam( async fn apply_classification( forge: &Forgejo, - mailer: &SmtpConfig, db: Arc>, classifier: &mut Classifier, + actually_ban: Arc, ids: &[(UserId, bool)], overwrite: bool, ) { @@ -176,7 +183,7 @@ async fn apply_classification( let login = db.lock().unwrap().users.get(&user).unwrap().login.clone(); // It is ok for any of these calls to fail now: a worker will periodically retry // TODO: signal the worker to wake up instead of performing a manual call here - workers::try_lock_and_notify_user(forge, mailer, db.clone(), user) + workers::try_lock_and_notify_user(forge, db.clone(), actually_ban.clone(), user) .await .unwrap_or_else(|err| eprintln!("Failed to lock or notify user {login}: {err}")); } @@ -198,7 +205,7 @@ struct AppState { db: Arc>, classifier: Arc>, forge: Arc, - mailer: Arc, + actually_ban: Arc, } #[derive(Debug, Deserialize)] @@ -306,9 +313,9 @@ async fn post_classified( apply_classification( &data.forge, - &data.mailer, data.db.clone(), classifier, + data.actually_ban.clone(), &updates, overwrite, ) @@ -382,8 +389,14 @@ async fn main() -> anyhow::Result<()> { eprintln!("Eval templates"); let _ = *TEMPLATES; + let actually_ban = Arc::new(match std::env::var("ACTUALLY_BAN_USERS") { + Ok(_) => ActuallyBan::Yes { + smtp: SmtpConfig::from_env().await?, + }, + Err(_) => ActuallyBan::No, + }); + let forge = Arc::new(forge()?); - let mailer = Arc::new(SmtpConfig::from_env().await?); eprintln!("Load users and repos"); let (db, classifier) = load_db(&forge).await?; let db = Arc::new(Mutex::new(db)); @@ -393,7 +406,7 @@ async fn main() -> anyhow::Result<()> { db: db.clone(), classifier: classifier.clone(), forge: forge.clone(), - mailer: mailer.clone(), + actually_ban: actually_ban.clone(), }); let _ = { @@ -405,13 +418,14 @@ async fn main() -> anyhow::Result<()> { let _ = { let forge = forge.clone(); let db = db.clone(); - tokio::spawn(async move { workers::purge_spammer_accounts(forge, db) }) + let actually_ban = actually_ban.clone(); + tokio::spawn(async move { workers::purge_spammer_accounts(forge, db, actually_ban) }) }; let _ = { let forge = forge.clone(); - let mailer = mailer.clone(); let db = db.clone(); - tokio::spawn(async move { workers::lock_and_notify_users(forge, mailer, db) }) + let actually_ban = actually_ban.clone(); + tokio::spawn(async move { workers::lock_and_notify_users(forge, db, actually_ban) }) }; println!("Listening on http://127.0.0.1:8080"); diff --git a/src/workers.rs b/src/workers.rs index c3ca731..38a616a 100644 --- a/src/workers.rs +++ b/src/workers.rs @@ -1,7 +1,7 @@ use crate::classifier::Classifier; use crate::data::UserId; use crate::db::{Db, IsSpam}; -use crate::email::SmtpConfig; +use crate::email; use crate::scrape; use anyhow::anyhow; use forgejo_api::Forgejo; @@ -9,6 +9,7 @@ use std::collections::HashMap; use std::path::Path; use std::sync::{Arc, Mutex}; +use crate::ActuallyBan; use crate::FORGEJO_POLL_DELAY; use crate::GRACE_PERIOD; use crate::{GUESS_LEGIT_THRESHOLD, GUESS_SPAM_THRESHOLD}; @@ -82,17 +83,32 @@ pub async fn refresh_user_data( // Worker to delete spam accounts after their grace period expired -async fn try_purge_account(forge: &Forgejo, login: &str) -> anyhow::Result<()> { +async fn try_purge_account( + forge: &Forgejo, + login: &str, + actually_ban: &ActuallyBan, +) -> anyhow::Result<()> { + if let ActuallyBan::No = actually_ban { + eprintln!("[Simulating: delete account of user {login}]"); + return Ok(()); + } + + eprintln!("Deleting account of user {login}"); forge .admin_delete_user( login, forgejo_api::structs::AdminDeleteUserQuery { purge: Some(true) }, ) .await?; + eprintln!("Success"); Ok(()) } -pub async fn purge_spammer_accounts(forge: Arc, db: Arc>) { +pub async fn purge_spammer_accounts( + forge: Arc, + db: Arc>, + actually_ban: Arc, +) { loop { let mut classified_users = Vec::new(); { @@ -129,7 +145,7 @@ pub async fn purge_spammer_accounts(forge: Arc, db: Arc>) { ); } - if let Err(e) = try_purge_account(&forge, &login).await { + if let Err(e) = try_purge_account(&forge, &login, &actually_ban).await { eprintln!("Error while deleting spammer account {login}: {:?}", e) } else { eprintln!("Deleted spammer account {login}"); @@ -185,8 +201,8 @@ pub async fn lock_user_account(forge: &Forgejo, username: &str) -> anyhow::Resul pub async fn try_lock_and_notify_user( forge: &Forgejo, - mailer: &SmtpConfig, db: Arc>, + actually_ban: Arc, user_id: UserId, ) -> anyhow::Result<()> { let (login, email, is_spam) = { @@ -205,7 +221,15 @@ pub async fn try_lock_and_notify_user( if let Some((classified_at, locked, notified)) = is_spam { if !locked { - lock_user_account(forge, &login).await?; + match actually_ban.as_ref() { + ActuallyBan::Yes { .. } => { + eprintln!("Locking account of user {login}"); + lock_user_account(forge, &login).await?; + eprintln!("Success"); + } + ActuallyBan::No => eprintln!("[Simulating: lock account of user {login}]"), + } + let db = &mut db.lock().unwrap(); db.is_spam.insert( user_id, @@ -219,7 +243,16 @@ pub async fn try_lock_and_notify_user( } if !notified { - crate::email::send_locked_account_notice(mailer, &login, &email).await?; + match actually_ban.as_ref() { + ActuallyBan::Yes { smtp } => { + eprintln!("Sending notification email to user {login}"); + email::send_locked_account_notice(&smtp, &login, &email).await?; + eprintln!("Success"); + } + ActuallyBan::No => { + eprintln!("[Simulating: send notification email to user {login}]") + } + } let db = &mut db.lock().unwrap(); db.is_spam.insert( user_id, @@ -243,8 +276,8 @@ pub async fn try_lock_and_notify_user( pub async fn lock_and_notify_users( forge: Arc, - mailer: Arc, db: Arc>, + actually_ban: Arc, ) { let mut spammers = Vec::new(); { @@ -257,7 +290,7 @@ pub async fn lock_and_notify_users( } for (user_id, login) in spammers { - try_lock_and_notify_user(&forge, &mailer, db.clone(), user_id) + try_lock_and_notify_user(&forge, db.clone(), actually_ban.clone(), user_id) .await .unwrap_or_else(|err| eprintln!("Failed to lock or notify user {login}: {err}")); }