From c96b859931d893751b15977f2ede7034b46628e7 Mon Sep 17 00:00:00 2001
From: Awiteb
Date: Sun, 17 Nov 2024 16:21:58 +0000
Subject: [PATCH] feat: Reason for banned and suspicious
Signed-off-by: Awiteb
---
locales/ar-sa.toml | 2 +
locales/en-us.toml | 2 +
locales/ru-ru.toml | 2 +
src/config.rs | 128 +++++++++++++++++++++++++-----
src/main.rs | 6 +-
src/telegram_bot/mod.rs | 9 ++-
src/telegram_bot/users_handler.rs | 29 ++++---
src/traits.rs | 15 ++--
src/users_fetcher.rs | 20 ++---
9 files changed, 162 insertions(+), 51 deletions(-)
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());