Move "lock account + send email" to a worker with retries

This commit is contained in:
Armaël Guéneau 2024-12-20 20:48:13 +01:00
parent 0e0b246115
commit dcbe528c64
4 changed files with 176 additions and 47 deletions

View file

@ -15,8 +15,11 @@
## Todos ## Todos
- take concrete actions for spam accounts: lock the account, send a warning - discuss the current design choices for when locking the account/sending a
email, then delete+purge account after some time. notification email fails.
(Current behavior is to periodically retry, avoid deleting if the account
could not be locked, but delete the account after the grace period even if
the email could not be sent…)
- add backend to store data on garage instead of local files - add backend to store data on garage instead of local files
- replate the `api_token` file with a better mechanism: oauth maybe? - replate the `api_token` file with a better mechanism: oauth maybe?
- improve error handling - improve error handling

View file

@ -11,7 +11,11 @@ use std::time::SystemTime;
#[derive(Serialize, Deserialize, Debug, Clone, Copy)] #[derive(Serialize, Deserialize, Debug, Clone, Copy)]
pub enum IsSpam { pub enum IsSpam {
Legit, Legit,
Spam { classified_at: SystemTime }, Spam {
classified_at: SystemTime,
locked: bool,
notified: bool,
},
} }
impl IsSpam { impl IsSpam {
@ -26,6 +30,8 @@ impl IsSpam {
if b { if b {
IsSpam::Spam { IsSpam::Spam {
classified_at: SystemTime::now(), classified_at: SystemTime::now(),
locked: false,
notified: false,
} }
} else { } else {
IsSpam::Legit IsSpam::Legit

View file

@ -162,53 +162,24 @@ fn set_spam(
spammers spammers
} }
async fn lock_user_account(forge: &Forgejo, username: &str) -> anyhow::Result<()> {
let opts = forgejo_api::structs::EditUserOption {
// boilerplate: we do not change these settings
active: None,
admin: None,
allow_create_organization: None,
allow_git_hook: None,
allow_import_local: None,
description: None,
email: None,
full_name: None,
location: None,
login_name: None,
max_repo_creation: None,
must_change_password: None,
password: None,
pronouns: None,
restricted: None,
source_id: None,
website: None,
// lock the account and set its visibility to private: the user's
// description and info will not be publicly visible
prohibit_login: Some(true),
visibility: Some("private".to_string()),
};
forge.admin_edit_user(username, opts).await?;
Ok(())
}
async fn apply_classification( async fn apply_classification(
forge: &Forgejo, forge: &Forgejo,
mailer: &SmtpConfig, mailer: &SmtpConfig,
db: &mut Db, db: Arc<Mutex<Db>>,
classifier: &mut Classifier, classifier: &mut Classifier,
ids: &[(UserId, bool)], ids: &[(UserId, bool)],
overwrite: bool, overwrite: bool,
) -> anyhow::Result<()> { ) {
let spammers = set_spam(db, classifier, ids, overwrite); let spammers = set_spam(&mut db.lock().unwrap(), classifier, ids, overwrite);
for user in spammers { for user in spammers {
let user = &db.users.get(&user).unwrap(); let login = db.lock().unwrap().users.get(&user).unwrap().login.clone();
lock_user_account(forge, &user.login).await?; // It is ok for any of these calls to fail now: a worker will periodically retry
email::send_locked_account_notice(&mailer, &user.login, &user.email).await?; // TODO: signal the worker to wake up instead of performing a manual call here
// TODO: better and more robust error handling: retries, or a worker to send the emails ..? workers::try_lock_and_notify_user(forge, mailer, db.clone(), user)
.await
.unwrap_or_else(|err| eprintln!("Failed to lock or notify user {login}: {err}"));
} }
Ok(())
} }
lazy_static! { lazy_static! {
@ -325,8 +296,8 @@ async fn post_classified(
) -> impl Responder { ) -> impl Responder {
eprintln!("POST {}", req.uri()); eprintln!("POST {}", req.uri());
let db = &mut data.db.lock().unwrap();
let classifier = &mut data.classifier.lock().unwrap(); let classifier = &mut data.classifier.lock().unwrap();
let db = data.db.clone();
let updates: Vec<(UserId, bool)> = form let updates: Vec<(UserId, bool)> = form
.iter() .iter()
@ -336,15 +307,17 @@ async fn post_classified(
apply_classification( apply_classification(
&data.forge, &data.forge,
&data.mailer, &data.mailer,
db, data.db.clone(),
classifier, classifier,
&updates, &updates,
overwrite, overwrite,
) )
.await .await;
.unwrap(); // FIXME
db.store_to_path(Path::new("db.json")).unwrap(); // FIXME db.lock()
.unwrap()
.store_to_path(Path::new("db.json"))
.unwrap(); // FIXME
classifier classifier
.save(&mut File::create(Path::new("model.json")).unwrap(), false) .save(&mut File::create(Path::new("model.json")).unwrap(), false)
.unwrap(); // FIXME .unwrap(); // FIXME
@ -434,6 +407,12 @@ async fn main() -> anyhow::Result<()> {
let db = db.clone(); let db = db.clone();
tokio::spawn(async move { workers::purge_spammer_accounts(forge, db) }) tokio::spawn(async move { workers::purge_spammer_accounts(forge, db) })
}; };
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) })
};
println!("Listening on http://127.0.0.1:8080"); println!("Listening on http://127.0.0.1:8080");

View file

@ -1,6 +1,9 @@
use crate::classifier::Classifier; use crate::classifier::Classifier;
use crate::data::UserId;
use crate::db::{Db, IsSpam}; use crate::db::{Db, IsSpam};
use crate::email::SmtpConfig;
use crate::scrape; use crate::scrape;
use anyhow::anyhow;
use forgejo_api::Forgejo; use forgejo_api::Forgejo;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::Path; use std::path::Path;
@ -100,9 +103,32 @@ pub async fn purge_spammer_accounts(forge: Arc<Forgejo>, db: Arc<Mutex<Db>>) {
} }
for (user_id, login, is_spam) in classified_users { for (user_id, login, is_spam) in classified_users {
if let IsSpam::Spam { classified_at } = is_spam { if let IsSpam::Spam {
classified_at,
locked,
notified,
} = is_spam
{
match classified_at.elapsed() { match classified_at.elapsed() {
Ok(duration) if duration > GRACE_PERIOD => { Ok(duration) if duration > GRACE_PERIOD => {
if !locked {
// NOTE: this is a minimum sanity check, but can this
// realistically happen? could we do better than printing a
// message in this case?
eprintln!("WARN: grace period for {login} expired but account is not locked! Skip user.");
continue;
}
if !notified {
// NOTE: we delete accounts even if we failed to notify them by email.
// Could we do better? (But if we do not delete in this case,
// spammers could perhaps prevent being deleted by providing
// non-working emails…)
eprintln!(
"WARN: grace period for {login} expired but we failed to send a \
notification email. Deleting anyway..."
);
}
if let Err(e) = try_purge_account(&forge, &login).await { if let Err(e) = try_purge_account(&forge, &login).await {
eprintln!("Error while deleting spammer account {login}: {:?}", e) eprintln!("Error while deleting spammer account {login}: {:?}", e)
} else { } else {
@ -123,3 +149,118 @@ pub async fn purge_spammer_accounts(forge: Arc<Forgejo>, db: Arc<Mutex<Db>>) {
tokio::time::sleep(std::time::Duration::from_secs(3600)).await; tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
} }
} }
// Lock a user account and send a notification email.
// Since this can fail, we put it into a worker that periodically retries on any
// user marked as spam and not already locked/notified.
pub async fn lock_user_account(forge: &Forgejo, username: &str) -> anyhow::Result<()> {
let opts = forgejo_api::structs::EditUserOption {
// boilerplate: we do not change these settings
active: None,
admin: None,
allow_create_organization: None,
allow_git_hook: None,
allow_import_local: None,
description: None,
email: None,
full_name: None,
location: None,
login_name: None,
max_repo_creation: None,
must_change_password: None,
password: None,
pronouns: None,
restricted: None,
source_id: None,
website: None,
// lock the account and set its visibility to private: the user's
// description and info will not be publicly visible
prohibit_login: Some(true),
visibility: Some("private".to_string()),
};
forge.admin_edit_user(username, opts).await?;
Ok(())
}
pub async fn try_lock_and_notify_user(
forge: &Forgejo,
mailer: &SmtpConfig,
db: Arc<Mutex<Db>>,
user_id: UserId,
) -> anyhow::Result<()> {
let (login, email, is_spam) = {
let db = &db.lock().unwrap();
let user = db.users.get(&user_id).unwrap();
let is_spam = match db.is_spam.get(&user_id) {
Some(IsSpam::Spam {
classified_at,
locked,
notified,
}) => Some((*classified_at, *locked, *notified)),
_ => None,
};
(user.login.clone(), user.email.clone(), is_spam)
};
if let Some((classified_at, locked, notified)) = is_spam {
if !locked {
lock_user_account(forge, &login).await?;
let db = &mut db.lock().unwrap();
db.is_spam.insert(
user_id,
IsSpam::Spam {
classified_at,
locked: true,
notified,
},
);
db.store_to_path(Path::new("db.json")).unwrap(); // FIXME
}
if !notified {
crate::email::send_locked_account_notice(mailer, &login, &email).await?;
let db = &mut db.lock().unwrap();
db.is_spam.insert(
user_id,
IsSpam::Spam {
classified_at,
locked: true,
notified: true,
},
);
db.store_to_path(Path::new("db.json")).unwrap(); // FIXME
}
Ok(())
} else {
Err(anyhow!(
"Tried to lock user {} who was not classified as spam",
login
))
}
}
pub async fn lock_and_notify_users(
forge: Arc<Forgejo>,
mailer: Arc<SmtpConfig>,
db: Arc<Mutex<Db>>,
) {
let mut spammers = Vec::new();
{
let db = &db.lock().unwrap();
for (id, user, is_spam) in db.classified_users() {
if is_spam.as_bool() {
spammers.push((id, user.login.clone()))
}
}
}
for (user_id, login) in spammers {
try_lock_and_notify_user(&forge, &mailer, db.clone(), user_id)
.await
.unwrap_or_else(|err| eprintln!("Failed to lock or notify user {login}: {err}"));
}
tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
}