Perform destructive actions only when ACTUALLY_BAN_USERS=true

This commit is contained in:
Armaël Guéneau 2024-12-20 21:14:43 +01:00
parent d7e6646226
commit f8375ca188
3 changed files with 79 additions and 20 deletions

View file

@ -13,10 +13,22 @@
locally in `db.json`, no concrete action is taken. (Ultimately we will want to locally in `db.json`, no concrete action is taken. (Ultimately we will want to
lock/delete accounts, etc.) 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 ## 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 - discuss the current design choices for when locking the account/sending a
notification email fails. notification email fails.
(Current behavior is to periodically retry, avoid deleting if the account (Current behavior is to periodically retry, avoid deleting if the account

View file

@ -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_SPAM_THRESHOLD: f32 = 0.8;
const GUESS_LEGIT_THRESHOLD: f32 = 0.3; 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<Forgejo> { fn forge() -> anyhow::Result<Forgejo> {
let api_token = std::fs::read_to_string(Path::new("api_token"))? let api_token = std::fs::read_to_string(Path::new("api_token"))?
.trim() .trim()
@ -164,9 +171,9 @@ fn set_spam(
async fn apply_classification( async fn apply_classification(
forge: &Forgejo, forge: &Forgejo,
mailer: &SmtpConfig,
db: Arc<Mutex<Db>>, db: Arc<Mutex<Db>>,
classifier: &mut Classifier, classifier: &mut Classifier,
actually_ban: Arc<ActuallyBan>,
ids: &[(UserId, bool)], ids: &[(UserId, bool)],
overwrite: bool, overwrite: bool,
) { ) {
@ -176,7 +183,7 @@ async fn apply_classification(
let login = db.lock().unwrap().users.get(&user).unwrap().login.clone(); 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 // 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 // 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 .await
.unwrap_or_else(|err| eprintln!("Failed to lock or notify user {login}: {err}")); .unwrap_or_else(|err| eprintln!("Failed to lock or notify user {login}: {err}"));
} }
@ -198,7 +205,7 @@ struct AppState {
db: Arc<Mutex<Db>>, db: Arc<Mutex<Db>>,
classifier: Arc<Mutex<Classifier>>, classifier: Arc<Mutex<Classifier>>,
forge: Arc<Forgejo>, forge: Arc<Forgejo>,
mailer: Arc<SmtpConfig>, actually_ban: Arc<ActuallyBan>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -306,9 +313,9 @@ async fn post_classified(
apply_classification( apply_classification(
&data.forge, &data.forge,
&data.mailer,
data.db.clone(), data.db.clone(),
classifier, classifier,
data.actually_ban.clone(),
&updates, &updates,
overwrite, overwrite,
) )
@ -382,8 +389,14 @@ async fn main() -> anyhow::Result<()> {
eprintln!("Eval templates"); eprintln!("Eval templates");
let _ = *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 forge = Arc::new(forge()?);
let mailer = Arc::new(SmtpConfig::from_env().await?);
eprintln!("Load users and repos"); eprintln!("Load users and repos");
let (db, classifier) = load_db(&forge).await?; let (db, classifier) = load_db(&forge).await?;
let db = Arc::new(Mutex::new(db)); let db = Arc::new(Mutex::new(db));
@ -393,7 +406,7 @@ async fn main() -> anyhow::Result<()> {
db: db.clone(), db: db.clone(),
classifier: classifier.clone(), classifier: classifier.clone(),
forge: forge.clone(), forge: forge.clone(),
mailer: mailer.clone(), actually_ban: actually_ban.clone(),
}); });
let _ = { let _ = {
@ -405,13 +418,14 @@ async fn main() -> anyhow::Result<()> {
let _ = { let _ = {
let forge = forge.clone(); let forge = forge.clone();
let db = db.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 _ = {
let forge = forge.clone(); let forge = forge.clone();
let mailer = mailer.clone();
let db = db.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"); println!("Listening on http://127.0.0.1:8080");

View file

@ -1,7 +1,7 @@
use crate::classifier::Classifier; use crate::classifier::Classifier;
use crate::data::UserId; use crate::data::UserId;
use crate::db::{Db, IsSpam}; use crate::db::{Db, IsSpam};
use crate::email::SmtpConfig; use crate::email;
use crate::scrape; use crate::scrape;
use anyhow::anyhow; use anyhow::anyhow;
use forgejo_api::Forgejo; use forgejo_api::Forgejo;
@ -9,6 +9,7 @@ use std::collections::HashMap;
use std::path::Path; use std::path::Path;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use crate::ActuallyBan;
use crate::FORGEJO_POLL_DELAY; use crate::FORGEJO_POLL_DELAY;
use crate::GRACE_PERIOD; use crate::GRACE_PERIOD;
use crate::{GUESS_LEGIT_THRESHOLD, GUESS_SPAM_THRESHOLD}; 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 // 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 forge
.admin_delete_user( .admin_delete_user(
login, login,
forgejo_api::structs::AdminDeleteUserQuery { purge: Some(true) }, forgejo_api::structs::AdminDeleteUserQuery { purge: Some(true) },
) )
.await?; .await?;
eprintln!("Success");
Ok(()) Ok(())
} }
pub async fn purge_spammer_accounts(forge: Arc<Forgejo>, db: Arc<Mutex<Db>>) { pub async fn purge_spammer_accounts(
forge: Arc<Forgejo>,
db: Arc<Mutex<Db>>,
actually_ban: Arc<ActuallyBan>,
) {
loop { loop {
let mut classified_users = Vec::new(); let mut classified_users = Vec::new();
{ {
@ -129,7 +145,7 @@ pub async fn purge_spammer_accounts(forge: Arc<Forgejo>, db: Arc<Mutex<Db>>) {
); );
} }
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) eprintln!("Error while deleting spammer account {login}: {:?}", e)
} else { } else {
eprintln!("Deleted spammer account {login}"); 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( pub async fn try_lock_and_notify_user(
forge: &Forgejo, forge: &Forgejo,
mailer: &SmtpConfig,
db: Arc<Mutex<Db>>, db: Arc<Mutex<Db>>,
actually_ban: Arc<ActuallyBan>,
user_id: UserId, user_id: UserId,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let (login, email, is_spam) = { 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 let Some((classified_at, locked, notified)) = is_spam {
if !locked { if !locked {
match actually_ban.as_ref() {
ActuallyBan::Yes { .. } => {
eprintln!("Locking account of user {login}");
lock_user_account(forge, &login).await?; lock_user_account(forge, &login).await?;
eprintln!("Success");
}
ActuallyBan::No => eprintln!("[Simulating: lock account of user {login}]"),
}
let db = &mut db.lock().unwrap(); let db = &mut db.lock().unwrap();
db.is_spam.insert( db.is_spam.insert(
user_id, user_id,
@ -219,7 +243,16 @@ pub async fn try_lock_and_notify_user(
} }
if !notified { 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(); let db = &mut db.lock().unwrap();
db.is_spam.insert( db.is_spam.insert(
user_id, user_id,
@ -243,8 +276,8 @@ pub async fn try_lock_and_notify_user(
pub async fn lock_and_notify_users( pub async fn lock_and_notify_users(
forge: Arc<Forgejo>, forge: Arc<Forgejo>,
mailer: Arc<SmtpConfig>,
db: Arc<Mutex<Db>>, db: Arc<Mutex<Db>>,
actually_ban: Arc<ActuallyBan>,
) { ) {
let mut spammers = Vec::new(); let mut spammers = Vec::new();
{ {
@ -257,7 +290,7 @@ pub async fn lock_and_notify_users(
} }
for (user_id, login) in spammers { 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 .await
.unwrap_or_else(|err| eprintln!("Failed to lock or notify user {login}: {err}")); .unwrap_or_else(|err| eprintln!("Failed to lock or notify user {login}: {err}"));
} }