veza/veza-chat-server/src/moderation.rs
2025-12-03 20:33:26 +01:00

407 lines
No EOL
14 KiB
Rust

use std::time::{Duration};
use serde::{Serialize, Deserialize};
use crate::error::{ChatError, Result};
use crate::hub::common::ChatHub;
use crate::permissions::Role;
use sqlx::Row;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SanctionType {
Warning,
Mute,
Kick,
TempBan,
PermaBan,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SanctionReason {
Spam,
Harassment,
Inappropriate,
Toxicity,
RuleViolation,
Abuse,
Other(String),
}
#[derive(Debug, Clone, Serialize)]
pub struct Sanction {
pub id: i32,
pub user_id: i32,
pub moderator_id: i32,
pub sanction_type: SanctionType,
pub reason: SanctionReason,
pub message: Option<String>,
pub duration: Option<Duration>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
pub is_active: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct UserModerationRecord {
pub user_id: i32,
pub username: String,
pub sanctions: Vec<Sanction>,
pub total_warnings: u32,
pub total_mutes: u32,
pub total_bans: u32,
pub reputation_score: i32,
pub is_currently_banned: bool,
pub is_currently_muted: bool,
}
/// Système de modération automatique et manuelle
pub struct ModerationSystem {
hub: std::sync::Arc<ChatHub>,
}
impl ModerationSystem {
pub fn new(hub: std::sync::Arc<ChatHub>) -> Self {
Self {
hub,
}
}
/// Applique une sanction manuelle par un modérateur
pub async fn apply_sanction(
&self,
moderator_id: i32,
moderator_role: &Role,
target_user_id: i32,
sanction_type: SanctionType,
reason: SanctionReason,
message: Option<String>,
duration: Option<Duration>,
) -> Result<()> {
// Vérifier les permissions du modérateur
self.check_moderator_permissions(moderator_role, &sanction_type)?;
// Vérifier que l'utilisateur cible existe
if !self.user_exists(target_user_id).await? {
return Err(ChatError::configuration_error("Utilisateur cible introuvable"));
}
// Calculer l'expiration si durée spécifiée
let expires_at = duration.map(|d| {
chrono::Utc::now() + chrono::Duration::from_std(d).unwrap_or(chrono::Duration::zero())
});
// Insérer la sanction en base
let sanction_id = self.insert_sanction(
target_user_id,
moderator_id,
&sanction_type,
&reason,
message.as_deref(),
expires_at,
).await?;
// Appliquer les effets de la sanction
self.enforce_sanction(target_user_id, &sanction_type, duration).await?;
// Notifier l'utilisateur sanctionné
self.notify_user_sanctioned(target_user_id, &sanction_type, &reason, message.as_deref()).await?;
// Audit log
tracing::warn!(
sanction_id = %sanction_id,
moderator_id = %moderator_id,
target_user_id = %target_user_id,
sanction_type = ?sanction_type,
reason = ?reason,
duration = ?duration,
"⚖️ Sanction appliquée"
);
Ok(())
}
/// Vérification automatique d'un message pour détecter les violations
pub async fn check_message_auto_moderation(
&self,
user_id: i32,
content: &str,
) -> Result<Option<SanctionType>> {
// Détection de spam basique
if self.detect_spam(user_id, content).await? {
let user_record = self.get_user_moderation_record(user_id).await?;
let sanction_type = self.determine_auto_sanction(&user_record).await?;
if let Some(sanction) = &sanction_type {
self.apply_sanction(
0, // ID système
&Role::Admin,
user_id,
sanction.clone(),
SanctionReason::Spam,
Some("Sanction automatique - spam détecté".to_string()),
Some(Duration::from_secs(3600)), // 1 heure
).await?;
}
return Ok(sanction_type);
}
Ok(None)
}
/// Lève une sanction (unban, unmute, etc.)
pub async fn lift_sanction(
&self,
moderator_id: i32,
moderator_role: &Role,
target_user_id: i32,
sanction_type: SanctionType,
) -> Result<()> {
// Vérifier les permissions
self.check_moderator_permissions(moderator_role, &sanction_type)?;
// Désactiver la sanction en base
sqlx::query(
"UPDATE sanctions SET is_active = false WHERE user_id = $1 AND sanction_type = $2 AND is_active = true"
)
.bind(target_user_id)
.bind(serde_json::to_string(&sanction_type).map_err(|e| ChatError::from_json_error(e))?)
.execute(&self.hub.db)
.await
.map_err(|e| ChatError::from_sqlx_error("database_operation", e))?;
// Retirer les effets de la sanction
self.remove_sanction_effects(target_user_id, &sanction_type).await?;
tracing::info!(
moderator_id = %moderator_id,
target_user_id = %target_user_id,
sanction_type = ?sanction_type,
"✅ Sanction levée"
);
Ok(())
}
/// Obtient l'historique de modération d'un utilisateur
pub async fn get_user_moderation_record(&self, user_id: i32) -> Result<UserModerationRecord> {
let rows = sqlx::query(
r#"
SELECT s.*, u.username
FROM sanctions s
JOIN users u ON u.id = s.moderator_id
WHERE s.user_id = $1
ORDER BY s.created_at DESC
"#
)
.bind(user_id)
.fetch_all(&self.hub.db)
.await
.map_err(|e| ChatError::from_sqlx_error("database_operation", e))?;
let mut sanctions = Vec::new();
let mut warning_count = 0;
let mut mute_count = 0;
let mut ban_count = 0;
let mut is_currently_banned = false;
let mut is_currently_muted = false;
for row in rows {
let sanction_type_str: String = row.get("sanction_type");
let sanction_type: SanctionType = serde_json::from_str(&sanction_type_str)
.map_err(|e| ChatError::from_json_error(e))?;
let reason_str: String = row.get("reason");
let reason: SanctionReason = serde_json::from_str(&reason_str)
.map_err(|e| ChatError::from_json_error(e))?;
let is_active: bool = row.get("is_active");
// Compter les types de sanctions
match sanction_type {
SanctionType::Warning => warning_count += 1,
SanctionType::Mute => {
mute_count += 1;
if is_active {
is_currently_muted = true;
}
},
SanctionType::TempBan | SanctionType::PermaBan => {
ban_count += 1;
if is_active {
is_currently_banned = true;
}
},
_ => {}
}
sanctions.push(Sanction {
id: row.get("id"),
user_id: row.get("user_id"),
moderator_id: row.get("moderator_id"),
sanction_type,
reason,
message: row.get("message"),
duration: None, // Simplifié pour cet exemple
created_at: row.get("created_at"),
expires_at: row.get("expires_at"),
is_active,
});
}
// Calculer un score de réputation basique
let reputation_score = 100 - (warning_count as i32 * 5) - (mute_count as i32 * 15) - (ban_count as i32 * 50);
// Obtenir le nom d'utilisateur
let username = self.get_username(user_id).await?;
Ok(UserModerationRecord {
user_id,
username,
sanctions,
total_warnings: warning_count,
total_mutes: mute_count,
total_bans: ban_count,
reputation_score,
is_currently_banned,
is_currently_muted,
})
}
/// Détection de spam basique
async fn detect_spam(&self, user_id: i32, content: &str) -> Result<bool> {
// Vérifier les messages répétitifs
let recent_messages = sqlx::query(
"SELECT content FROM messages WHERE user_id = $1 AND created_at > NOW() - INTERVAL '5 minutes' ORDER BY created_at DESC LIMIT 5"
)
.bind(user_id)
.fetch_all(&self.hub.db)
.await
.map_err(|e| ChatError::from_sqlx_error("database_operation", e))?;
let similar_count = recent_messages.iter()
.filter(|row| {
let msg_content: String = row.get("content");
self.calculate_similarity(content, &msg_content) > 0.8
})
.count();
Ok(similar_count >= 3)
}
/// Calcule la similarité entre deux strings (simplifiée)
fn calculate_similarity(&self, a: &str, b: &str) -> f32 {
if a == b {
return 1.0;
}
let a_len = a.len() as f32;
let b_len = b.len() as f32;
let max_len = a_len.max(b_len);
if max_len == 0.0 {
return 1.0;
}
// Distance de Levenshtein simplifiée
let common_chars = a.chars().filter(|c| b.contains(*c)).count() as f32;
common_chars / max_len
}
/// Détermine la sanction automatique appropriée
async fn determine_auto_sanction(&self, user_record: &UserModerationRecord) -> Result<Option<SanctionType>> {
match user_record.total_warnings {
0 => Ok(Some(SanctionType::Warning)),
1..=2 => Ok(Some(SanctionType::Mute)),
_ => Ok(Some(SanctionType::TempBan)),
}
}
/// Implémentations helpers simplifiées
fn check_moderator_permissions(&self, role: &Role, sanction: &SanctionType) -> Result<()> {
match sanction {
SanctionType::Warning | SanctionType::Mute => {
if matches!(role, Role::Admin | Role::Moderator) {
Ok(())
} else {
Err(ChatError::unauthorized_simple("unauthorized_action"))
}
},
SanctionType::Kick | SanctionType::TempBan => {
if matches!(role, Role::Admin | Role::Moderator) {
Ok(())
} else {
Err(ChatError::unauthorized_simple("unauthorized_action"))
}
},
SanctionType::PermaBan => {
if matches!(role, Role::Admin) {
Ok(())
} else {
Err(ChatError::unauthorized_simple("unauthorized_action"))
}
},
}
}
async fn user_exists(&self, user_id: i32) -> Result<bool> {
let row = sqlx::query("SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)")
.bind(user_id)
.fetch_one(&self.hub.db)
.await
.map_err(|e| ChatError::from_sqlx_error("database_operation", e))?;
Ok(row.get(0))
}
async fn get_username(&self, user_id: i32) -> Result<String> {
let row = sqlx::query("SELECT username FROM users WHERE id = $1")
.bind(user_id)
.fetch_one(&self.hub.db)
.await
.map_err(|e| ChatError::from_sqlx_error("database_operation", e))?;
Ok(row.get(0))
}
async fn insert_sanction(
&self,
user_id: i32,
moderator_id: i32,
sanction_type: &SanctionType,
reason: &SanctionReason,
message: Option<&str>,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<i32> {
let row = sqlx::query(
"INSERT INTO sanctions (user_id, moderator_id, sanction_type, reason, message, expires_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id"
)
.bind(user_id)
.bind(moderator_id)
.bind(serde_json::to_string(sanction_type).map_err(|e| ChatError::from_json_error(e))?)
.bind(serde_json::to_string(reason).map_err(|e| ChatError::from_json_error(e))?)
.bind(message)
.bind(expires_at)
.fetch_one(&self.hub.db)
.await
.map_err(|e| ChatError::from_sqlx_error("database_operation", e))?;
Ok(row.get(0))
}
async fn enforce_sanction(&self, user_id: i32, sanction_type: &SanctionType, _duration: Option<Duration>) -> Result<()> {
// Ici on appliquerait les effets réels (déconnecter, bloquer messages, etc.)
tracing::info!(user_id = %user_id, sanction_type = ?sanction_type, "⚖️ Sanction appliquée");
Ok(())
}
async fn remove_sanction_effects(&self, user_id: i32, sanction_type: &SanctionType) -> Result<()> {
// Ici on retirerait les effets (débloquer, etc.)
tracing::info!(user_id = %user_id, sanction_type = ?sanction_type, "✅ Effets de sanction retirés");
Ok(())
}
async fn notify_user_sanctioned(&self, user_id: i32, sanction_type: &SanctionType, _reason: &SanctionReason, _message: Option<&str>) -> Result<()> {
// Ici on notifierait l'utilisateur
tracing::info!(user_id = %user_id, sanction_type = ?sanction_type, "📢 Utilisateur notifié de la sanction");
Ok(())
}
}