remove deuxfleurs-specific bits, add environment variables for configuration

This commit is contained in:
Armaël Guéneau 2024-12-22 15:03:38 +01:00
parent 4d59f04e6f
commit f50b81e8e2
7 changed files with 91 additions and 47 deletions

View file

@ -14,9 +14,14 @@
## Configuration
Forgery reads the following environment variables:
- `FORGEJO_URL`: url of the forgejo instance
- `FORGEJO_API_TOKEN`: Forgejo API token *granting admin access*. Required. You
can generate an API token using the Forgejo web interface in `Settings ->
Applications -> Generate New Token`.
- `ORG_NAME`: organization name (used in the notification email sent when
locking accounts)
- `ADMIN_CONTACT_EMAIL`: email that can be used to contact admins of your
instance (included in the notification email sent when locking accounts)
- `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

View file

@ -1,3 +1,4 @@
use crate::Config;
use anyhow::anyhow;
use lettre::{Message, SmtpTransport, Transport};
use std::env;
@ -36,17 +37,21 @@ impl SmtpConfig {
}
pub async fn send_locked_account_notice(
config: &Config,
smtp: &SmtpConfig,
login: &str,
email: &str,
) -> anyhow::Result<()> {
let grace_period_days = crate::GRACE_PERIOD.as_secs() / (24 * 3600);
let org_name = &config.org_name;
let forge_url = &config.forge_url;
let admin_contact_email = &config.admin_contact_email;
let email = Message::builder()
.from(smtp.username.parse().unwrap())
.to(email.parse()?)
.subject(format!(
"[Forgejo Deuxfleurs] Your account was marked as spam and will be deleted in {} days",
"[Forgejo {org_name}] Your account was marked as spam and will be deleted in {} days",
grace_period_days
))
.body(format!(
@ -54,25 +59,26 @@ pub async fn send_locked_account_notice(
(English version below)
Ceci est un email pour vous informer que votre compte utilisateur \"{login}\" sur \
https://git.deuxfleurs.fr a été considéré comme spam par un administrateur.
{forge_url} a é considéré comme spam par un administrateur.
Par défaut, ce compte sera par conséquent supprimé dans {grace_period_days} jours.
S'il s'agit d'une erreur de notre part, merci de nous contacter rapidement par email via \
<prod-sysadmin@deuxfleurs.fr>.
<{admin_contact_email}>.
-- les administrateurs de Deuxfleurs
-- les administrateurs de {org_name}
--------
This is an email to inform you that your user account \"{login}\" at https://git.deuxfleurs.fr \
This is an email to inform you that your user account \"{login}\" at {forge_url} \
was considered as spam by an administrator.
Without further action on your part, the account will be deleted in {grace_period_days} days.
If you believe this is a mistake, please reach out quickly by email at <prod-sysadmin@deuxfleurs.fr>.
If you believe this is a mistake, please reach out quickly by email at <{admin_contact_email}>.
-- the Deuxfleurs admins"))
-- the {org_name} admins"
))
.unwrap();
smtp.mailer()?.send(&email)?;
Ok(())

View file

@ -10,6 +10,7 @@ use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tera::Tera;
use url::Url;
mod classifier;
mod data;
@ -43,6 +44,38 @@ 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;
pub struct Config {
pub forge_url: Url,
pub org_name: String,
pub admin_contact_email: String,
pub actually_ban: ActuallyBan,
}
impl Config {
async fn from_env() -> anyhow::Result<Self> {
let forge_url_s =
std::env::var("FORGEJO_URL").context("reading the FORGEJO_URL environment variable")?;
let org_name =
std::env::var("ORG_NAME").context("reading the ORG_NAME environment variable")?;
let admin_contact_email = std::env::var("ADMIN_CONTACT_EMAIL")
.context("reading the ADMIN_CONTACT_EMAIL environment variable")?;
let actually_ban = match std::env::var("ACTUALLY_BAN_USERS") {
Ok(_) => ActuallyBan::Yes {
smtp: SmtpConfig::from_env().await?,
},
Err(_) => ActuallyBan::No,
};
Ok(Config {
forge_url: Url::parse(&forge_url_s).context("parsing FORGEJO_URL")?,
org_name,
admin_contact_email,
actually_ban,
})
}
}
// Whether we are actually banning users or are instead in "testing" mode where
// we don't do anything. (Defaults to "No".)
pub enum ActuallyBan {
@ -50,14 +83,22 @@ pub enum ActuallyBan {
No,
}
fn forge() -> anyhow::Result<Forgejo> {
// Runtime state of the application
struct AppState {
// config parameters derived from environment variable
config: Arc<Config>,
// authenticated access to the forgejo instance
forge: Arc<Forgejo>,
// runtime state (to be persisted in the storage when modified)
db: Arc<Mutex<Db>>,
classifier: Arc<Mutex<Classifier>>,
}
fn forge(url: &Url) -> anyhow::Result<Forgejo> {
let api_token = std::env::var("FORGEJO_API_TOKEN")
.context("reading the FORGEJO_API_TOKEN environment variable")?;
let forge = Forgejo::new(
Auth::Token(&api_token),
url::Url::parse("https://git.deuxfleurs.fr")?,
)?;
let forge = Forgejo::new(Auth::Token(&api_token), url.clone())?;
Ok(forge)
}
@ -171,10 +212,10 @@ fn set_spam(
}
async fn apply_classification(
config: &Config,
forge: &Forgejo,
db: Arc<Mutex<Db>>,
classifier: &mut Classifier,
actually_ban: Arc<ActuallyBan>,
ids: &[(UserId, bool)],
overwrite: bool,
) {
@ -184,7 +225,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, db.clone(), actually_ban.clone(), user)
workers::try_lock_and_notify_user(config, forge, db.clone(), user)
.await
.unwrap_or_else(|err| eprintln!("Failed to lock or notify user {login}: {err}"));
}
@ -202,13 +243,6 @@ lazy_static! {
};
}
struct AppState {
db: Arc<Mutex<Db>>,
classifier: Arc<Mutex<Classifier>>,
forge: Arc<Forgejo>,
actually_ban: Arc<ActuallyBan>,
}
#[derive(Debug, Deserialize)]
struct SortSetting {
sort: Option<String>,
@ -284,6 +318,7 @@ async fn index(
let classified_count = db.is_spam.len();
let mut context = tera::Context::new();
context.insert("forge_url", &data.config.forge_url.to_string());
context.insert("users", &users);
context.insert(
"unclassified_users_count",
@ -313,10 +348,10 @@ async fn post_classified(
.collect();
apply_classification(
&data.config,
&data.forge,
data.db.clone(),
classifier,
data.actually_ban.clone(),
&updates,
overwrite,
)
@ -378,6 +413,7 @@ async fn classified(
.collect();
let mut context = tera::Context::new();
context.insert("forge_url", &data.config.forge_url.to_string());
context.insert("users", &users);
eprintln!("rendering template...");
let page = TEMPLATES.render("classified.html", &context).unwrap();
@ -390,14 +426,9 @@ 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 config = Arc::new(Config::from_env().await?);
let forge = Arc::new(forge(&config.forge_url)?);
let forge = Arc::new(forge()?);
eprintln!("Load users and repos");
let (db, classifier) = load_db(&forge).await?;
let db = Arc::new(Mutex::new(db));
@ -407,7 +438,7 @@ async fn main() -> anyhow::Result<()> {
db: db.clone(),
classifier: classifier.clone(),
forge: forge.clone(),
actually_ban: actually_ban.clone(),
config: config.clone(),
});
let _ = {
@ -417,16 +448,16 @@ async fn main() -> anyhow::Result<()> {
tokio::spawn(async move { workers::refresh_user_data(forge, db, classifier) })
};
let _ = {
let config = config.clone();
let forge = forge.clone();
let db = db.clone();
let actually_ban = actually_ban.clone();
tokio::spawn(async move { workers::purge_spammer_accounts(forge, db, actually_ban) })
tokio::spawn(async move { workers::purge_spammer_accounts(config, forge, db) })
};
let _ = {
let config = config.clone();
let forge = forge.clone();
let db = db.clone();
let actually_ban = actually_ban.clone();
tokio::spawn(async move { workers::lock_and_notify_users(forge, db, actually_ban) })
tokio::spawn(async move { workers::lock_and_notify_users(config, forge, db) })
};
println!("Listening on http://127.0.0.1:8080");

View file

@ -9,9 +9,9 @@ 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::{ActuallyBan, Config};
use crate::{GUESS_LEGIT_THRESHOLD, GUESS_SPAM_THRESHOLD};
// Worker to refresh user data by periodically polling Forgejo
@ -84,11 +84,11 @@ pub async fn refresh_user_data(
// Worker to delete spam accounts after their grace period expired
async fn try_purge_account(
config: &Config,
forge: &Forgejo,
login: &str,
actually_ban: &ActuallyBan,
) -> anyhow::Result<()> {
if let ActuallyBan::No = actually_ban {
if let ActuallyBan::No = config.actually_ban {
eprintln!("[Simulating: delete account of user {login}]");
return Ok(());
}
@ -105,9 +105,9 @@ async fn try_purge_account(
}
pub async fn purge_spammer_accounts(
config: Arc<Config>,
forge: Arc<Forgejo>,
db: Arc<Mutex<Db>>,
actually_ban: Arc<ActuallyBan>,
) {
loop {
let mut classified_users = Vec::new();
@ -145,7 +145,7 @@ pub async fn purge_spammer_accounts(
);
}
if let Err(e) = try_purge_account(&forge, &login, &actually_ban).await {
if let Err(e) = try_purge_account(&config, &forge, &login).await {
eprintln!("Error while deleting spammer account {login}: {:?}", e)
} else {
eprintln!("Deleted spammer account {login}");
@ -200,9 +200,9 @@ async fn lock_user_account(forge: &Forgejo, username: &str) -> anyhow::Result<()
}
pub async fn try_lock_and_notify_user(
config: &Config,
forge: &Forgejo,
db: Arc<Mutex<Db>>,
actually_ban: Arc<ActuallyBan>,
user_id: UserId,
) -> anyhow::Result<()> {
let (login, email, is_spam) = {
@ -221,7 +221,7 @@ pub async fn try_lock_and_notify_user(
if let Some((classified_at, locked, notified)) = is_spam {
if !locked {
match actually_ban.as_ref() {
match &config.actually_ban {
ActuallyBan::Yes { .. } => {
eprintln!("Locking account of user {login}");
lock_user_account(forge, &login).await?;
@ -243,10 +243,10 @@ pub async fn try_lock_and_notify_user(
}
if !notified {
match actually_ban.as_ref() {
match &config.actually_ban {
ActuallyBan::Yes { smtp } => {
eprintln!("Sending notification email to user {login}");
email::send_locked_account_notice(&smtp, &login, &email).await?;
email::send_locked_account_notice(config, &smtp, &login, &email).await?;
eprintln!("Success");
}
ActuallyBan::No => {
@ -275,9 +275,9 @@ pub async fn try_lock_and_notify_user(
}
pub async fn lock_and_notify_users(
config: Arc<Config>,
forge: Arc<Forgejo>,
db: Arc<Mutex<Db>>,
actually_ban: Arc<ActuallyBan>,
) {
let mut spammers = Vec::new();
{
@ -290,7 +290,7 @@ pub async fn lock_and_notify_users(
}
for (user_id, login) in spammers {
try_lock_and_notify_user(&forge, db.clone(), actually_ban.clone(), user_id)
try_lock_and_notify_user(&config, &forge, db.clone(), user_id)
.await
.unwrap_or_else(|err| eprintln!("Failed to lock or notify user {login}: {err}"));
}

View file

@ -15,6 +15,7 @@
<div class="users">
{% for user_data in users %}
{{ ui::user_card(
forge_url=forge_url,
user_id=user_data[0],
user=user_data[1],
score=user_data[2],

View file

@ -21,6 +21,7 @@
<div class="users">
{% for user_data in users %}
{{ ui::user_card(
forge_url=forge_url,
user_id=user_data[0],
user=user_data[1],
score=user_data[2],

View file

@ -1,6 +1,6 @@
{% import "macros.html" as macros %}
{% macro user_card(user_id, user, score, score_approx, is_spam) %}
{% macro user_card(forge_url, user_id, user, score, score_approx, is_spam) %}
<div class="user">
<div class="user-classification">
<input type="radio" name="{{user_id}}" id="{{user_id}}-spam" value="spam"
@ -19,7 +19,7 @@
</div>
<div class="user-card">
<div class="user-name">
<div><strong><a href="https://git.deuxfleurs.fr/{{user.login}}">{{ user.login }}</a></strong></div>
<div><strong><a href="{forge_url}/{{user.login}}">{{ user.login }}</a></strong></div>
{%- if user.full_name %}<div><strong>({{ user.full_name }})</strong></div>{% endif -%}
</div>
<div class="user-info">