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

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_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> {
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<Mutex<Db>>,
classifier: &mut Classifier,
actually_ban: Arc<ActuallyBan>,
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<Mutex<Db>>,
classifier: Arc<Mutex<Classifier>>,
forge: Arc<Forgejo>,
mailer: Arc<SmtpConfig>,
actually_ban: Arc<ActuallyBan>,
}
#[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");

View file

@ -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<Forgejo>, db: Arc<Mutex<Db>>) {
pub async fn purge_spammer_accounts(
forge: Arc<Forgejo>,
db: Arc<Mutex<Db>>,
actually_ban: Arc<ActuallyBan>,
) {
loop {
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)
} 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<Mutex<Db>>,
actually_ban: Arc<ActuallyBan>,
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<Forgejo>,
mailer: Arc<SmtpConfig>,
db: Arc<Mutex<Db>>,
actually_ban: Arc<ActuallyBan>,
) {
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}"));
}