Compare commits

..

12 commits

Author SHA1 Message Date
forgejo-actions
6b0931c2ca Update changelog for v0.2.0 2024-12-09 23:04:29 +00:00
forgejo-actions
65a333446d chore(changelog): Update changelog 2024-12-09 22:52:04 +00:00
af11f8aa14
chore: Bump the version to v0.2.0
All checks were successful
Write changelog / write-changelog (push) Successful in 7s
CD / build-assets (aarch64-unknown-linux-gnu) (push) Successful in 7m2s
Rust CI / Rust CI (push) Successful in 7m31s
CD / build-assets (aarch64-unknown-linux-musl) (push) Successful in 7m20s
CD / build-assets (x86_64-unknown-linux-gnu) (push) Successful in 4m21s
CD / build-assets (x86_64-unknown-linux-musl) (push) Successful in 4m32s
CD / release (push) Successful in 22s
Signed-off-by: Awiteb <a@4rs.nl>
2024-12-09 22:51:25 +00:00
forgejo-actions
dd360c73aa chore(changelog): Update changelog 2024-12-09 22:31:33 +00:00
3d6b49c01a
fix: Matching users multiline description correctly
All checks were successful
Write changelog / write-changelog (push) Successful in 6s
Rust CI / Rust CI (push) Successful in 3m39s
Reported-by: Awiteb <a@4rs.nl>
Fixes: #2
Signed-off-by: Awiteb <a@4rs.nl>
2024-12-10 01:31:04 +03:00
forgejo-actions
7a0f3fab27 chore(changelog): Update changelog 2024-11-17 18:01:02 +00:00
d59b4e6d3f
chore: Update README.md
All checks were successful
Write changelog / write-changelog (push) Successful in 25s
Rust CI / Rust CI (push) Successful in 4m3s
Signed-off-by: Awiteb <a@4rs.nl>
2024-11-17 21:00:26 +03:00
cc2f8a791b
feat: Possibility to put an array of expressions and they must all matches
Signed-off-by: Awiteb <a@4rs.nl>
2024-11-17 21:00:25 +03:00
c96b859931
feat: Reason for banned and suspicious
Signed-off-by: Awiteb <a@4rs.nl>
2024-11-17 21:00:25 +03:00
62ca71a140
chore: Toml formatting
Signed-off-by: Awiteb <a@4rs.nl>
2024-11-17 21:00:25 +03:00
b3397f6316
feat: Add the email to user details message
Signed-off-by: Awiteb <a@4rs.nl>
2024-11-17 21:00:25 +03:00
forgejo-actions
642d8994de Update changelog for v0.1.0 2024-11-16 13:25:59 +00:00
15 changed files with 267 additions and 78 deletions

View file

