remove deuxfleurs-specific bits, add environment variables for configuration
This commit is contained in:
parent
4d59f04e6f
commit
f50b81e8e2
7 changed files with 91 additions and 47 deletions
|
@ -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
|
||||
|
|
20
src/email.rs
20
src/email.rs
|
@ -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 été 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(())
|
||||
|
|
85
src/main.rs
85
src/main.rs
|
@ -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");
|
||||
|
|
|
@ -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}"));
|
||||
}
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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">
|
||||
|
|
Loading…
Add table
Reference in a new issue