Compare commits
5 commits
642d8994de
...
d59b4e6d3f
Author | SHA1 | Date | |
---|---|---|---|
d59b4e6d3f | |||
cc2f8a791b | |||
c96b859931 | |||
62ca71a140 | |||
b3397f6316 |
13 changed files with 251 additions and 78 deletions
16
Cargo.toml
16
Cargo.toml
|
@ -9,26 +9,30 @@ license = "AGPL-3.0-or-later"
|
|||
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0.214", features = ["derive"] }
|
||||
regex = "1.11.1"
|
||||
rust-i18n = "3.1.2"
|
||||
serde_json = "1.0.132"
|
||||
thiserror = "2.0.2"
|
||||
toml = "0.8.19"
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = "0.3.18"
|
||||
thiserror = "2.0.2"
|
||||
regex = "1.11.1"
|
||||
rust-i18n = "3.1.2"
|
||||
|
||||
serde = { version = "1.0.214", features = ["derive"] }
|
||||
reqwest = { version = "0.12.9", default-features = false, features = [
|
||||
"charset",
|
||||
"http2",
|
||||
"rustls-tls",
|
||||
] }
|
||||
tokio-util = { version = "0.7.12", default-features = false }
|
||||
teloxide = { version = "0.13.0", default-features = false, features = [
|
||||
"macros",
|
||||
"ctrlc_handler",
|
||||
"rustls",
|
||||
] }
|
||||
tokio = { version = "1.41.1", default-features = false, features = [
|
||||
"rt-multi-thread",
|
||||
"macros",
|
||||
"sync",
|
||||
"signal",
|
||||
] }
|
||||
tokio-util = { version = "0.7.12", default-features = false }
|
||||
url = { version = "2.5.3", default-features = false, features = ["serde"] }
|
||||
teloxide = { version = "0.13.0", default-features = false, features = ["macros", "ctrlc_handler", "rustls"] }
|
||||
|
|
14
README.md
14
README.md
|
@ -4,8 +4,8 @@
|
|||
|
||||
Simple Forgejo instance guardian, banning users and alerting admins based on certain regular expressions (regex)
|
||||
|
||||
<!-- [![Forgejo CI Status](https://git.4rs.nl/awiteb/forgejo-guardian/badges/workflows/ci.yml/badge.svg)](https://git.4rs.nl/awiteb/forgejo-guardian)
|
||||
[![Forgejo CD Status](https://git.4rs.nl/awiteb/forgejo-guardian/badges/workflows/cd.yml/badge.svg)](https://git.4rs.nl/awiteb/forgejo-guardian) -->
|
||||
[![Forgejo CI Status](https://git.4rs.nl/awiteb/forgejo-guardian/badges/workflows/ci.yml/badge.svg)](https://git.4rs.nl/awiteb/forgejo-guardian)
|
||||
[![Forgejo CD Status](https://git.4rs.nl/awiteb/forgejo-guardian/badges/workflows/cd.yml/badge.svg)](https://git.4rs.nl/awiteb/forgejo-guardian)
|
||||
|
||||
[![agplv3-or-later](https://www.gnu.org/graphics/agplv3-88x31.png)](https://www.gnu.org/licenses/agpl-3.0.html)
|
||||
|
||||
|
@ -13,7 +13,7 @@ Simple Forgejo instance guardian, banning users and alerting admins based on cer
|
|||
|
||||
## Installation
|
||||
|
||||
You can let [cargo](https://doc.rust-lang.org/cargo/) build the binary for you, or build it yourself. <!-- You can also download the pre-built binaries from the [releases](https://git.4rs.nl/awiteb/forgejo-guardian/releases) page. -->
|
||||
You can let [cargo](https://doc.rust-lang.org/cargo/) build the binary for you, or build it yourself. You can also download the pre-built binaries from the [releases](https://git.4rs.nl/awiteb/forgejo-guardian/releases) page.
|
||||
|
||||
### Build it
|
||||
|
||||
|
@ -97,9 +97,17 @@ Expressions configuration section, with the following fields:
|
|||
- `websites`: Regular expressions to match against the websites
|
||||
- `locations`: Regular expressions to match against the locations
|
||||
|
||||
Each field is an array of regular expressions, the regular expression can be one of the following:
|
||||
|
||||
- String: The regular expression itself
|
||||
- Table: The regular expression and the reason, with the following fields:
|
||||
- `re` (string, array of string): The regular expression (if it's an array of strings, all regex in that array should match to ban/sus the user)
|
||||
- `reason` (optional string): The reason to ban/sus the user. This will be used in the notification message.
|
||||
|
||||
```toml
|
||||
[expressions.ban]
|
||||
usernames = ['^admin.*$']
|
||||
websites = ['^https://example\.com$', { re = '^https://example2\.com$', reason = "Example 2 is not allowed" }, '^https://example3\.com$']
|
||||
|
||||
[expressions.sus]
|
||||
usernames = ['^mod.*$']
|
||||
|
|
28
cliff.toml
28
cliff.toml
|
@ -64,10 +64,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n
|
||||
"""
|
||||
postprocessors = [
|
||||
{pattern = '<REPO>', replace = "https://git.4rs.nl/awiteb/forgejo-guardian"},
|
||||
{pattern = '- (\w+)(\(\w+\))?:', replace = "- "}, # Remove the type
|
||||
{pattern = '- \((\w+)\):', replace = "- (**$1**)"}, # Make the scope blod
|
||||
{pattern = "\t", replace = " "},
|
||||
{ pattern = '<REPO>', replace = "https://git.4rs.nl/awiteb/forgejo-guardian" },
|
||||
{ pattern = '- (\w+)(\(\w+\))?:', replace = "- " }, # Remove the type
|
||||
{ pattern = '- \((\w+)\):', replace = "- (**$1**)" }, # Make the scope blod
|
||||
{ pattern = "\t", replace = " " },
|
||||
]
|
||||
|
||||
# remove the leading and trailing whitespace from the template
|
||||
|
@ -75,24 +75,24 @@ trim = true
|
|||
|
||||
[git]
|
||||
# parse the commits based on https://www.conventionalcommits.org
|
||||
conventional_commits = false
|
||||
conventional_commits = false
|
||||
filter_unconventional = true
|
||||
# process each line of a commit as an individual commit
|
||||
split_commits = false
|
||||
# regex for preprocessing the commit messages
|
||||
commit_preprocessors = [
|
||||
{pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([**#${2}**](<REPO>/issues/${2}))"}, # replace issue numbers (Note the PR is also an issue in Forgejo, so this will also link to PRs)
|
||||
{pattern = ' +$', replace = ""}, # Remove trailing whitespace.
|
||||
{pattern = ' +', replace = " "}, # Replace multiple spaces with a single space.
|
||||
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([**#${2}**](<REPO>/issues/${2}))" }, # replace issue numbers (Note the PR is also an issue in Forgejo, so this will also link to PRs)
|
||||
{ pattern = ' +$', replace = "" }, # Remove trailing whitespace.
|
||||
{ pattern = ' +', replace = " " }, # Replace multiple spaces with a single space.
|
||||
]
|
||||
# regex for parsing and grouping commits
|
||||
commit_parsers = [
|
||||
{message = "^feat", group = "Added"},
|
||||
{message = "^fix", group = "Fixed"},
|
||||
{message = "^(refactor|change)", group = "Changed"},
|
||||
{message = "^deprecate", group = "Deprecated"},
|
||||
{message = "^remove", group = "Removed"},
|
||||
{message = "^security", group = "Security"},
|
||||
{ message = "^feat", group = "Added" },
|
||||
{ message = "^fix", group = "Fixed" },
|
||||
{ message = "^(refactor|change)", group = "Changed" },
|
||||
{ message = "^deprecate", group = "Deprecated" },
|
||||
{ message = "^remove", group = "Removed" },
|
||||
{ message = "^security", group = "Security" },
|
||||
]
|
||||
protect_breaking_commits = false
|
||||
# filter out the commits that are not matched by commit parsers
|
||||
|
|
|
@ -3,20 +3,24 @@ help_start = "مرحبًا، أنا حارس فورجيو، سأرسل لك إش
|
|||
sus_alert = """تم اكتشاف مستخدم مشبوه! 🚨
|
||||
• معرف المستخدم: %{user_id}
|
||||
• اسم المستخدم: %{username}
|
||||
• الإيميل: %{email}
|
||||
• الاسم الكامل: %{full_name}
|
||||
• النبذة: %{bio}
|
||||
• الموقع: %{website}
|
||||
• الملف التعريفي: %{profile}
|
||||
• السبب: %{reason}
|
||||
|
||||
هل تريد حظر هذا المستخدم؟
|
||||
"""
|
||||
ban_notify = """تم حظر مستخدم ⛔
|
||||
• معرف المستخدم: %{user_id}
|
||||
• اسم المستخدم: %{username}
|
||||
• الإيميل: %{email}
|
||||
• الاسم الكامل: %{full_name}
|
||||
• النبذة: %{bio}
|
||||
• الموقع: %{website}
|
||||
• الملف التعريفي: %{profile}
|
||||
• السبب: %{reason}
|
||||
"""
|
||||
ban_success = "تم حظر المستخدم بنجاح ⛔"
|
||||
ban_failed = "فشل حظر المستخدم! ⚠️"
|
||||
|
|
|
@ -3,20 +3,24 @@ help_start = "Hi, I'm forgejo-guardian, I'll send you a notification about suspi
|
|||
sus_alert = """Suspicious user detected! 🚨
|
||||
• User ID: %{user_id}
|
||||
• Username: %{username}
|
||||
• Email: %{email}
|
||||
• Full name: %{full_name}
|
||||
• Bio: %{bio}
|
||||
• Website: %{website}
|
||||
• Profile: %{profile}
|
||||
• Reason: %{reason}
|
||||
|
||||
Do you want to ban this user?
|
||||
"""
|
||||
ban_notify = """User has been banned ⛔
|
||||
• User ID: %{user_id}
|
||||
• Username: %{username}
|
||||
• Email: %{email}
|
||||
• Full name: %{full_name}
|
||||
• Bio: %{bio}
|
||||
• Website: %{website}
|
||||
• Profile: %{profile}
|
||||
• Reason: %{reason}
|
||||
"""
|
||||
ban_success = "User has been banned successfully ⛔"
|
||||
ban_failed = "Failed to ban the user! ⚠️"
|
||||
|
|
|
@ -3,20 +3,24 @@ help_start = "Привет! я forgejo-guardian. Я буду отправлят
|
|||
sus_alert = """Обнаружен подозрительный пользователь! 🚨
|
||||
• ID пользователя: %{user_id}
|
||||
• Юзернейм: %{username}
|
||||
• Имейл: %{email}
|
||||
• Полное имя: %{full_name}
|
||||
• Био: %{bio}
|
||||
• Вебсайт: %{website}
|
||||
• Профиль: %{profile}
|
||||
• Причина: %{reason}
|
||||
|
||||
Хотите забанить этого пользователя?
|
||||
"""
|
||||
ban_notify = """Пользователь заблокирован ⛔
|
||||
• ID пользователя: %{user_id}
|
||||
• Юзернейм: %{username}
|
||||
• Имейл: %{email}
|
||||
• Полное имя: %{full_name}
|
||||
• Био: %{bio}
|
||||
• Вебсайт: %{website}
|
||||
• Профиль: %{profile}
|
||||
• Причина: %{reason}
|
||||
"""
|
||||
ban_success = "Пользователь успешно забанен ⛔"
|
||||
ban_failed = "Не удалось забанить пользователя! ⚠️"
|
||||
|
|
160
src/config.rs
160
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,24 +44,105 @@ fn deserialize_str_url<'de, D>(deserializer: D) -> Result<Url, D::Error>
|
|||
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"));
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
/// Deserialize a vector of strings into a vector of `regex::Regex`
|
||||
fn deserialize_regex_vec<'de, D>(deserializer: D) -> Result<Vec<Regex>, D::Error>
|
||||
/// Parse the `re` key in the table, which can be a string or an array of string
|
||||
fn parse_re<'de, D>(toml_value: &Value) -> Result<Vec<String>, D::Error>
|
||||
where
|
||||
D: de::Deserializer<'de>,
|
||||
{
|
||||
Vec::<String>::deserialize(deserializer)?
|
||||
match toml_value {
|
||||
Value::String(str_re) => Ok(vec![str_re.to_owned()]),
|
||||
Value::Array(re_vec) => {
|
||||
re_vec
|
||||
.iter()
|
||||
.map(|str_re| {
|
||||
str_re.as_str().map(String::from).ok_or_else(|| {
|
||||
<D::Error as de::Error>::custom(format!(
|
||||
"expected an array of string, found `{str_re}`"
|
||||
))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
value => {
|
||||
Err(<D::Error as de::Error>::custom(format!(
|
||||
"expected a string value or an array of string for `re`, found `{value}`"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the vector of string regex to `Vec<Regex>`
|
||||
fn parse_re_vec<'de, D>(re_vec: Vec<String>) -> Result<Vec<Regex>, D::Error>
|
||||
where
|
||||
D: de::Deserializer<'de>,
|
||||
{
|
||||
re_vec
|
||||
.into_iter()
|
||||
.map(|s| Regex::new(&s))
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(|e| de::Error::custom(e.to_string()))
|
||||
.map(|re| re.parse().map_err(custom_de_err::<'de, D>))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Deserialize `RegexReason`
|
||||
fn deserialize_regex_reason<'de, D>(deserializer: D) -> Result<Vec<RegexReason>, D::Error>
|
||||
where
|
||||
D: de::Deserializer<'de>,
|
||||
{
|
||||
let Ok(toml_value) = Vec::<Value>::deserialize(deserializer) else {
|
||||
return Err(de::Error::custom(
|
||||
"expected an array contains strings or arrays of string or tables with the keys `re` \
|
||||
and optional `reason`",
|
||||
));
|
||||
};
|
||||
|
||||
toml_value
|
||||
.into_iter()
|
||||
.map(|value| {
|
||||
if let Value::Table(table) = value {
|
||||
let re_vec = table.get("re").map(parse_re::<D>).ok_or_else(|| {
|
||||
<D::Error as de::Error>::custom(
|
||||
"The table must contain a `re` key with a string value or an array of \
|
||||
string",
|
||||
)
|
||||
})??;
|
||||
let reason = table
|
||||
.get("reason")
|
||||
.map(|reason| {
|
||||
reason.as_str().map(String::from).ok_or_else(|| {
|
||||
<D::Error as de::Error>::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(parse_re_vec::<D>(re_vec)?, reason))
|
||||
} else if matches!(value, Value::String(_) | Value::Array(_)) {
|
||||
Ok(RegexReason::new(
|
||||
parse_re_vec::<D>(parse_re::<D>(&value)?)?,
|
||||
None,
|
||||
))
|
||||
} else {
|
||||
Err(de::Error::custom(format!(
|
||||
"unexpected value in the regex list, expected a string or an array of string \
|
||||
or a table with `re` (string) and optional `reason` (string), found `{value}`"
|
||||
)))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The forgejo config of the guard
|
||||
|
@ -81,44 +173,53 @@ pub struct Telegram {
|
|||
pub lang: Lang,
|
||||
}
|
||||
|
||||
/// The regular expression with the reason
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RegexReason {
|
||||
/// The regular expression
|
||||
pub re_vec: Vec<Regex>,
|
||||
/// Optional reason
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<Regex>,
|
||||
#[serde(deserialize_with = "deserialize_regex_reason")]
|
||||
pub usernames: Vec<RegexReason>,
|
||||
|
||||
/// 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<Regex>,
|
||||
#[serde(deserialize_with = "deserialize_regex_reason")]
|
||||
pub full_names: Vec<RegexReason>,
|
||||
|
||||
/// 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<Regex>,
|
||||
#[serde(deserialize_with = "deserialize_regex_reason")]
|
||||
pub biographies: Vec<RegexReason>,
|
||||
|
||||
/// 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<Regex>,
|
||||
#[serde(deserialize_with = "deserialize_regex_reason")]
|
||||
pub emails: Vec<RegexReason>,
|
||||
|
||||
/// 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<Regex>,
|
||||
#[serde(deserialize_with = "deserialize_regex_reason")]
|
||||
pub websites: Vec<RegexReason>,
|
||||
|
||||
/// 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<Regex>,
|
||||
#[serde(deserialize_with = "deserialize_regex_reason")]
|
||||
pub locations: Vec<RegexReason>,
|
||||
}
|
||||
|
||||
/// the expressions
|
||||
|
@ -155,3 +256,22 @@ pub struct Config {
|
|||
#[serde(default)]
|
||||
pub expressions: Exprs,
|
||||
}
|
||||
|
||||
impl RegexReason {
|
||||
/// Create a new `RegexReason` instance
|
||||
fn new(re: Vec<Regex>, reason: Option<String>) -> Self {
|
||||
Self { re_vec: re, reason }
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for RegexReason {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
for re in &self.re_vec {
|
||||
write!(f, "{re} ").ok();
|
||||
}
|
||||
if let Some(ref reason) = self.reason {
|
||||
write!(f, " ({reason})").ok();
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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::<forgejo_api::ForgejoUser>(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::<forgejo_api::ForgejoUser>(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);
|
||||
|
|
|
@ -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<Config>,
|
||||
cancellation_token: CancellationToken,
|
||||
sus_receiver: Receiver<ForgejoUser>,
|
||||
ban_receiver: Receiver<ForgejoUser>,
|
||||
sus_receiver: Receiver<(ForgejoUser, RegexReason)>,
|
||||
ban_receiver: Receiver<(ForgejoUser, RegexReason)>,
|
||||
) {
|
||||
tracing::info!("Starting the telegram bot");
|
||||
|
||||
|
|
|
@ -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,15 +52,20 @@ 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,
|
||||
username = user.username,
|
||||
email = user.email,
|
||||
full_name = not_found_if_empty(&user.full_name),
|
||||
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()
|
||||
}
|
||||
|
@ -65,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)
|
||||
|
@ -82,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?;
|
||||
|
@ -98,16 +108,16 @@ pub async fn users_handler(
|
|||
bot: Bot,
|
||||
config: Arc<Config>,
|
||||
cancellation_token: CancellationToken,
|
||||
mut sus_receiver: Receiver<ForgejoUser>,
|
||||
mut ban_receiver: Receiver<ForgejoUser>,
|
||||
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.");
|
||||
|
|
|
@ -14,22 +14,24 @@
|
|||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://gnu.org/licenses/agpl.txt>.
|
||||
|
||||
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<Regex>;
|
||||
fn is_match(&self, user: &ForgejoUser) -> Option<RegexReason>;
|
||||
}
|
||||
|
||||
impl ExprChecker for Expr {
|
||||
fn is_match<'a>(&'a self, user: &ForgejoUser) -> Option<Regex> {
|
||||
let one_of = |hay: &str, exprs: &'a Vec<Regex>| {
|
||||
exprs
|
||||
.iter()
|
||||
.find(|re| hay.split('\n').any(|line| re.is_match(line.trim())))
|
||||
fn is_match<'a>(&'a self, user: &ForgejoUser) -> Option<RegexReason> {
|
||||
let one_of = |hay: &str, exprs: &'a Vec<RegexReason>| {
|
||||
exprs.iter().find(|re| {
|
||||
hay.split('\n')
|
||||
.any(|line| re.re_vec.iter().all(|re| re.is_match(line.trim())))
|
||||
})
|
||||
};
|
||||
[
|
||||
one_of(&user.username, &self.usernames),
|
||||
|
|
|
@ -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<ForgejoUser>,
|
||||
ban_sender: &Sender<ForgejoUser>,
|
||||
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<AtomicUsize>,
|
||||
request_client: Arc<reqwest::Client>,
|
||||
config: Arc<Config>,
|
||||
sus_sender: Sender<ForgejoUser>,
|
||||
ban_sender: Sender<ForgejoUser>,
|
||||
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<Config>,
|
||||
cancellation_token: CancellationToken,
|
||||
sus_sender: Sender<ForgejoUser>,
|
||||
ban_sender: Sender<ForgejoUser>,
|
||||
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());
|
||||
|
|
14
sumi.toml
14
sumi.toml
|
@ -50,7 +50,19 @@ scopes_allowed = []
|
|||
|
||||
# Rule: List of allowed commit types.
|
||||
# An empty list allows all types. Example: ["feat", "fix", "docs"].
|
||||
types_allowed = ["feat", "fix", "docs", "refactor", "change", "deprecate", "remove", "security", "perf", "test", "chore"]
|
||||
types_allowed = [
|
||||
"feat",
|
||||
"fix",
|
||||
"docs",
|
||||
"refactor",
|
||||
"change",
|
||||
"deprecate",
|
||||
"remove",
|
||||
"security",
|
||||
"perf",
|
||||
"test",
|
||||
"chore",
|
||||
]
|
||||
|
||||
# Rule: Header must match regex pattern.
|
||||
# Example: '^JIRA-\d+:'.
|
||||
|
|
Loading…
Reference in a new issue