diff --git a/locales/ar-sa.toml b/locales/ar-sa.toml index d4387d7..b1d145e 100644 --- a/locales/ar-sa.toml +++ b/locales/ar-sa.toml @@ -8,6 +8,7 @@ sus_alert = """تم اكتشاف مستخدم مشبوه! 🚨 • النبذة: %{bio} • الموقع: %{website} • الملف التعريفي: %{profile} +• السبب: %{reason} هل تريد حظر هذا المستخدم؟ """ @@ -19,6 +20,7 @@ ban_notify = """تم حظر مستخدم ⛔ • النبذة: %{bio} • الموقع: %{website} • الملف التعريفي: %{profile} +• السبب: %{reason} """ ban_success = "تم حظر المستخدم بنجاح ⛔" ban_failed = "فشل حظر المستخدم! ⚠️" diff --git a/locales/en-us.toml b/locales/en-us.toml index 8a57d49..45faf0c 100644 --- a/locales/en-us.toml +++ b/locales/en-us.toml @@ -8,6 +8,7 @@ sus_alert = """Suspicious user detected! 🚨 • Bio: %{bio} • Website: %{website} • Profile: %{profile} +• Reason: %{reason} Do you want to ban this user? """ @@ -19,6 +20,7 @@ ban_notify = """User has been banned ⛔ • Bio: %{bio} • Website: %{website} • Profile: %{profile} +• Reason: %{reason} """ ban_success = "User has been banned successfully ⛔" ban_failed = "Failed to ban the user! ⚠️" diff --git a/locales/ru-ru.toml b/locales/ru-ru.toml index ddcc234..4f1a69c 100644 --- a/locales/ru-ru.toml +++ b/locales/ru-ru.toml @@ -8,6 +8,7 @@ sus_alert = """Обнаружен подозрительный пользова • Био: %{bio} • Вебсайт: %{website} • Профиль: %{profile} +• Причина: %{reason} Хотите забанить этого пользователя? """ @@ -19,6 +20,7 @@ ban_notify = """Пользователь заблокирован ⛔ • Био: %{bio} • Вебсайт: %{website} • Профиль: %{profile} +• Причина: %{reason} """ ban_success = "Пользователь успешно забанен ⛔" ban_failed = "Не удалось забанить пользователя! ⚠️" diff --git a/src/config.rs b/src/config.rs index 1d8baae..14584ca 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,13 +19,24 @@ pub(crate) const CONFIG_PATH_ENV: &str = "FORGEJO_GUARDIAN_CONFIG"; /// Defult config path location pub(crate) const DEFAULT_CONFIG_PATH: &str = "/app/forgejo-guardian.toml"; +use std::fmt::Display; + use regex::Regex; use serde::{de, Deserialize}; use teloxide::types::ChatId; +use toml::Value; use url::Url; use crate::telegram_bot::Lang; +/// Function to create a custom error for deserialization from its string +fn custom_de_err<'de, D>(err: impl ToString) -> D::Error +where + D: de::Deserializer<'de>, +{ + de::Error::custom(err.to_string()) +} + /// Deserialize a string into a `url::Url` /// /// This will check if the url is `http` or `https` and if it is a valid url @@ -33,8 +44,7 @@ fn deserialize_str_url<'de, D>(deserializer: D) -> Result where D: de::Deserializer<'de>, { - let url = Url::parse(&String::deserialize(deserializer)?) - .map_err(|e| de::Error::custom(e.to_string()))?; + let url = Url::parse(&String::deserialize(deserializer)?).map_err(custom_de_err::<'de, D>)?; if url.scheme() != "http" && url.scheme() != "https" { return Err(de::Error::custom("URL scheme must be http or https")); } @@ -42,15 +52,69 @@ where } /// Deserialize a vector of strings into a vector of `regex::Regex` -fn deserialize_regex_vec<'de, D>(deserializer: D) -> Result, D::Error> +fn deserialize_regex_reason<'de, D>(deserializer: D) -> Result, D::Error> where D: de::Deserializer<'de>, { - Vec::::deserialize(deserializer)? + let Ok(json_value) = Vec::::deserialize(deserializer) else { + return Err(de::Error::custom( + "expected an array of strings or tables with the keys `re` and optional `reason`", + )); + }; + + json_value .into_iter() - .map(|s| Regex::new(&s)) - .collect::>() - .map_err(|e| de::Error::custom(e.to_string())) + .map(|value| { + if let Value::String(re) = value { + Ok(RegexReason::new( + re.parse().map_err(custom_de_err::<'de, D>)?, + None, + )) + } else if let Value::Table(table) = value { + let re = table + .get("re") + .map(|re| { + re.as_str().map(String::from).ok_or_else(|| { + ::custom(format!( + "expected a string value for `re`, found `{re}`" + )) + }) + }) + .ok_or_else(|| { + ::custom( + "The table must contain a `re` key with a string value", + ) + })??; + let reason = table + .get("reason") + .map(|reason| { + reason.as_str().map(String::from).ok_or_else(|| { + ::custom(format!( + "expected a string value for `reason`, found `{reason}`" + )) + }) + }) + .transpose()?; + + // Warn for unused keys + for key in table.keys() { + if !["re", "reason"].contains(&key.as_str()) { + tracing::warn!("Unused key `{key}` in the configuration"); + } + } + + Ok(RegexReason::new( + re.parse().map_err(custom_de_err::<'de, D>)?, + reason, + )) + } else { + Err(de::Error::custom(format!( + "unexpected value in the regex list, expected a string or a table with `re` \ + (string) and optional `reason` (string), found `{value}`" + ))) + } + }) + .collect() } /// The forgejo config of the guard @@ -81,44 +145,53 @@ pub struct Telegram { pub lang: Lang, } +/// The regular expression with the reason +#[derive(Debug, Clone)] +pub struct RegexReason { + /// The regular expression + pub re: Regex, + /// Optional reason + pub reason: Option, +} + /// The expression #[derive(Deserialize, Debug, Default)] pub struct Expr { /// The regular expressions that the action will be performed if they are /// present in the username #[serde(default)] - #[serde(deserialize_with = "deserialize_regex_vec")] - pub usernames: Vec, + #[serde(deserialize_with = "deserialize_regex_reason")] + pub usernames: Vec, /// The regular expressions that the action will be performed if they are /// present in the user full_name #[serde(default)] - #[serde(deserialize_with = "deserialize_regex_vec")] - pub full_names: Vec, + #[serde(deserialize_with = "deserialize_regex_reason")] + pub full_names: Vec, /// The regular expressions that the action will be performed if they are /// present in the user biography #[serde(default)] - #[serde(deserialize_with = "deserialize_regex_vec")] - pub biographies: Vec, + #[serde(deserialize_with = "deserialize_regex_reason")] + pub biographies: Vec, /// The regular expressions that the action will be performed if they are /// present in the user email #[serde(default)] - #[serde(deserialize_with = "deserialize_regex_vec")] - pub emails: Vec, + #[serde(deserialize_with = "deserialize_regex_reason")] + pub emails: Vec, /// The regular expressions that the action will be performed if they are /// present in the user website #[serde(default)] - #[serde(deserialize_with = "deserialize_regex_vec")] - pub websites: Vec, + #[serde(deserialize_with = "deserialize_regex_reason")] + pub websites: Vec, /// The regular expressions that the action will be performed if they are /// present in the user location #[serde(default)] - #[serde(deserialize_with = "deserialize_regex_vec")] - pub locations: Vec, + #[serde(deserialize_with = "deserialize_regex_reason")] + pub locations: Vec, } /// the expressions @@ -155,3 +228,20 @@ pub struct Config { #[serde(default)] pub expressions: Exprs, } + +impl RegexReason { + /// Create a new `RegexReason` instance + fn new(re: Regex, reason: Option) -> Self { + Self { re, reason } + } +} + +impl Display for RegexReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.re).ok(); + if let Some(ref reason) = self.reason { + write!(f, " ({reason})").ok(); + }; + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index adc4a94..9a3bc0c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,10 +37,12 @@ async fn try_main() -> error::GuardResult<()> { let cancellation_token = CancellationToken::new(); // Suspicious users are sent and received in this channel, users who meet the // `alert` expressions - let (sus_sender, sus_receiver) = sync::mpsc::channel::(100); + let (sus_sender, sus_receiver) = + sync::mpsc::channel::<(forgejo_api::ForgejoUser, config::RegexReason)>(100); // Banned users (already banned) are sent and received in this channel, this // to alert the admins on Telegram if `ban_alert` is set to true - let (ban_sender, ban_receiver) = sync::mpsc::channel::(100); + let (ban_sender, ban_receiver) = + sync::mpsc::channel::<(forgejo_api::ForgejoUser, config::RegexReason)>(100); tracing::info!("The instance: {}", config.forgejo.instance); tracing::info!("Dry run: {}", config.dry_run); diff --git a/src/telegram_bot/mod.rs b/src/telegram_bot/mod.rs index b160c8d..8317277 100644 --- a/src/telegram_bot/mod.rs +++ b/src/telegram_bot/mod.rs @@ -28,7 +28,10 @@ use teloxide::{dispatching::UpdateFilterExt, prelude::*}; use tokio::sync::mpsc::Receiver; use tokio_util::sync::CancellationToken; -use crate::{config::Config, forgejo_api::ForgejoUser}; +use crate::{ + config::{Config, RegexReason}, + forgejo_api::ForgejoUser, +}; /// Language of the telegram bot #[derive(Deserialize)] @@ -54,8 +57,8 @@ impl Lang { pub async fn start_bot( config: Arc, cancellation_token: CancellationToken, - sus_receiver: Receiver, - ban_receiver: Receiver, + sus_receiver: Receiver<(ForgejoUser, RegexReason)>, + ban_receiver: Receiver<(ForgejoUser, RegexReason)>, ) { tracing::info!("Starting the telegram bot"); diff --git a/src/telegram_bot/users_handler.rs b/src/telegram_bot/users_handler.rs index ca6d32c..760a0d7 100644 --- a/src/telegram_bot/users_handler.rs +++ b/src/telegram_bot/users_handler.rs @@ -23,7 +23,10 @@ use teloxide::{ use tokio::sync::mpsc::Receiver; use tokio_util::sync::CancellationToken; -use crate::{config::Config, forgejo_api::ForgejoUser}; +use crate::{ + config::{Config, RegexReason}, + forgejo_api::ForgejoUser, +}; /// Create an inline keyboard of the suspicious user fn make_sus_inline_keyboard(sus_user: &ForgejoUser) -> InlineKeyboardMarkup { @@ -49,7 +52,7 @@ fn not_found_if_empty(text: &str) -> Cow<'_, str> { } /// Generate a user details message -fn user_details(msg: &str, user: &ForgejoUser) -> String { +fn user_details(msg: &str, user: &ForgejoUser, re: &RegexReason) -> String { t!( msg, user_id = user.id, @@ -59,6 +62,10 @@ fn user_details(msg: &str, user: &ForgejoUser) -> String { bio = not_found_if_empty(&user.biography), website = not_found_if_empty(&user.website), profile = user.html_url, + reason = re + .reason + .clone() + .unwrap_or_else(|| t!("words.not_found").to_string()), ) .to_string() } @@ -66,12 +73,13 @@ fn user_details(msg: &str, user: &ForgejoUser) -> String { /// Send a suspicious user alert to the admins pub async fn send_sus_alert( bot: &Bot, + re: &RegexReason, sus_user: ForgejoUser, config: &Config, ) -> ResponseResult<()> { let keyboard = make_sus_inline_keyboard(&sus_user); - let caption = user_details("messages.sus_alert", &sus_user); + let caption = user_details("messages.sus_alert", &sus_user, re); bot.send_photo(config.telegram.chat, InputFile::url(sus_user.avatar_url)) .caption(caption) .reply_markup(keyboard) @@ -83,10 +91,11 @@ pub async fn send_sus_alert( /// Send a ban notification to the admins chat pub async fn send_ban_notify( bot: &Bot, + re: &RegexReason, sus_user: ForgejoUser, config: &Config, ) -> ResponseResult<()> { - let caption = user_details("messages.ban_notify", &sus_user); + let caption = user_details("messages.ban_notify", &sus_user, re); bot.send_photo(config.telegram.chat, InputFile::url(sus_user.avatar_url)) .caption(caption) .await?; @@ -99,16 +108,16 @@ pub async fn users_handler( bot: Bot, config: Arc, cancellation_token: CancellationToken, - mut sus_receiver: Receiver, - mut ban_receiver: Receiver, + mut sus_receiver: Receiver<(ForgejoUser, RegexReason)>, + mut ban_receiver: Receiver<(ForgejoUser, RegexReason)>, ) { loop { tokio::select! { - Some(sus_user) = sus_receiver.recv() => { - send_sus_alert(&bot, sus_user, &config).await.ok(); + Some((sus_user, re)) = sus_receiver.recv() => { + send_sus_alert(&bot, &re, sus_user, &config).await.ok(); } - Some(banned_user) = ban_receiver.recv() => { - send_ban_notify(&bot, banned_user, &config).await.ok(); + Some((banned_user, re)) = ban_receiver.recv() => { + send_ban_notify(&bot, &re, banned_user, &config).await.ok(); } _ = cancellation_token.cancelled() => { tracing::info!("sus users handler has been stopped successfully."); diff --git a/src/traits.rs b/src/traits.rs index 29d610c..c5fa60e 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -14,22 +14,23 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use regex::Regex; - -use crate::{config::Expr, forgejo_api::ForgejoUser}; +use crate::{ + config::{Expr, RegexReason}, + forgejo_api::ForgejoUser, +}; /// Trait for checking if a user matches one of the expressions pub trait ExprChecker { /// Returns the first matching expression, if any - fn is_match(&self, user: &ForgejoUser) -> Option; + fn is_match(&self, user: &ForgejoUser) -> Option; } impl ExprChecker for Expr { - fn is_match<'a>(&'a self, user: &ForgejoUser) -> Option { - let one_of = |hay: &str, exprs: &'a Vec| { + fn is_match<'a>(&'a self, user: &ForgejoUser) -> Option { + let one_of = |hay: &str, exprs: &'a Vec| { exprs .iter() - .find(|re| hay.split('\n').any(|line| re.is_match(line.trim()))) + .find(|re| hay.split('\n').any(|line| re.re.is_match(line.trim()))) }; [ one_of(&user.username, &self.usernames), diff --git a/src/users_fetcher.rs b/src/users_fetcher.rs index d61538b..7a5d8f4 100644 --- a/src/users_fetcher.rs +++ b/src/users_fetcher.rs @@ -26,7 +26,7 @@ use tokio::sync::mpsc::Sender; use tokio_util::sync::CancellationToken; use crate::{ - config::Config, + config::{Config, RegexReason}, error::GuardResult, forgejo_api::{self, get_users, ForgejoUser}, traits::ExprChecker, @@ -58,15 +58,15 @@ async fn check_new_user( user: ForgejoUser, request_client: &reqwest::Client, config: &Config, - sus_sender: &Sender, - ban_sender: &Sender, + sus_sender: &Sender<(ForgejoUser, RegexReason)>, + ban_sender: &Sender<(ForgejoUser, RegexReason)>, ) { if let Some(re) = config.expressions.ban.is_match(&user) { tracing::info!("@{} has been banned because `{re}`", user.username); if config.dry_run { // If it's a dry run, we don't need to ban the user if config.telegram.ban_alert { - ban_sender.send(user).await.ok(); + ban_sender.send((user, re)).await.ok(); } return; } @@ -81,11 +81,11 @@ async fn check_new_user( { tracing::error!("Error while banning a user: {err}"); } else if config.telegram.ban_alert { - ban_sender.send(user).await.ok(); + ban_sender.send((user, re)).await.ok(); } } else if let Some(re) = config.expressions.sus.is_match(&user) { tracing::info!("@{} has been suspected because `{re}`", user.username); - sus_sender.send(user).await.ok(); + sus_sender.send((user, re)).await.ok(); } } @@ -95,8 +95,8 @@ async fn check_new_users( last_user_id: Arc, request_client: Arc, config: Arc, - sus_sender: Sender, - ban_sender: Sender, + sus_sender: Sender<(ForgejoUser, RegexReason)>, + ban_sender: Sender<(ForgejoUser, RegexReason)>, ) { let is_first_fetch = last_user_id.load(Ordering::Relaxed) == 0; match get_new_users( @@ -135,8 +135,8 @@ async fn check_new_users( pub async fn users_fetcher( config: Arc, cancellation_token: CancellationToken, - sus_sender: Sender, - ban_sender: Sender, + sus_sender: Sender<(ForgejoUser, RegexReason)>, + ban_sender: Sender<(ForgejoUser, RegexReason)>, ) { let last_user_id = Arc::new(AtomicUsize::new(0)); let request_client = Arc::new(reqwest::Client::new());