@ -5,6 +5,16 @@ 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## unreleased ## unreleased
## [0.2.0](https://git.4rs.nl/awiteb/forgejo-guardian/compare/v0.1.0..v0.2.0) - 2024-12-09
### Added
- Add the email to user details message ([`b3397f6`](https://git.4rs.nl/awiteb/forgejo-guardian/commit/b3397f63163b6679248a680a7ab423d7852df647))
- Possibility to put an array of expressions and they must all matches ([`cc2f8a7`](https://git.4rs.nl/awiteb/forgejo-guardian/commit/cc2f8a791b2c1be8cae2f6ba9dfd0a718d4d3c71))
- Reason for banned and suspicious ([`c96b859`](https://git.4rs.nl/awiteb/forgejo-guardian/commit/c96b859931d893751b15977f2ede7034b46628e7))
### Fixed
- Matching users multiline description correctly ([`3d6b49c`](https://git.4rs.nl/awiteb/forgejo-guardian/commit/3d6b49c01a61d6ee18da488dbc1d1fbf5caedf3c))
## 0.1.0 - 2024-11-16
### Added ### Added
- Add Russian language ([`8dc8c0d`](https://git.4rs.nl/awiteb/forgejo-guardian/commit/8dc8c0d2315d7d47f6f2605fcdfd62499a4c4460)) - Add Russian language ([`8dc8c0d`](https://git.4rs.nl/awiteb/forgejo-guardian/commit/8dc8c0d2315d7d47f6f2605fcdfd62499a4c4460))
- Add telegram bot to the config ([`68cd88e`](https://git.4rs.nl/awiteb/forgejo-guardian/commit/68cd88e96af0cd92c10e30ec9675f003c89c436f)) - Add telegram bot to the config ([`68cd88e`](https://git.4rs.nl/awiteb/forgejo-guardian/commit/68cd88e96af0cd92c10e30ec9675f003c89c436f))

2
Cargo.lock generated
View file

@ -331,7 +331,7 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]] [[package]]
name = "forgejo-guardian" name = "forgejo-guardian"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"regex", "regex",
"reqwest 0.12.9", "reqwest 0.12.9",

View file

@ -1,7 +1,7 @@
[package] [package]
name = "forgejo-guardian" name = "forgejo-guardian"
description = "Simple Forgejo instance guardian, banning users and alerting admins based on certain regular expressions" description = "Simple Forgejo instance guardian, banning users and alerting admins based on certain regular expressions"
version = "0.1.0" version = "0.2.0"
edition = "2021" edition = "2021"
authors = ["Awiteb <a@4rs.nl>"] authors = ["Awiteb <a@4rs.nl>"]
repository = "https://git.4rs.nl/awiteb/forgejo-guardian" repository = "https://git.4rs.nl/awiteb/forgejo-guardian"
@ -9,26 +9,30 @@ license = "AGPL-3.0-or-later"
[dependencies] [dependencies]
serde = { version = "1.0.214", features = ["derive"] } regex = "1.11.1"
rust-i18n = "3.1.2"
serde_json = "1.0.132" serde_json = "1.0.132"
thiserror = "2.0.2"
toml = "0.8.19" toml = "0.8.19"
tracing = "0.1.40" tracing = "0.1.40"
tracing-subscriber = "0.3.18" 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 = [ reqwest = { version = "0.12.9", default-features = false, features = [
"charset", "charset",
"http2", "http2",
"rustls-tls", "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 = [ tokio = { version = "1.41.1", default-features = false, features = [
"rt-multi-thread", "rt-multi-thread",
"macros", "macros",
"sync", "sync",
"signal", "signal",
] } ] }
tokio-util = { version = "0.7.12", default-features = false }
url = { version = "2.5.3", default-features = false, features = ["serde"] } url = { version = "2.5.3", default-features = false, features = ["serde"] }
teloxide = { version = "0.13.0", default-features = false, features = ["macros", "ctrlc_handler", "rustls"] }

View file

@ -4,8 +4,8 @@
Simple Forgejo instance guardian, banning users and alerting admins based on certain regular expressions (regex) 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 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 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) [![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 ## 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 ### Build it
@ -97,9 +97,17 @@ Expressions configuration section, with the following fields:
- `websites`: Regular expressions to match against the websites - `websites`: Regular expressions to match against the websites
- `locations`: Regular expressions to match against the locations - `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 ```toml
[expressions.ban] [expressions.ban]
usernames = ['^admin.*$'] usernames = ['^admin.*$']
websites = ['^https://example\.com$', { re = '^https://example2\.com$', reason = "Example 2 is not allowed" }, '^https://example3\.com$']
[expressions.sus] [expressions.sus]
usernames = ['^mod.*$'] usernames = ['^mod.*$']

View file

@ -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 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n
""" """
postprocessors = [ postprocessors = [
{pattern = '<REPO>', replace = "https://git.4rs.nl/awiteb/forgejo-guardian"}, { pattern = '<REPO>', replace = "https://git.4rs.nl/awiteb/forgejo-guardian" },
{pattern = '- (\w+)(\(\w+\))?:', replace = "- "}, # Remove the type { pattern = '- (\w+)(\(\w+\))?:', replace = "- " }, # Remove the type
{pattern = '- \((\w+)\):', replace = "- (**$1**)"}, # Make the scope blod { pattern = '- \((\w+)\):', replace = "- (**$1**)" }, # Make the scope blod
{pattern = "\t", replace = " "}, { pattern = "\t", replace = " " },
] ]
# remove the leading and trailing whitespace from the template # remove the leading and trailing whitespace from the template
@ -81,18 +81,18 @@ filter_unconventional = true
split_commits = false split_commits = false
# regex for preprocessing the commit messages # regex for preprocessing the commit messages
commit_preprocessors = [ 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 = '\((\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 = "" }, # Remove trailing whitespace.
{pattern = ' +', replace = " "}, # Replace multiple spaces with a single space. { pattern = ' +', replace = " " }, # Replace multiple spaces with a single space.
] ]
# regex for parsing and grouping commits # regex for parsing and grouping commits
commit_parsers = [ commit_parsers = [
{message = "^feat", group = "Added"}, { message = "^feat", group = "Added" },
{message = "^fix", group = "Fixed"}, { message = "^fix", group = "Fixed" },
{message = "^(refactor|change)", group = "Changed"}, { message = "^(refactor|change)", group = "Changed" },
{message = "^deprecate", group = "Deprecated"}, { message = "^deprecate", group = "Deprecated" },
{message = "^remove", group = "Removed"}, { message = "^remove", group = "Removed" },
{message = "^security", group = "Security"}, { message = "^security", group = "Security" },
] ]
protect_breaking_commits = false protect_breaking_commits = false
# filter out the commits that are not matched by commit parsers # filter out the commits that are not matched by commit parsers

View file

@ -3,20 +3,24 @@ help_start = "مرحبًا، أنا حارس فورجيو، سأرسل لك إش
sus_alert = """تم اكتشاف مستخدم مشبوه! 🚨 sus_alert = """تم اكتشاف مستخدم مشبوه! 🚨
معرف المستخدم: %{user_id} معرف المستخدم: %{user_id}
اسم المستخدم: %{username} اسم المستخدم: %{username}
الإيميل: %{email}
الاسم الكامل: %{full_name} الاسم الكامل: %{full_name}
النبذة: %{bio} النبذة: %{bio}
الموقع: %{website} الموقع: %{website}
الملف التعريفي: %{profile} الملف التعريفي: %{profile}
السبب: %{reason}
هل تريد حظر هذا المستخدم؟ هل تريد حظر هذا المستخدم؟
""" """
ban_notify = """تم حظر مستخدم ban_notify = """تم حظر مستخدم
معرف المستخدم: %{user_id} معرف المستخدم: %{user_id}
اسم المستخدم: %{username} اسم المستخدم: %{username}
الإيميل: %{email}
الاسم الكامل: %{full_name} الاسم الكامل: %{full_name}
النبذة: %{bio} النبذة: %{bio}
الموقع: %{website} الموقع: %{website}
الملف التعريفي: %{profile} الملف التعريفي: %{profile}
السبب: %{reason}
""" """
ban_success = "تم حظر المستخدم بنجاح ⛔" ban_success = "تم حظر المستخدم بنجاح ⛔"
ban_failed = "فشل حظر المستخدم! ⚠️" ban_failed = "فشل حظر المستخدم! ⚠️"

View file

@ -3,20 +3,24 @@ help_start = "Hi, I'm forgejo-guardian, I'll send you a notification about suspi
sus_alert = """Suspicious user detected! 🚨 sus_alert = """Suspicious user detected! 🚨
User ID: %{user_id} User ID: %{user_id}
Username: %{username} Username: %{username}
Email: %{email}
Full name: %{full_name} Full name: %{full_name}
Bio: %{bio} Bio: %{bio}
Website: %{website} Website: %{website}
Profile: %{profile} Profile: %{profile}
Reason: %{reason}
Do you want to ban this user? Do you want to ban this user?
""" """
ban_notify = """User has been banned ban_notify = """User has been banned
User ID: %{user_id} User ID: %{user_id}
Username: %{username} Username: %{username}
Email: %{email}
Full name: %{full_name} Full name: %{full_name}
Bio: %{bio} Bio: %{bio}
Website: %{website} Website: %{website}
Profile: %{profile} Profile: %{profile}
Reason: %{reason}
""" """
ban_success = "User has been banned successfully ⛔" ban_success = "User has been banned successfully ⛔"
ban_failed = "Failed to ban the user! ⚠️" ban_failed = "Failed to ban the user! ⚠️"

View file

@ -3,20 +3,24 @@ help_start = "Привет! я forgejo-guardian. Я буду отправлят
sus_alert = """Обнаружен подозрительный пользователь! 🚨 sus_alert = """Обнаружен подозрительный пользователь! 🚨
ID пользователя: %{user_id} ID пользователя: %{user_id}
Юзернейм: %{username} Юзернейм: %{username}
Имейл: %{email}
Полное имя: %{full_name} Полное имя: %{full_name}
Био: %{bio} Био: %{bio}
Вебсайт: %{website} Вебсайт: %{website}
Профиль: %{profile} Профиль: %{profile}
Причина: %{reason}
Хотите забанить этого пользователя? Хотите забанить этого пользователя?
""" """
ban_notify = """Пользователь заблокирован ban_notify = """Пользователь заблокирован
ID пользователя: %{user_id} ID пользователя: %{user_id}
Юзернейм: %{username} Юзернейм: %{username}
Имейл: %{email}
Полное имя: %{full_name} Полное имя: %{full_name}
Био: %{bio} Био: %{bio}
Вебсайт: %{website} Вебсайт: %{website}
Профиль: %{profile} Профиль: %{profile}
Причина: %{reason}
""" """
ban_success = "Пользователь успешно забанен ⛔" ban_success = "Пользователь успешно забанен ⛔"
ban_failed = "Не удалось забанить пользователя! ⚠️" ban_failed = "Не удалось забанить пользователя! ⚠️"

View file

@ -19,13 +19,24 @@ pub(crate) const CONFIG_PATH_ENV: &str = "FORGEJO_GUARDIAN_CONFIG";
/// Defult config path location /// Defult config path location
pub(crate) const DEFAULT_CONFIG_PATH: &str = "/app/forgejo-guardian.toml"; pub(crate) const DEFAULT_CONFIG_PATH: &str = "/app/forgejo-guardian.toml";
use std::fmt::Display;
use regex::Regex; use regex::Regex;
use serde::{de, Deserialize}; use serde::{de, Deserialize};
use teloxide::types::ChatId; use teloxide::types::ChatId;
use toml::Value;
use url::Url; use url::Url;
use crate::telegram_bot::Lang; 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` /// Deserialize a string into a `url::Url`
/// ///
/// This will check if the url is `http` or `https` and if it is a valid 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 where
D: de::Deserializer<'de>, D: de::Deserializer<'de>,
{ {
let url = Url::parse(&String::deserialize(deserializer)?) let url = Url::parse(&String::deserialize(deserializer)?).map_err(custom_de_err::<'de, D>)?;
.map_err(|e| de::Error::custom(e.to_string()))?;
if url.scheme() != "http" && url.scheme() != "https" { if url.scheme() != "http" && url.scheme() != "https" {
return Err(de::Error::custom("URL scheme must be http or https")); return Err(de::Error::custom("URL scheme must be http or https"));
} }
Ok(url) Ok(url)
} }
/// Deserialize a vector of strings into a vector of `regex::Regex` /// Parse the `re` key in the table, which can be a string or an array of string
fn deserialize_regex_vec<'de, D>(deserializer: D) -> Result<Vec<Regex>, D::Error> fn parse_re<'de, D>(toml_value: &Value) -> Result<Vec<String>, D::Error>
where where
D: de::Deserializer<'de>, 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() .into_iter()
.map(|s| Regex::new(&s)) .map(|re| re.parse().map_err(custom_de_err::<'de, D>))
.collect::<Result<_, _>>() .collect()
.map_err(|e| de::Error::custom(e.to_string())) }
/// 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 /// The forgejo config of the guard
@ -81,44 +173,53 @@ pub struct Telegram {
pub lang: Lang, 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 /// The expression
#[derive(Deserialize, Debug, Default)] #[derive(Deserialize, Debug, Default)]
pub struct Expr { pub struct Expr {
/// The regular expressions that the action will be performed if they are /// The regular expressions that the action will be performed if they are
/// present in the username /// present in the username
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_regex_vec")] #[serde(deserialize_with = "deserialize_regex_reason")]
pub usernames: Vec<Regex>, pub usernames: Vec<RegexReason>,
/// The regular expressions that the action will be performed if they are /// The regular expressions that the action will be performed if they are
/// present in the user full_name /// present in the user full_name
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_regex_vec")] #[serde(deserialize_with = "deserialize_regex_reason")]
pub full_names: Vec<Regex>, pub full_names: Vec<RegexReason>,
/// The regular expressions that the action will be performed if they are /// The regular expressions that the action will be performed if they are
/// present in the user biography /// present in the user biography
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_regex_vec")] #[serde(deserialize_with = "deserialize_regex_reason")]
pub biographies: Vec<Regex>, pub biographies: Vec<RegexReason>,
/// The regular expressions that the action will be performed if they are /// The regular expressions that the action will be performed if they are
/// present in the user email /// present in the user email
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_regex_vec")] #[serde(deserialize_with = "deserialize_regex_reason")]
pub emails: Vec<Regex>, pub emails: Vec<RegexReason>,
/// The regular expressions that the action will be performed if they are /// The regular expressions that the action will be performed if they are
/// present in the user website /// present in the user website
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_regex_vec")] #[serde(deserialize_with = "deserialize_regex_reason")]
pub websites: Vec<Regex>, pub websites: Vec<RegexReason>,
/// The regular expressions that the action will be performed if they are /// The regular expressions that the action will be performed if they are
/// present in the user location /// present in the user location
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_regex_vec")] #[serde(deserialize_with = "deserialize_regex_reason")]
pub locations: Vec<Regex>, pub locations: Vec<RegexReason>,
} }
/// the expressions /// the expressions
@ -155,3 +256,22 @@ pub struct Config {
#[serde(default)] #[serde(default)]
pub expressions: Exprs, 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(())
}
}

View file

@ -37,10 +37,12 @@ async fn try_main() -> error::GuardResult<()> {
let cancellation_token = CancellationToken::new(); let cancellation_token = CancellationToken::new();
// Suspicious users are sent and received in this channel, users who meet the // Suspicious users are sent and received in this channel, users who meet the
// `alert` expressions // `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 // 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 // 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!("The instance: {}", config.forgejo.instance);
tracing::info!("Dry run: {}", config.dry_run); tracing::info!("Dry run: {}", config.dry_run);

View file

@ -28,7 +28,10 @@ use teloxide::{dispatching::UpdateFilterExt, prelude::*};
use tokio::sync::mpsc::Receiver; use tokio::sync::mpsc::Receiver;
use tokio_util::sync::CancellationToken; 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 /// Language of the telegram bot
#[derive(Deserialize)] #[derive(Deserialize)]
@ -54,8 +57,8 @@ impl Lang {
pub async fn start_bot( pub async fn start_bot(
config: Arc<Config>, config: Arc<Config>,
cancellation_token: CancellationToken, cancellation_token: CancellationToken,
sus_receiver: Receiver<ForgejoUser>, sus_receiver: Receiver<(ForgejoUser, RegexReason)>,
ban_receiver: Receiver<ForgejoUser>, ban_receiver: Receiver<(ForgejoUser, RegexReason)>,
) { ) {
tracing::info!("Starting the telegram bot"); tracing::info!("Starting the telegram bot");

View file

@ -23,7 +23,10 @@ use teloxide::{
use tokio::sync::mpsc::Receiver; use tokio::sync::mpsc::Receiver;
use tokio_util::sync::CancellationToken; 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 /// Create an inline keyboard of the suspicious user
fn make_sus_inline_keyboard(sus_user: &ForgejoUser) -> InlineKeyboardMarkup { 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 /// Generate a user details message
fn user_details(msg: &str, user: &ForgejoUser) -> String { fn user_details(msg: &str, user: &ForgejoUser, re: &RegexReason) -> String {
t!( t!(
msg, msg,
user_id = user.id, user_id = user.id,
username = user.username, username = user.username,
email = user.email,
full_name = not_found_if_empty(&user.full_name), full_name = not_found_if_empty(&user.full_name),
bio = not_found_if_empty(&user.biography), bio = not_found_if_empty(&user.biography),
website = not_found_if_empty(&user.website), website = not_found_if_empty(&user.website),
profile = user.html_url, profile = user.html_url,
reason = re
.reason
.clone()
.unwrap_or_else(|| t!("words.not_found").to_string()),
) )
.to_string() .to_string()
} }
@ -65,12 +73,13 @@ fn user_details(msg: &str, user: &ForgejoUser) -> String {
/// Send a suspicious user alert to the admins /// Send a suspicious user alert to the admins
pub async fn send_sus_alert( pub async fn send_sus_alert(
bot: &Bot, bot: &Bot,
re: &RegexReason,
sus_user: ForgejoUser, sus_user: ForgejoUser,
config: &Config, config: &Config,
) -> ResponseResult<()> { ) -> ResponseResult<()> {
let keyboard = make_sus_inline_keyboard(&sus_user); 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)) bot.send_photo(config.telegram.chat, InputFile::url(sus_user.avatar_url))
.caption(caption) .caption(caption)
.reply_markup(keyboard) .reply_markup(keyboard)
@ -82,10 +91,11 @@ pub async fn send_sus_alert(
/// Send a ban notification to the admins chat /// Send a ban notification to the admins chat
pub async fn send_ban_notify( pub async fn send_ban_notify(
bot: &Bot, bot: &Bot,
re: &RegexReason,
sus_user: ForgejoUser, sus_user: ForgejoUser,
config: &Config, config: &Config,
) -> ResponseResult<()> { ) -> 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)) bot.send_photo(config.telegram.chat, InputFile::url(sus_user.avatar_url))
.caption(caption) .caption(caption)
.await?; .await?;
@ -98,16 +108,16 @@ pub async fn users_handler(
bot: Bot, bot: Bot,
config: Arc<Config>, config: Arc<Config>,
cancellation_token: CancellationToken, cancellation_token: CancellationToken,
mut sus_receiver: Receiver<ForgejoUser>, mut sus_receiver: Receiver<(ForgejoUser, RegexReason)>,
mut ban_receiver: Receiver<ForgejoUser>, mut ban_receiver: Receiver<(ForgejoUser, RegexReason)>,
) { ) {
loop { loop {
tokio::select! { tokio::select! {
Some(sus_user) = sus_receiver.recv() => { Some((sus_user, re)) = sus_receiver.recv() => {
send_sus_alert(&bot, sus_user, &config).await.ok(); send_sus_alert(&bot, &re, sus_user, &config).await.ok();
} }
Some(banned_user) = ban_receiver.recv() => { Some((banned_user, re)) = ban_receiver.recv() => {
send_ban_notify(&bot, banned_user, &config).await.ok(); send_ban_notify(&bot, &re, banned_user, &config).await.ok();
} }
_ = cancellation_token.cancelled() => { _ = cancellation_token.cancelled() => {
tracing::info!("sus users handler has been stopped successfully."); tracing::info!("sus users handler has been stopped successfully.");

View file

@ -14,22 +14,30 @@
// You should have received a copy of the GNU Affero General Public License // 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>. // along with this program. If not, see <https://gnu.org/licenses/agpl.txt>.
use regex::Regex; use crate::{
config::{Expr, RegexReason},
use crate::{config::Expr, forgejo_api::ForgejoUser}; forgejo_api::ForgejoUser,
};
/// Trait for checking if a user matches one of the expressions /// Trait for checking if a user matches one of the expressions
pub trait ExprChecker { pub trait ExprChecker {
/// Returns the first matching expression, if any /// 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 { impl ExprChecker for Expr {
fn is_match<'a>(&'a self, user: &ForgejoUser) -> Option<Regex> { fn is_match<'a>(&'a self, user: &ForgejoUser) -> Option<RegexReason> {
let one_of = |hay: &str, exprs: &'a Vec<Regex>| { let one_of = |hay: &str, exprs: &'a Vec<RegexReason>| {
// Join the user bio into a single line
// ref: https://git.4rs.nl/awiteb/forgejo-guardian/issues/2
let hay = if hay.contains('\n') {
hay.split('\n').collect::<Vec<_>>().join(" ")
} else {
hay.to_string()
};
exprs exprs
.iter() .iter()
.find(|re| hay.split('\n').any(|line| re.is_match(line.trim()))) .find(|re_re| re_re.re_vec.iter().all(|re| re.is_match(&hay)))
}; };
[ [
one_of(&user.username, &self.usernames), one_of(&user.username, &self.usernames),

View file

@ -26,7 +26,7 @@ use tokio::sync::mpsc::Sender;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use crate::{ use crate::{
config::Config, config::{Config, RegexReason},
error::GuardResult, error::GuardResult,
forgejo_api::{self, get_users, ForgejoUser}, forgejo_api::{self, get_users, ForgejoUser},
traits::ExprChecker, traits::ExprChecker,
@ -58,15 +58,15 @@ async fn check_new_user(
user: ForgejoUser, user: ForgejoUser,
request_client: &reqwest::Client, request_client: &reqwest::Client,
config: &Config, config: &Config,
sus_sender: &Sender<ForgejoUser>, sus_sender: &Sender<(ForgejoUser, RegexReason)>,
ban_sender: &Sender<ForgejoUser>, ban_sender: &Sender<(ForgejoUser, RegexReason)>,
) { ) {
if let Some(re) = config.expressions.ban.is_match(&user) { if let Some(re) = config.expressions.ban.is_match(&user) {
tracing::info!("@{} has been banned because `{re}`", user.username); tracing::info!("@{} has been banned because `{re}`", user.username);
if config.dry_run { if config.dry_run {
// If it's a dry run, we don't need to ban the user // If it's a dry run, we don't need to ban the user
if config.telegram.ban_alert { if config.telegram.ban_alert {
ban_sender.send(user).await.ok(); ban_sender.send((user, re)).await.ok();
} }
return; return;
} }
@ -81,11 +81,11 @@ async fn check_new_user(
{ {
tracing::error!("Error while banning a user: {err}"); tracing::error!("Error while banning a user: {err}");
} else if config.telegram.ban_alert { } 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) { } else if let Some(re) = config.expressions.sus.is_match(&user) {
tracing::info!("@{} has been suspected because `{re}`", user.username); 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>, last_user_id: Arc<AtomicUsize>,
request_client: Arc<reqwest::Client>, request_client: Arc<reqwest::Client>,
config: Arc<Config>, config: Arc<Config>,
sus_sender: Sender<ForgejoUser>, sus_sender: Sender<(ForgejoUser, RegexReason)>,
ban_sender: Sender<ForgejoUser>, ban_sender: Sender<(ForgejoUser, RegexReason)>,
) { ) {
let is_first_fetch = last_user_id.load(Ordering::Relaxed) == 0; let is_first_fetch = last_user_id.load(Ordering::Relaxed) == 0;
match get_new_users( match get_new_users(
@ -135,8 +135,8 @@ async fn check_new_users(
pub async fn users_fetcher( pub async fn users_fetcher(
config: Arc<Config>, config: Arc<Config>,
cancellation_token: CancellationToken, cancellation_token: CancellationToken,
sus_sender: Sender<ForgejoUser>, sus_sender: Sender<(ForgejoUser, RegexReason)>,
ban_sender: Sender<ForgejoUser>, ban_sender: Sender<(ForgejoUser, RegexReason)>,
) { ) {
let last_user_id = Arc::new(AtomicUsize::new(0)); let last_user_id = Arc::new(AtomicUsize::new(0));
let request_client = Arc::new(reqwest::Client::new()); let request_client = Arc::new(reqwest::Client::new());

View file

@ -50,7 +50,19 @@ scopes_allowed = []
# Rule: List of allowed commit types. # Rule: List of allowed commit types.
# An empty list allows all types. Example: ["feat", "fix", "docs"]. # 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. # Rule: Header must match regex pattern.
# Example: '^JIRA-\d+:'. # Example: '^JIRA-\d+:'.