232 lines
8.0 KiB
Rust
232 lines
8.0 KiB
Rust
use axum::{http::StatusCode, response::IntoResponse, routing::post, Json, Router};
|
||
use regex::Regex;
|
||
use reqwest::Client;
|
||
use rustrict::CensorStr;
|
||
use serde::{Deserialize, Serialize};
|
||
use std::sync::LazyLock;
|
||
|
||
#[derive(Deserialize, Serialize, Debug)]
|
||
struct CommentPayload {
|
||
nickname: String,
|
||
message: String,
|
||
}
|
||
|
||
static BAD_WORDS_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(r"(?i)(хуй|хуя|хуе|хул|пизд|ебат|ебан|ебл|ебуч|бляд|блят|шлюх|сука|сучк|жоп|пидор|пидар|педик|голубо|гондон|гандон|залуп|мудак|мудил|мраз|уеб|чмо|долбое|даун|чурк|нигер|нигга|хач|хохол|кацап|жид|порно|наркот|убить|насилие|соли|закладк|мефедрон|гашиш|спайс|heroin|meth|murder|gore|cpam|scam|crypto|binance|bitco)").unwrap()
|
||
});
|
||
|
||
const SAFE_WORDS: &[&str] = &[
|
||
// Защита корня "ебл/ебат" (самые частые ложные срабатывания)
|
||
"колебат",
|
||
"оскорблят",
|
||
"употреблят",
|
||
"расслаблят",
|
||
"потреблят",
|
||
"истреблят",
|
||
"хлебат",
|
||
"щебет",
|
||
"мебел",
|
||
"стебл",
|
||
"ослабл",
|
||
"гребл",
|
||
"хлебл",
|
||
"дебел",
|
||
"колебл",
|
||
// Защита корня "хуй"
|
||
"страхуй",
|
||
"подстрахуй",
|
||
// Защита корня "бля"
|
||
"рубл",
|
||
"сабл",
|
||
"челяб",
|
||
"влюбл",
|
||
"ограбл",
|
||
"дубл",
|
||
"гибл",
|
||
"корабл",
|
||
// Защита корня "жоп"
|
||
"джоп",
|
||
"джобс",
|
||
"прожора",
|
||
// Защита корня "пидар/пидор"
|
||
"скипидар",
|
||
"купидор",
|
||
"коридор",
|
||
// Защита корня "чмо"
|
||
"чмок",
|
||
"ячмен",
|
||
"черемух",
|
||
// Защита корня "мраз"
|
||
"мразер",
|
||
"замороз",
|
||
"мороз",
|
||
"мрамор",
|
||
// Защита корня "сука"
|
||
"рисука",
|
||
"присука",
|
||
"бамбука",
|
||
// Защита корня "жид"
|
||
"ожид",
|
||
"жидкос",
|
||
"жидк",
|
||
// Защита корня "даун"
|
||
"даунгрейд",
|
||
"адаун",
|
||
];
|
||
// Заменяет легальные слова-исключения на плейсхолдер перед проверкой
|
||
fn apply_allowlist(text: &str) -> String {
|
||
let mut safe_text = text.to_string();
|
||
for &safe_word in SAFE_WORDS {
|
||
safe_text = safe_text.replace(safe_word, "[SAFE]");
|
||
}
|
||
safe_text
|
||
}
|
||
// Функция жесткой нормализации текста перед проверкой
|
||
fn normalize_for_filter(input: &str) -> String {
|
||
let mut normalized = String::new();
|
||
let mut prev_char: Option<char> = None;
|
||
|
||
let lower_input = input.to_lowercase();
|
||
|
||
for ch in lower_input.chars() {
|
||
// СОХРАНЯЕМ ПРОБЕЛЫ, чтобы не склеивать соседние слова ("нас укачало" != "насукачало")
|
||
if ch.is_whitespace() {
|
||
normalized.push(' ');
|
||
prev_char = Some(' ');
|
||
continue;
|
||
}
|
||
|
||
// Вырезаем пунктуацию (чтобы ловить х.у.й), но пропускаем дальше только буквы/цифры
|
||
if !ch.is_alphanumeric() {
|
||
continue;
|
||
}
|
||
|
||
// Замена "leetspeak" (англо-русская подмена и цифры)
|
||
let mapped = match ch {
|
||
'a' | '@' => 'а',
|
||
'b' => 'в',
|
||
'c' => 'с',
|
||
'e' | '3' => 'е',
|
||
'o' | '0' => 'о',
|
||
'p' => 'р',
|
||
'x' => 'х',
|
||
'y' => 'у',
|
||
'm' => 'м',
|
||
't' => 'т',
|
||
'k' => 'к',
|
||
'h' => 'н',
|
||
'4' => 'ч',
|
||
_ => ch,
|
||
};
|
||
|
||
// Сжатие повторяющихся символов (хххуууууууй -> хуй)
|
||
// Не сжимаем пробелы, только буквы
|
||
if let Some(p) = prev_char {
|
||
if p == mapped && mapped != ' ' {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
normalized.push(mapped);
|
||
prev_char = Some(mapped);
|
||
}
|
||
|
||
normalized
|
||
}
|
||
|
||
// Функция проверки на спам (КАПС или слишком длинные бессмысленные слова)
|
||
fn is_spam(input: &str) -> bool {
|
||
let alpha_chars: String = input.chars().filter(|c| c.is_alphabetic()).collect();
|
||
if alpha_chars.is_empty() {
|
||
return false;
|
||
}
|
||
|
||
// Проверка на КАПС (> 70% заглавных при длине > 10)
|
||
let uppercase_count = alpha_chars.chars().filter(|c| c.is_uppercase()).count();
|
||
let uppercase_ratio = uppercase_count as f64 / alpha_chars.len() as f64;
|
||
|
||
if input.len() > 10 && uppercase_ratio > 0.7 {
|
||
return true;
|
||
}
|
||
|
||
// Проверка на зажатую клавиатуру (aaaaaaaaaaaa)
|
||
for word in input.split_whitespace() {
|
||
// Игнорируем длинные ссылки (http/https)
|
||
if word.len() > 35 && !word.starts_with("http") {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
false
|
||
}
|
||
|
||
#[tokio::main]
|
||
async fn main() {
|
||
let app = Router::new().route("/api/comments", post(create_comment));
|
||
|
||
println!("🚀 Netrunner Gateway started on 0.0.0.0:3001");
|
||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3001").await.unwrap();
|
||
axum::serve(listener, app).await.unwrap();
|
||
}
|
||
|
||
async fn create_comment(Json(payload): Json<CommentPayload>) -> impl IntoResponse {
|
||
// --- 1. Проверка на спам ---
|
||
if is_spam(&payload.message) || is_spam(&payload.nickname) {
|
||
println!("🗑️ Blocked by SPAM filter: {}", payload.nickname);
|
||
return (
|
||
StatusCode::NOT_ACCEPTABLE,
|
||
"Transmission rejected: Spam pattern detected.",
|
||
);
|
||
}
|
||
|
||
// --- 2. Английский фильтр (Crate rustrict) ---
|
||
if payload.message.is_inappropriate() || payload.nickname.is_inappropriate() {
|
||
println!("🛡️ Blocked by rustrict: {}", payload.nickname);
|
||
return (
|
||
StatusCode::NOT_ACCEPTABLE,
|
||
"Transmission rejected: Policy violation.",
|
||
);
|
||
}
|
||
|
||
// --- 3. Русский хардкорный фильтр с защитой от ложных срабатываний ---
|
||
let normalized_msg = normalize_for_filter(&payload.message);
|
||
let normalized_nick = normalize_for_filter(&payload.nickname);
|
||
|
||
// Сначала защищаем нормальные слова, затем проверяем регуляркой
|
||
let safe_msg = apply_allowlist(&normalized_msg);
|
||
let safe_nick = apply_allowlist(&normalized_nick);
|
||
|
||
if BAD_WORDS_REGEX.is_match(&safe_msg) || BAD_WORDS_REGEX.is_match(&safe_nick) {
|
||
println!(
|
||
"🛡️ Blocked by Deep Regex: [Original: {}] -> [Normalized: {}]",
|
||
payload.message, safe_msg
|
||
);
|
||
return (
|
||
StatusCode::NOT_ACCEPTABLE,
|
||
"Transmission rejected: Policy violation.",
|
||
);
|
||
}
|
||
|
||
// --- 4. ОТПРАВКА В POCKETBASE ---
|
||
let pb_base_url =
|
||
std::env::var("PB_INTERNAL_URL").unwrap_or_else(|_| "http://localhost:8090".to_string());
|
||
let pb_url = format!("{}/api/collections/comments/records", pb_base_url);
|
||
|
||
let client = Client::new();
|
||
let pb_response = client.post(pb_url).json(&payload).send().await;
|
||
|
||
match pb_response {
|
||
Ok(res) if res.status().is_success() => {
|
||
(StatusCode::OK, "Transmission secured and delivered.")
|
||
}
|
||
Ok(res) => {
|
||
println!("⚠️ PocketBase rejected: {:?}", res.status());
|
||
(StatusCode::BAD_REQUEST, "Database rejected transmission.")
|
||
}
|
||
Err(e) => {
|
||
println!("💥 PB Connection error: {}", e);
|
||
(StatusCode::INTERNAL_SERVER_ERROR, "Uplink offline.")
|
||
}
|
||
}
|
||
}
|