diff --git a/Cargo.lock b/Cargo.lock index c5b7100f38..db53b9c45f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -553,6 +553,15 @@ dependencies = [ "jobserver", ] +[[package]] +name = "censor" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5563d2728feef9a6186acdd148bccbe850dad63c5ba55a3b3355abc9137cb3eb" +dependencies = [ + "once_cell", +] + [[package]] name = "cesu8" version = "1.1.0" @@ -6711,6 +6720,7 @@ dependencies = [ "atomicwrites", "authc", "bincode", + "censor", "chrono", "chrono-tz", "crossbeam-channel", diff --git a/client/src/lib.rs b/client/src/lib.rs index 9a583bcb05..d79b949c79 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -54,12 +54,11 @@ use common::{ use common_base::{prof_span, span}; use common_net::{ msg::{ - self, validate_chat_msg, + self, world_msg::{EconomyInfo, PoiInfo, SiteId, SiteInfo}, - ChatMsgValidationError, ClientGeneral, ClientMsg, ClientRegister, ClientType, - DisconnectReason, InviteAnswer, Notification, PingMsg, PlayerInfo, PlayerListUpdate, - PresenceKind, RegisterError, ServerGeneral, ServerInit, ServerRegisterAnswer, - MAX_BYTES_CHAT_MSG, + ClientGeneral, ClientMsg, ClientRegister, ClientType, DisconnectReason, InviteAnswer, + Notification, PingMsg, PlayerInfo, PlayerListUpdate, PresenceKind, RegisterError, + ServerGeneral, ServerInit, ServerRegisterAnswer, }, sync::WorldSyncExt, }; @@ -1551,15 +1550,7 @@ impl Client { pub fn inventories(&self) -> ReadStorage { self.state.read_storage() } /// Send a chat message to the server. - pub fn send_chat(&mut self, message: String) { - match validate_chat_msg(&message) { - Ok(()) => self.send_msg(ClientGeneral::ChatMsg(message)), - Err(ChatMsgValidationError::TooLong) => warn!( - "Attempted to send a message that's too long (Over {} bytes)", - MAX_BYTES_CHAT_MSG - ), - } - } + pub fn send_chat(&mut self, message: String) { self.send_msg(ClientGeneral::ChatMsg(message)); } /// Send a command to the server. pub fn send_command(&mut self, name: String, args: Vec) { diff --git a/common/net/src/msg/mod.rs b/common/net/src/msg/mod.rs index 087f94452a..c2162a356e 100644 --- a/common/net/src/msg/mod.rs +++ b/common/net/src/msg/mod.rs @@ -41,18 +41,3 @@ pub enum PingMsg { Ping, Pong, } - -pub const MAX_BYTES_CHAT_MSG: usize = 256; - -pub enum ChatMsgValidationError { - TooLong, -} - -pub fn validate_chat_msg(msg: &str) -> Result<(), ChatMsgValidationError> { - // TODO: Consider using grapheme cluster count instead of size in bytes - if msg.len() <= MAX_BYTES_CHAT_MSG { - Ok(()) - } else { - Err(ChatMsgValidationError::TooLong) - } -} diff --git a/server/Cargo.toml b/server/Cargo.toml index 8b7df44dfa..1f4c91b95d 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -58,6 +58,7 @@ slab = "0.4" rand_distr = "0.4.0" enumset = "1.0.8" noise = { version = "0.7", default-features = false } +censor = "0.2" rusqlite = { version = "0.24.2", features = ["array", "vtab", "bundled", "trace"] } refinery = { git = "https://gitlab.com/veloren/refinery.git", rev = "8ecf4b4772d791e6c8c0a3f9b66a7530fad1af3e", features = ["rusqlite"] } diff --git a/server/src/automod.rs b/server/src/automod.rs new file mode 100644 index 0000000000..822dc7f87d --- /dev/null +++ b/server/src/automod.rs @@ -0,0 +1,132 @@ +use crate::settings::ModerationSettings; +use authc::Uuid; +use censor::Censor; +use common::comp::AdminRole; +use hashbrown::HashMap; +use std::time::{Duration, Instant}; +use tracing::info; + +pub const MAX_BYTES_CHAT_MSG: usize = 256; + +pub enum ActionNote { + SpamWarn, +} + +pub enum ActionErr { + BannedWord, + TooLong, + SpamMuted(Duration), +} + +pub struct AutoMod { + settings: ModerationSettings, + censor: Censor, + players: HashMap, +} + +impl AutoMod { + pub fn new(settings: &ModerationSettings, banned_words: Vec) -> Self { + if settings.automod { + info!( + "Automod enabled, players{} will be subject to automated spam/content filters", + if settings.admins_exempt { + "" + } else { + " (and admins)" + } + ); + } else { + info!("Automod disabled"); + } + + Self { + settings: settings.clone(), + censor: Censor::Custom(banned_words.into_iter().collect()), + players: HashMap::default(), + } + } + + pub fn enabled(&self) -> bool { self.settings.automod } + + fn player_mut(&mut self, player: Uuid) -> &mut PlayerState { + self.players.entry(player).or_default() + } + + pub fn validate_chat_msg( + &mut self, + player: Uuid, + role: Option, + now: Instant, + msg: &str, + ) -> Result, ActionErr> { + // TODO: Consider using grapheme cluster count instead of size in bytes + if msg.len() > MAX_BYTES_CHAT_MSG { + Err(ActionErr::TooLong) + } else if !self.settings.automod || (role.is_some() && self.settings.admins_exempt) { + Ok(None) + } else if self.censor.check(msg) { + Err(ActionErr::BannedWord) + } else { + let volume = self.player_mut(player).enforce_message_volume(now); + + if let Some(until) = self.player_mut(player).muted_until { + Err(ActionErr::SpamMuted(until.saturating_duration_since(now))) + } else if volume > 0.75 { + Ok(Some(ActionNote::SpamWarn)) + } else { + Ok(None) + } + } + } +} + +/// The period, in seconds, over which chat volume should be tracked to detect +/// spam. +const CHAT_VOLUME_PERIOD: f32 = 30.0; +/// The maximum permitted average number of chat messages over the chat volume +/// period. +const MAX_AVG_MSG_PER_SECOND: f32 = 1.0 / 7.0; // No more than a message every 7 seconds on average +/// The period for which a player should be muted when they exceed the message +/// spam threshold. +const SPAM_MUTE_PERIOD: Duration = Duration::from_secs(180); + +#[derive(Default)] +pub struct PlayerState { + last_msg_time: Option, + /// The average number of messages per second over the last N seconds. + chat_volume: f32, + muted_until: Option, +} + +impl PlayerState { + // 0.0 => message is permitted, nothing unusual + // >=1.0 => message is not permitted, chat volume exceeded + pub fn enforce_message_volume(&mut self, now: Instant) -> f32 { + if self.muted_until.map_or(false, |u| u <= now) { + self.muted_until = None; + } + + if let Some(time_since_last) = self + .last_msg_time + .map(|last| now.saturating_duration_since(last).as_secs_f32()) + { + let time_proportion = (time_since_last / CHAT_VOLUME_PERIOD).min(1.0); + self.chat_volume = self.chat_volume * (1.0 - time_proportion) + + (1.0 / time_since_last) * time_proportion; + } else { + self.chat_volume = 0.0; + } + self.last_msg_time = Some(now); + + let min_level = 1.0 / CHAT_VOLUME_PERIOD; + let max_level = MAX_AVG_MSG_PER_SECOND; + + let volume = ((self.chat_volume - min_level) / (max_level - min_level)).max(0.0); + + if volume > 1.0 && self.muted_until.is_none() { + self.muted_until = now.checked_add(SPAM_MUTE_PERIOD); + } + + volume + } +} diff --git a/server/src/lib.rs b/server/src/lib.rs index 4aa7c134c0..5896c68fd7 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -15,6 +15,7 @@ #![cfg_attr(not(feature = "worldgen"), feature(const_panic))] pub mod alias_validator; +pub mod automod; mod character_creator; pub mod chunk_generator; mod chunk_serialize; @@ -55,6 +56,7 @@ pub use crate::{ use crate::terrain_persistence::TerrainPersistence; use crate::{ alias_validator::AliasValidator, + automod::AutoMod, chunk_generator::ChunkGenerator, client::Client, cmd::ChatCommandExt, @@ -339,7 +341,7 @@ impl Server { state.ecs_mut().register::(); //Alias validator - let banned_words_paths = &settings.banned_words_files; + let banned_words_paths = &settings.moderation.banned_words_files; let mut banned_words = Vec::new(); for path in banned_words_paths { let mut list = match std::fs::File::open(&path) { @@ -367,7 +369,14 @@ impl Server { let banned_words_count = banned_words.len(); debug!(?banned_words_count); trace!(?banned_words); - state.ecs_mut().insert(AliasValidator::new(banned_words)); + state + .ecs_mut() + .insert(AliasValidator::new(banned_words.clone())); + + // Init automod + state + .ecs_mut() + .insert(AutoMod::new(&settings.moderation, banned_words)); #[cfg(feature = "worldgen")] let (world, index) = World::generate( diff --git a/server/src/settings.rs b/server/src/settings.rs index ecabef5284..44d503713d 100644 --- a/server/src/settings.rs +++ b/server/src/settings.rs @@ -95,6 +95,26 @@ impl Default for GameplaySettings { } } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ModerationSettings { + #[serde(default)] + pub banned_words_files: Vec, + #[serde(default)] + pub automod: bool, + #[serde(default)] + pub admins_exempt: bool, +} + +impl Default for ModerationSettings { + fn default() -> Self { + Self { + banned_words_files: Vec::new(), + automod: false, + admins_exempt: true, + } + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub enum CalendarMode { None, @@ -132,7 +152,6 @@ pub struct Settings { /// uses the value of the file options to decide how to proceed. pub map_file: Option, pub max_view_distance: Option, - pub banned_words_files: Vec, pub max_player_group_size: u32, pub client_timeout: Duration, pub spawn_town: Option, @@ -146,6 +165,8 @@ pub struct Settings { #[serde(default)] pub gameplay: GameplaySettings, + #[serde(default)] + pub moderation: ModerationSettings, } impl Default for Settings { @@ -167,7 +188,6 @@ impl Default for Settings { start_time: 9.0 * 3600.0, map_file: None, max_view_distance: Some(65), - banned_words_files: Vec::new(), max_player_group_size: 6, calendar_mode: CalendarMode::Auto, client_timeout: Duration::from_secs(40), @@ -175,6 +195,7 @@ impl Default for Settings { max_player_for_kill_broadcast: None, experimental_terrain_persistence: false, gameplay: GameplaySettings::default(), + moderation: ModerationSettings::default(), } } } diff --git a/server/src/sys/msg/character_screen.rs b/server/src/sys/msg/character_screen.rs index 7190a5a91a..389bbbdf9a 100644 --- a/server/src/sys/msg/character_screen.rs +++ b/server/src/sys/msg/character_screen.rs @@ -1,5 +1,6 @@ use crate::{ alias_validator::AliasValidator, + automod::AutoMod, character_creator, client::Client, persistence::{character_loader::CharacterLoader, character_updater::CharacterUpdater}, @@ -30,6 +31,7 @@ impl Sys { presences: &ReadStorage<'_, Presence>, editable_settings: &ReadExpect<'_, EditableSettings>, alias_validator: &ReadExpect<'_, AliasValidator>, + automod: &AutoMod, msg: ClientGeneral, ) -> Result<(), crate::error::Error> { let mut send_join_messages = || -> Result<(), crate::error::Error> { @@ -41,6 +43,14 @@ impl Sys { ))?; } + // Warn them about automod + if automod.enabled() { + client.send(ServerGeneral::server_msg( + ChatType::CommandInfo, + "Automatic moderation is enabled: play nice and have fun!", + ))?; + } + if !client.login_msg_sent.load(Ordering::Relaxed) { if let Some(player_uid) = uids.get(entity) { server_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg { @@ -211,6 +221,7 @@ impl<'a> System<'a> for Sys { ReadStorage<'a, Presence>, ReadExpect<'a, EditableSettings>, ReadExpect<'a, AliasValidator>, + ReadExpect<'a, AutoMod>, ); const NAME: &'static str = "msg::character_screen"; @@ -231,6 +242,7 @@ impl<'a> System<'a> for Sys { presences, editable_settings, alias_validator, + automod, ): Self::SystemData, ) { let mut server_emitter = server_event_bus.emitter(); @@ -249,6 +261,7 @@ impl<'a> System<'a> for Sys { &presences, &editable_settings, &alias_validator, + &automod, msg, ) }); diff --git a/server/src/sys/msg/general.rs b/server/src/sys/msg/general.rs index 0527da7dcf..27a207ffb8 100644 --- a/server/src/sys/msg/general.rs +++ b/server/src/sys/msg/general.rs @@ -1,32 +1,37 @@ -use crate::client::Client; +use crate::{ + automod::{self, AutoMod}, + client::Client, +}; use common::{ - comp::{ChatMode, Player}, + comp::{Admin, AdminRole, ChatMode, ChatType, Player}, event::{EventBus, ServerEvent}, resources::Time, uid::Uid, }; use common_ecs::{Job, Origin, Phase, System}; -use common_net::msg::{ - validate_chat_msg, ChatMsgValidationError, ClientGeneral, MAX_BYTES_CHAT_MSG, -}; -use specs::{Entities, Join, Read, ReadStorage}; +use common_net::msg::{ClientGeneral, ServerGeneral}; +use specs::{Entities, Join, Read, ReadStorage, WriteExpect}; +use std::time::Instant; use tracing::{debug, error, warn}; impl Sys { fn handle_general_msg( server_emitter: &mut common::event::Emitter<'_, ServerEvent>, entity: specs::Entity, - _client: &Client, + client: &Client, player: Option<&Player>, + admin_role: Option, uids: &ReadStorage<'_, Uid>, chat_modes: &ReadStorage<'_, ChatMode>, msg: ClientGeneral, + now: Instant, + automod: &mut AutoMod, ) -> Result<(), crate::error::Error> { match msg { ClientGeneral::ChatMsg(message) => { - if player.is_some() { - match validate_chat_msg(&message) { - Ok(()) => { + if let Some(player) = player { + match automod.validate_chat_msg(player.uuid(), admin_role, now, &message) { + Ok(note) => { if let Some(from) = uids.get(entity) { const CHAT_MODE_DEFAULT: &ChatMode = &ChatMode::default(); let mode = chat_modes.get(entity).unwrap_or(CHAT_MODE_DEFAULT); @@ -36,13 +41,42 @@ impl Sys { } else { error!("Could not send message. Missing player uid"); } + + match note { + None => {}, + Some(automod::ActionNote::SpamWarn) => { + let _ = client.send(ServerGeneral::server_msg( + ChatType::CommandError, + "You've sent a lot of messages recently. Make sure to \ + reduce the rate of messages or you will be automatically \ + muted.", + )); + }, + } }, - Err(ChatMsgValidationError::TooLong) => { - let max = MAX_BYTES_CHAT_MSG; + Err(automod::ActionErr::TooLong) => { let len = message.len(); - warn!(?len, ?max, "Received a chat message that's too long") + warn!(?len, "Received a chat message that's too long"); + }, + Err(automod::ActionErr::BannedWord) => { + let _ = client.send(ServerGeneral::server_msg( + ChatType::CommandError, + "Your message contained a banned word. If you think this is a \ + false positive, please open a bug report.", + )); + }, + Err(automod::ActionErr::SpamMuted(dur)) => { + let _ = client.send(ServerGeneral::server_msg( + ChatType::CommandError, + format!( + "You have sent too many messages and are muted for {} seconds.", + dur.as_secs_f32() as u64 + ), + )); }, } + } else { + warn!("Received a chat message from an unregistered client"); } }, ClientGeneral::Command(name, args) => { @@ -81,6 +115,8 @@ impl<'a> System<'a> for Sys { ReadStorage<'a, ChatMode>, ReadStorage<'a, Player>, ReadStorage<'a, Client>, + ReadStorage<'a, Admin>, + WriteExpect<'a, AutoMod>, ); const NAME: &'static str = "msg::general"; @@ -89,20 +125,26 @@ impl<'a> System<'a> for Sys { fn run( _job: &mut Job, - (entities, server_event_bus, time, uids, chat_modes, players, clients): Self::SystemData, + (entities, server_event_bus, time, uids, chat_modes, players, clients, admins, mut automod): Self::SystemData, ) { let mut server_emitter = server_event_bus.emitter(); - for (entity, client, player) in (&entities, &clients, (&players).maybe()).join() { + let now = Instant::now(); + for (entity, client, player, admin) in + (&entities, &clients, players.maybe(), admins.maybe()).join() + { let res = super::try_recv_all(client, 3, |client, msg| { Self::handle_general_msg( &mut server_emitter, entity, client, player, + admin.map(|a| a.0), &uids, &chat_modes, msg, + now, + &mut automod, ) }); diff --git a/voxygen/src/hud/chat.rs b/voxygen/src/hud/chat.rs index adc2541cf8..dbdffb21cb 100644 --- a/voxygen/src/hud/chat.rs +++ b/voxygen/src/hud/chat.rs @@ -9,7 +9,6 @@ use common::comp::{ group::Role, BuffKind, ChatMode, ChatMsg, ChatType, }; -use common_net::msg::validate_chat_msg; use conrod_core::{ color, input::Key, @@ -120,9 +119,7 @@ impl<'a> Chat<'a> { } pub fn input(mut self, input: String) -> Self { - if let Ok(()) = validate_chat_msg(&input) { - self.force_input = Some(input); - } + self.force_input = Some(input); self } @@ -388,9 +385,7 @@ impl<'a> Widget for Chat<'a> { .set(state.ids.chat_input, ui) { input.retain(|c| c != '\n'); - if let Ok(()) = validate_chat_msg(&input) { - state.update(|s| s.input.message = input); - } + state.update(|s| s.input.message = input); } }