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<comp::Inventory> { 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<String>) { 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/common/src/comp/chat.rs b/common/src/comp/chat.rs index b31857638b..791e47580c 100644 --- a/common/src/comp/chat.rs +++ b/common/src/comp/chat.rs @@ -124,6 +124,28 @@ impl<G> ChatType<G> { message: msg.into(), } } + + pub fn uid(&self) -> Option<Uid> { + match self { + ChatType::Online(_) => None, + ChatType::Offline(_) => None, + ChatType::CommandInfo => None, + ChatType::CommandError => None, + ChatType::FactionMeta(_) => None, + ChatType::GroupMeta(_) => None, + ChatType::Kill(_, _) => None, + ChatType::Tell(u, _t) => Some(*u), + ChatType::Say(u) => Some(*u), + ChatType::Group(u, _s) => Some(*u), + ChatType::Faction(u, _s) => Some(*u), + ChatType::Region(u) => Some(*u), + ChatType::World(u) => Some(*u), + ChatType::Npc(u, _r) => Some(*u), + ChatType::NpcSay(u, _r) => Some(*u), + ChatType::NpcTell(u, _t, _r) => Some(*u), + ChatType::Meta => None, + } + } } // Stores chat text, type @@ -226,27 +248,7 @@ impl<G> GenericChatMsg<G> { } } - pub fn uid(&self) -> Option<Uid> { - match &self.chat_type { - ChatType::Online(_) => None, - ChatType::Offline(_) => None, - ChatType::CommandInfo => None, - ChatType::CommandError => None, - ChatType::FactionMeta(_) => None, - ChatType::GroupMeta(_) => None, - ChatType::Kill(_, _) => None, - ChatType::Tell(u, _t) => Some(*u), - ChatType::Say(u) => Some(*u), - ChatType::Group(u, _s) => Some(*u), - ChatType::Faction(u, _s) => Some(*u), - ChatType::Region(u) => Some(*u), - ChatType::World(u) => Some(*u), - ChatType::Npc(u, _r) => Some(*u), - ChatType::NpcSay(u, _r) => Some(*u), - ChatType::NpcTell(u, _t, _r) => Some(*u), - ChatType::Meta => None, - } - } + pub fn uid(&self) -> Option<Uid> { self.chat_type.uid() } } /// Player factions are used to coordinate pvp vs hostile factions or segment 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/alias_validator.rs b/server/src/alias_validator.rs deleted file mode 100644 index 7eb27ea3f3..0000000000 --- a/server/src/alias_validator.rs +++ /dev/null @@ -1,139 +0,0 @@ -use common::character::MAX_NAME_LENGTH; -use std::fmt::{self, Display}; - -#[derive(Debug, Default)] -pub struct AliasValidator { - banned_substrings: Vec<String>, -} - -impl AliasValidator { - pub fn new(banned_substrings: Vec<String>) -> Self { - let banned_substrings = banned_substrings - .iter() - .map(|string| string.to_lowercase()) - .collect(); - - AliasValidator { banned_substrings } - } - - pub fn validate(&self, alias: &str) -> Result<(), ValidatorError> { - if alias.len() > MAX_NAME_LENGTH { - return Err(ValidatorError::TooLong(alias.to_owned(), alias.len())); - } - - let lowercase_alias = alias.to_lowercase(); - - for banned_word in self.banned_substrings.iter() { - if lowercase_alias.contains(banned_word) { - return Err(ValidatorError::Forbidden( - alias.to_owned(), - banned_word.to_owned(), - )); - } - } - Ok(()) - } -} - -#[derive(Debug, PartialEq)] -pub enum ValidatorError { - Forbidden(String, String), - TooLong(String, usize), -} - -impl Display for ValidatorError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Forbidden(name, _) => write!( - formatter, - "Character name \"{}\" contains a banned word", - name - ), - Self::TooLong(name, _) => write!(formatter, "Character name \"{}\" too long", name), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn multiple_matches() { - let banned_substrings = vec!["bad".to_owned(), "worse".to_owned()]; - let validator = AliasValidator::new(banned_substrings); - - let bad_alias = "BadplayerMcWorseFace"; - let result = validator.validate(bad_alias); - - assert_eq!( - result, - Err(ValidatorError::Forbidden( - bad_alias.to_owned(), - "bad".to_owned() - )) - ); - } - - #[test] - fn single_lowercase_match() { - let banned_substrings = vec!["blue".to_owned()]; - let validator = AliasValidator::new(banned_substrings); - - let bad_alias = "blueName"; - let result = validator.validate(bad_alias); - - assert_eq!( - result, - Err(ValidatorError::Forbidden( - bad_alias.to_owned(), - "blue".to_owned() - )) - ); - } - - #[test] - fn single_case_insensitive_match() { - let banned_substrings = vec!["GrEEn".to_owned()]; - let validator = AliasValidator::new(banned_substrings); - - let bad_alias = "gReenName"; - let result = validator.validate(bad_alias); - - assert_eq!( - result, - Err(ValidatorError::Forbidden( - bad_alias.to_owned(), - "green".to_owned() - )) - ); - } - - #[test] - fn mp_matches() { - let banned_substrings = vec!["orange".to_owned()]; - let validator = AliasValidator::new(banned_substrings); - - let good_alias = "ReasonableName"; - let result = validator.validate(good_alias); - - assert_eq!(result, Ok(())); - } - - #[test] - fn too_long() { - let banned_substrings = vec!["orange".to_owned()]; - let validator = AliasValidator::new(banned_substrings); - - let bad_alias = "Thisnameistoolong Muchtoolong MuchTooLongByFar"; - let result = validator.validate(bad_alias); - - assert_eq!( - result, - Err(ValidatorError::TooLong( - bad_alias.to_owned(), - bad_alias.chars().count() - )) - ); - } -} diff --git a/server/src/automod.rs b/server/src/automod.rs new file mode 100644 index 0000000000..c9bc3b16e8 --- /dev/null +++ b/server/src/automod.rs @@ -0,0 +1,170 @@ +use crate::settings::ModerationSettings; +use authc::Uuid; +use censor::Censor; +use common::comp::AdminRole; +use hashbrown::HashMap; +use std::{ + fmt, + sync::Arc, + time::{Duration, Instant}, +}; +use tracing::info; + +pub const MAX_BYTES_CHAT_MSG: usize = 256; + +pub enum ActionNote { + SpamWarn, +} + +impl fmt::Display for ActionNote { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ActionNote::SpamWarn => write!( + f, + "You've sent a lot of messages recently. Make sure to reduce the rate of messages \ + or you will be automatically muted." + ), + } + } +} + +pub enum ActionErr { + BannedWord, + TooLong, + SpamMuted(Duration), +} + +impl fmt::Display for ActionErr { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ActionErr::BannedWord => write!( + f, + "Your message contained a banned word. If you think this is a mistake, please let \ + a moderator know." + ), + ActionErr::TooLong => write!( + f, + "Your message was too long, no more than {} characters are permitted.", + MAX_BYTES_CHAT_MSG + ), + ActionErr::SpamMuted(dur) => write!( + f, + "You have sent too many messages and are muted for {} seconds.", + dur.as_secs_f32() as u64 + ), + } + } +} + +pub struct AutoMod { + settings: ModerationSettings, + censor: Arc<Censor>, + players: HashMap<Uuid, PlayerState>, +} + +impl AutoMod { + pub fn new(settings: &ModerationSettings, censor: Arc<Censor>) -> 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, + 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<AdminRole>, + now: Instant, + msg: &str, + ) -> Result<Option<ActionNote>, 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<Instant>, + /// The average number of messages per second over the last N seconds. + chat_volume: f32, + muted_until: Option<Instant>, +} + +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..85c2e3a1ad 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -14,7 +14,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; @@ -54,7 +54,7 @@ pub use crate::{ #[cfg(feature = "persistent_world")] use crate::terrain_persistence::TerrainPersistence; use crate::{ - alias_validator::AliasValidator, + automod::AutoMod, chunk_generator::ChunkGenerator, client::Client, cmd::ChatCommandExt, @@ -68,6 +68,7 @@ use crate::{ state_ext::StateExt, sys::sentinel::{DeletedEntities, TrackedStorages}, }; +use censor::Censor; #[cfg(not(feature = "worldgen"))] use common::grid::Grid; use common::{ @@ -338,36 +339,15 @@ impl Server { state.ecs_mut().register::<login_provider::PendingLogin>(); state.ecs_mut().register::<RepositionOnChunkLoad>(); - //Alias validator - let banned_words_paths = &settings.banned_words_files; - let mut banned_words = Vec::new(); - for path in banned_words_paths { - let mut list = match std::fs::File::open(&path) { - Ok(file) => match ron::de::from_reader(&file) { - Ok(vec) => vec, - Err(error) => { - warn!(?error, ?file, "Couldn't deserialize banned words file"); - return Err(Error::Other(format!( - "Couldn't read banned words file \"{}\"", - path.to_string_lossy() - ))); - }, - }, - Err(error) => { - warn!(?error, ?path, "Couldn't open banned words file"); - return Err(Error::Other(format!( - "Couldn't open banned words file \"{}\". Error: {}", - path.to_string_lossy(), - error - ))); - }, - }; - banned_words.append(&mut list); - } - let banned_words_count = banned_words.len(); - debug!(?banned_words_count); - trace!(?banned_words); - state.ecs_mut().insert(AliasValidator::new(banned_words)); + // Load banned words list + let banned_words = settings.moderation.load_banned_words(data_dir); + let censor = Arc::new(Censor::Custom(banned_words.into_iter().collect())); + state.ecs_mut().insert(Arc::clone(&censor)); + + // Init automod + state + .ecs_mut() + .insert(AutoMod::new(&settings.moderation, censor)); #[cfg(feature = "worldgen")] let (world, index) = World::generate( diff --git a/server/src/settings.rs b/server/src/settings.rs index ecabef5284..896dc8df7f 100644 --- a/server/src/settings.rs +++ b/server/src/settings.rs @@ -95,6 +95,44 @@ impl Default for GameplaySettings { } } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ModerationSettings { + #[serde(default)] + pub banned_words_files: Vec<PathBuf>, + #[serde(default)] + pub automod: bool, + #[serde(default)] + pub admins_exempt: bool, +} + +impl ModerationSettings { + pub fn load_banned_words(&self, data_dir: &Path) -> Vec<String> { + let mut banned_words = Vec::new(); + for fname in self.banned_words_files.iter() { + let mut path = with_config_dir(data_dir); + path.push(fname); + match std::fs::File::open(&path) { + Ok(file) => match ron::de::from_reader(&file) { + Ok(mut words) => banned_words.append(&mut words), + Err(error) => error!(?error, ?file, "Couldn't read banned words file"), + }, + Err(error) => error!(?error, ?path, "Couldn't open banned words file"), + } + } + banned_words + } +} + +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 +170,6 @@ pub struct Settings { /// uses the value of the file options to decide how to proceed. pub map_file: Option<FileOpts>, pub max_view_distance: Option<u32>, - pub banned_words_files: Vec<PathBuf>, pub max_player_group_size: u32, pub client_timeout: Duration, pub spawn_town: Option<String>, @@ -146,6 +183,8 @@ pub struct Settings { #[serde(default)] pub gameplay: GameplaySettings, + #[serde(default)] + pub moderation: ModerationSettings, } impl Default for Settings { @@ -167,7 +206,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 +213,7 @@ impl Default for Settings { max_player_for_kill_broadcast: None, experimental_terrain_persistence: false, gameplay: GameplaySettings::default(), + moderation: ModerationSettings::default(), } } } @@ -265,7 +304,7 @@ impl Settings { } } -fn with_config_dir(path: &Path) -> PathBuf { +pub fn with_config_dir(path: &Path) -> PathBuf { let mut path = PathBuf::from(path); path.push(CONFIG_DIR); path diff --git a/server/src/state_ext.rs b/server/src/state_ext.rs index 42bc324286..237f891849 100644 --- a/server/src/state_ext.rs +++ b/server/src/state_ext.rs @@ -1,4 +1,5 @@ use crate::{ + automod::AutoMod, client::Client, events::update_map_markers, persistence::PersistedComponents, @@ -17,7 +18,7 @@ use common::{ self, item::MaterialStatManifest, skills::{GeneralSkill, Skill}, - Group, Inventory, Item, Poise, + ChatType, Group, Inventory, Item, Player, Poise, }, effect::Effect, link::{Link, LinkHandle}, @@ -36,7 +37,7 @@ use specs::{ saveload::MarkerAllocator, Builder, Entity as EcsEntity, EntityBuilder as EcsEntityBuilder, Join, WorldExt, }; -use std::time::Duration; +use std::time::{Duration, Instant}; use tracing::{trace, warn}; use vek::*; @@ -113,6 +114,7 @@ pub trait StateExt { /// Performed after loading component data from the database fn update_character_data(&mut self, entity: EcsEntity, components: PersistedComponents); /// Iterates over registered clients and send each `ServerMsg` + fn validate_chat_msg(&self, player: EcsEntity, msg: &str) -> bool; fn send_chat(&self, msg: comp::UnresolvedChatMsg); fn notify_players(&self, msg: ServerGeneral); fn notify_in_game_clients(&self, msg: ServerGeneral); @@ -674,6 +676,39 @@ impl StateExt for State { } } + fn validate_chat_msg(&self, entity: EcsEntity, msg: &str) -> bool { + let mut automod = self.ecs().write_resource::<AutoMod>(); + let Some(client) = self.ecs().read_storage::<Client>().get(entity) else { return true }; + let Some(player) = self.ecs().read_storage::<Player>().get(entity) else { return true }; + + match automod.validate_chat_msg( + player.uuid(), + self.ecs() + .read_storage::<comp::Admin>() + .get(entity) + .map(|a| a.0), + Instant::now(), + msg, + ) { + Ok(note) => { + if let Some(note) = note { + let _ = client.send(ServerGeneral::server_msg( + ChatType::CommandInfo, + format!("{}", note), + )); + } + true + }, + Err(err) => { + let _ = client.send(ServerGeneral::server_msg( + ChatType::CommandError, + format!("{}", err), + )); + false + }, + } + } + /// Send the chat message to the proper players. Say and region are limited /// by location. Faction and group are limited by component. fn send_chat(&self, msg: comp::UnresolvedChatMsg) { @@ -689,165 +724,183 @@ impl StateExt for State { .clone() .map_group(|_| group_info.map_or_else(|| "???".to_string(), |i| i.name.clone())); - match &msg.chat_type { - comp::ChatType::Offline(_) - | comp::ChatType::CommandInfo - | comp::ChatType::CommandError - | comp::ChatType::Meta - | comp::ChatType::World(_) => self.notify_players(ServerGeneral::ChatMsg(resolved_msg)), - comp::ChatType::Online(u) => { - for (client, uid) in - (&ecs.read_storage::<Client>(), &ecs.read_storage::<Uid>()).join() - { - if uid != u { - client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone())); + if msg.chat_type.uid().map_or(true, |sender| { + (*ecs.read_resource::<UidAllocator>()) + .retrieve_entity_internal(sender.0) + .map_or(false, |e| self.validate_chat_msg(e, &msg.message)) + }) { + match &msg.chat_type { + comp::ChatType::Offline(_) + | comp::ChatType::CommandInfo + | comp::ChatType::CommandError + | comp::ChatType::Meta + | comp::ChatType::World(_) => { + self.notify_players(ServerGeneral::ChatMsg(resolved_msg)) + }, + comp::ChatType::Online(u) => { + for (client, uid) in + (&ecs.read_storage::<Client>(), &ecs.read_storage::<Uid>()).join() + { + if uid != u { + client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone())); + } } - } - }, - comp::ChatType::Tell(u, t) => { - for (client, uid) in - (&ecs.read_storage::<Client>(), &ecs.read_storage::<Uid>()).join() - { - if uid == u || uid == t { - client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone())); - } - } - }, - comp::ChatType::Kill(kill_source, uid) => { - let comp::chat::GenericChatMsg { message, .. } = msg; - let clients = ecs.read_storage::<Client>(); - let clients_count = clients.count(); - // Avoid chat spam, send kill message only to group or nearby players if a - // certain amount of clients are online - if clients_count - > ecs - .fetch::<Settings>() - .max_player_for_kill_broadcast - .unwrap_or_default() - { - // Send kill message to the dead player's group - let killed_entity = - (*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0); - let groups = ecs.read_storage::<Group>(); - let killed_group = killed_entity.and_then(|e| groups.get(e)); - if let Some(g) = &killed_group { - send_to_group(g, ecs, &resolved_msg); + }, + comp::ChatType::Tell(from, to) => { + for (client, uid) in + (&ecs.read_storage::<Client>(), &ecs.read_storage::<Uid>()).join() + { + if uid == from || uid == to { + client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone())); + } } + }, + comp::ChatType::Kill(kill_source, uid) => { + let comp::chat::GenericChatMsg { message, .. } = msg; + let clients = ecs.read_storage::<Client>(); + let clients_count = clients.count(); + // Avoid chat spam, send kill message only to group or nearby players if a + // certain amount of clients are online + if clients_count + > ecs + .fetch::<Settings>() + .max_player_for_kill_broadcast + .unwrap_or_default() + { + // Send kill message to the dead player's group + let killed_entity = + (*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0); + let groups = ecs.read_storage::<Group>(); + let killed_group = killed_entity.and_then(|e| groups.get(e)); + if let Some(g) = &killed_group { + send_to_group(g, ecs, &resolved_msg); + } - // Send kill message to nearby players that aren't part of the deceased's group - let positions = ecs.read_storage::<comp::Pos>(); - if let Some(died_player_pos) = killed_entity.and_then(|e| positions.get(e)) { - for (ent, client, pos) in (&*ecs.entities(), &clients, &positions).join() { - let client_group = groups.get(ent); - let is_different_group = - !(killed_group == client_group && client_group.is_some()); - if is_within(comp::ChatMsg::SAY_DISTANCE, pos, died_player_pos) - && is_different_group + // Send kill message to nearby players that aren't part of the deceased's + // group + let positions = ecs.read_storage::<comp::Pos>(); + if let Some(died_player_pos) = killed_entity.and_then(|e| positions.get(e)) + { + for (ent, client, pos) in + (&*ecs.entities(), &clients, &positions).join() { + let client_group = groups.get(ent); + let is_different_group = + !(killed_group == client_group && client_group.is_some()); + if is_within(comp::ChatMsg::SAY_DISTANCE, pos, died_player_pos) + && is_different_group + { + client.send_fallible(ServerGeneral::ChatMsg( + resolved_msg.clone(), + )); + } + } + } + } else { + self.notify_players(ServerGeneral::server_msg( + comp::ChatType::Kill(kill_source.clone(), *uid), + message, + )) + } + }, + comp::ChatType::Say(uid) => { + let entity_opt = + (*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0); + + let positions = ecs.read_storage::<comp::Pos>(); + if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) { + for (client, pos) in (&ecs.read_storage::<Client>(), &positions).join() { + if is_within(comp::ChatMsg::SAY_DISTANCE, pos, speaker_pos) { client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone())); } } } - } else { - self.notify_players(ServerGeneral::server_msg( - comp::ChatType::Kill(kill_source.clone(), *uid), - message, - )) - } - }, - comp::ChatType::Say(uid) => { - let entity_opt = - (*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0); - let positions = ecs.read_storage::<comp::Pos>(); - if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) { - for (client, pos) in (&ecs.read_storage::<Client>(), &positions).join() { - if is_within(comp::ChatMsg::SAY_DISTANCE, pos, speaker_pos) { - client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone())); - } - } - } - }, - comp::ChatType::Region(uid) => { - let entity_opt = - (*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0); - let positions = ecs.read_storage::<comp::Pos>(); - if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) { - for (client, pos) in (&ecs.read_storage::<Client>(), &positions).join() { - if is_within(comp::ChatMsg::REGION_DISTANCE, pos, speaker_pos) { - client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone())); - } - } - } - }, - comp::ChatType::Npc(uid, _r) => { - let entity_opt = - (*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0); - let positions = ecs.read_storage::<comp::Pos>(); - if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) { - for (client, pos) in (&ecs.read_storage::<Client>(), &positions).join() { - if is_within(comp::ChatMsg::NPC_DISTANCE, pos, speaker_pos) { - client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone())); - } - } - } - }, - comp::ChatType::NpcSay(uid, _r) => { - let entity_opt = - (*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0); - let positions = ecs.read_storage::<comp::Pos>(); - if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) { - for (client, pos) in (&ecs.read_storage::<Client>(), &positions).join() { - if is_within(comp::ChatMsg::NPC_SAY_DISTANCE, pos, speaker_pos) { - client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone())); - } - } - } - }, - comp::ChatType::NpcTell(from, to, _r) => { - for (client, uid) in - (&ecs.read_storage::<Client>(), &ecs.read_storage::<Uid>()).join() - { - if uid == from || uid == to { - client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone())); - } - } - }, - comp::ChatType::FactionMeta(s) | comp::ChatType::Faction(_, s) => { - for (client, faction) in ( - &ecs.read_storage::<Client>(), - &ecs.read_storage::<comp::Faction>(), - ) - .join() - { - if s == &faction.0 { - client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone())); - } - } - }, - comp::ChatType::Group(from, g) => { - if group_info.is_none() { - // group not found, reply with command error - let reply = comp::ChatMsg { - chat_type: comp::ChatType::CommandError, - message: "You are using group chat but do not belong to a group. Use \ - /world or /region to change chat." - .into(), - }; + }, + comp::ChatType::Region(uid) => { + let entity_opt = + (*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0); - if let Some((client, _)) = - (&ecs.read_storage::<Client>(), &ecs.read_storage::<Uid>()) - .join() - .find(|(_, uid)| *uid == from) - { - client.send_fallible(ServerGeneral::ChatMsg(reply)); + let positions = ecs.read_storage::<comp::Pos>(); + if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) { + for (client, pos) in (&ecs.read_storage::<Client>(), &positions).join() { + if is_within(comp::ChatMsg::REGION_DISTANCE, pos, speaker_pos) { + client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone())); + } + } } - return; - } - send_to_group(g, ecs, &resolved_msg); - }, - comp::ChatType::GroupMeta(g) => { - send_to_group(g, ecs, &resolved_msg); - }, + }, + comp::ChatType::Npc(uid, _r) => { + let entity_opt = + (*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0); + + let positions = ecs.read_storage::<comp::Pos>(); + if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) { + for (client, pos) in (&ecs.read_storage::<Client>(), &positions).join() { + if is_within(comp::ChatMsg::NPC_DISTANCE, pos, speaker_pos) { + client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone())); + } + } + } + }, + comp::ChatType::NpcSay(uid, _r) => { + let entity_opt = + (*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0); + + let positions = ecs.read_storage::<comp::Pos>(); + if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) { + for (client, pos) in (&ecs.read_storage::<Client>(), &positions).join() { + if is_within(comp::ChatMsg::NPC_SAY_DISTANCE, pos, speaker_pos) { + client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone())); + } + } + } + }, + comp::ChatType::NpcTell(from, to, _r) => { + for (client, uid) in + (&ecs.read_storage::<Client>(), &ecs.read_storage::<Uid>()).join() + { + if uid == from || uid == to { + client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone())); + } + } + }, + comp::ChatType::FactionMeta(s) | comp::ChatType::Faction(_, s) => { + for (client, faction) in ( + &ecs.read_storage::<Client>(), + &ecs.read_storage::<comp::Faction>(), + ) + .join() + { + if s == &faction.0 { + client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone())); + } + } + }, + comp::ChatType::Group(from, g) => { + if group_info.is_none() { + // group not found, reply with command error + let reply = comp::ChatMsg { + chat_type: comp::ChatType::CommandError, + message: "You are using group chat but do not belong to a group. Use \ + /world or /region to change chat." + .into(), + }; + + if let Some((client, _)) = + (&ecs.read_storage::<Client>(), &ecs.read_storage::<Uid>()) + .join() + .find(|(_, uid)| *uid == from) + { + client.send_fallible(ServerGeneral::ChatMsg(reply)); + } + return; + } + send_to_group(g, ecs, &resolved_msg); + }, + comp::ChatType::GroupMeta(g) => { + send_to_group(g, ecs, &resolved_msg); + }, + } } } diff --git a/server/src/sys/msg/character_screen.rs b/server/src/sys/msg/character_screen.rs index 7190a5a91a..d9a12f58f0 100644 --- a/server/src/sys/msg/character_screen.rs +++ b/server/src/sys/msg/character_screen.rs @@ -1,5 +1,5 @@ use crate::{ - alias_validator::AliasValidator, + automod::AutoMod, character_creator, client::Client, persistence::{character_loader::CharacterLoader, character_updater::CharacterUpdater}, @@ -14,7 +14,7 @@ use common::{ use common_ecs::{Job, Origin, Phase, System}; use common_net::msg::{ClientGeneral, ServerGeneral}; use specs::{Entities, Join, Read, ReadExpect, ReadStorage, WriteExpect}; -use std::sync::atomic::Ordering; +use std::sync::{atomic::Ordering, Arc}; use tracing::debug; impl Sys { @@ -29,7 +29,8 @@ impl Sys { admins: &ReadStorage<'_, Admin>, presences: &ReadStorage<'_, Presence>, editable_settings: &ReadExpect<'_, EditableSettings>, - alias_validator: &ReadExpect<'_, AliasValidator>, + censor: &ReadExpect<'_, Arc<censor::Censor>>, + automod: &AutoMod, msg: ClientGeneral, ) -> Result<(), crate::error::Error> { let mut send_join_messages = || -> Result<(), crate::error::Error> { @@ -41,6 +42,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 { @@ -128,9 +137,12 @@ impl Sys { offhand, body, } => { - if let Err(error) = alias_validator.validate(&alias) { - debug!(?error, ?alias, "denied alias as it contained a banned word"); - client.send(ServerGeneral::CharacterActionError(error.to_string()))?; + if censor.check(&alias) { + debug!(?alias, "denied alias as it contained a banned word"); + client.send(ServerGeneral::CharacterActionError(format!( + "Alias '{}' contains a banned word", + alias + )))?; } else if let Some(player) = players.get(entity) { if let Err(error) = character_creator::create_character( entity, @@ -153,9 +165,12 @@ impl Sys { } }, ClientGeneral::EditCharacter { id, alias, body } => { - if let Err(error) = alias_validator.validate(&alias) { - debug!(?error, ?alias, "denied alias as it contained a banned word"); - client.send(ServerGeneral::CharacterActionError(error.to_string()))?; + if censor.check(&alias) { + debug!(?alias, "denied alias as it contained a banned word"); + client.send(ServerGeneral::CharacterActionError(format!( + "Alias '{}' contains a banned word", + alias + )))?; } else if let Some(player) = players.get(entity) { if let Err(error) = character_creator::edit_character( entity, @@ -210,7 +225,8 @@ impl<'a> System<'a> for Sys { ReadStorage<'a, Admin>, ReadStorage<'a, Presence>, ReadExpect<'a, EditableSettings>, - ReadExpect<'a, AliasValidator>, + ReadExpect<'a, Arc<censor::Censor>>, + ReadExpect<'a, AutoMod>, ); const NAME: &'static str = "msg::character_screen"; @@ -230,7 +246,8 @@ impl<'a> System<'a> for Sys { admins, presences, editable_settings, - alias_validator, + censor, + automod, ): Self::SystemData, ) { let mut server_emitter = server_event_bus.emitter(); @@ -248,7 +265,8 @@ impl<'a> System<'a> for Sys { &admins, &presences, &editable_settings, - &alias_validator, + &censor, + &automod, msg, ) }); diff --git a/server/src/sys/msg/general.rs b/server/src/sys/msg/general.rs index 0527da7dcf..0588308b96 100644 --- a/server/src/sys/msg/general.rs +++ b/server/src/sys/msg/general.rs @@ -6,9 +6,7 @@ use common::{ uid::Uid, }; use common_ecs::{Job, Origin, Phase, System}; -use common_net::msg::{ - validate_chat_msg, ChatMsgValidationError, ClientGeneral, MAX_BYTES_CHAT_MSG, -}; +use common_net::msg::ClientGeneral; use specs::{Entities, Join, Read, ReadStorage}; use tracing::{debug, error, warn}; @@ -25,24 +23,16 @@ impl Sys { match msg { ClientGeneral::ChatMsg(message) => { if player.is_some() { - match validate_chat_msg(&message) { - Ok(()) => { - 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); - // Send chat message - server_emitter - .emit(ServerEvent::Chat(mode.new_message(*from, message))); - } else { - error!("Could not send message. Missing player uid"); - } - }, - Err(ChatMsgValidationError::TooLong) => { - let max = MAX_BYTES_CHAT_MSG; - let len = message.len(); - warn!(?len, ?max, "Received a chat message that's too long") - }, + 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); + // Send chat message + server_emitter.emit(ServerEvent::Chat(mode.new_message(*from, message))); + } else { + error!("Could not send message. Missing player uid"); } + } else { + warn!("Received a chat message from an unregistered client"); } }, ClientGeneral::Command(name, args) => { @@ -93,7 +83,7 @@ impl<'a> System<'a> for Sys { ) { let mut server_emitter = server_event_bus.emitter(); - for (entity, client, player) in (&entities, &clients, (&players).maybe()).join() { + for (entity, client, player) in (&entities, &clients, players.maybe()).join() { let res = super::try_recv_all(client, 3, |client, msg| { Self::handle_general_msg( &mut server_emitter, 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); } }