From 602de267b1f5d9acce33158a96996716372f3275 Mon Sep 17 00:00:00 2001 From: Joshua Barretto Date: Sun, 7 Aug 2022 19:50:51 +0100 Subject: [PATCH] Perform validation on all kinds of chat message --- common/src/comp/chat.rs | 44 +++-- server/src/automod.rs | 35 ++++ server/src/state_ext.rs | 355 +++++++++++++++++++--------------- server/src/sys/msg/general.rs | 82 ++------ 4 files changed, 277 insertions(+), 239 deletions(-) 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 ChatType { message: msg.into(), } } + + pub fn uid(&self) -> Option { + 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 GenericChatMsg { } } - pub fn uid(&self) -> Option { - 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 { self.chat_type.uid() } } /// Player factions are used to coordinate pvp vs hostile factions or segment diff --git a/server/src/automod.rs b/server/src/automod.rs index 27780e8a8f..c9bc3b16e8 100644 --- a/server/src/automod.rs +++ b/server/src/automod.rs @@ -4,6 +4,7 @@ use censor::Censor; use common::comp::AdminRole; use hashbrown::HashMap; use std::{ + fmt, sync::Arc, time::{Duration, Instant}, }; @@ -15,12 +16,46 @@ 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, 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::(); + let Some(client) = self.ecs().read_storage::().get(entity) else { return true }; + let Some(player) = self.ecs().read_storage::().get(entity) else { return true }; + + match automod.validate_chat_msg( + player.uuid(), + self.ecs() + .read_storage::() + .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::(), &ecs.read_storage::()).join() - { - if uid != u { - client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone())); + if msg.chat_type.uid().map_or(true, |sender| { + (*ecs.read_resource::()) + .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::(), &ecs.read_storage::()).join() + { + if uid != u { + client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone())); + } } - } - }, - comp::ChatType::Tell(u, t) => { - for (client, uid) in - (&ecs.read_storage::(), &ecs.read_storage::()).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::(); - 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::() - .max_player_for_kill_broadcast - .unwrap_or_default() - { - // Send kill message to the dead player's group - let killed_entity = - (*ecs.read_resource::()).retrieve_entity_internal(uid.0); - let groups = ecs.read_storage::(); - 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::(), &ecs.read_storage::()).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::(); + 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::() + .max_player_for_kill_broadcast + .unwrap_or_default() + { + // Send kill message to the dead player's group + let killed_entity = + (*ecs.read_resource::()).retrieve_entity_internal(uid.0); + let groups = ecs.read_storage::(); + 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::(); - 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::(); + 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::()).retrieve_entity_internal(uid.0); + + let positions = ecs.read_storage::(); + if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) { + for (client, pos) in (&ecs.read_storage::(), &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::()).retrieve_entity_internal(uid.0); - let positions = ecs.read_storage::(); - if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) { - for (client, pos) in (&ecs.read_storage::(), &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::()).retrieve_entity_internal(uid.0); - let positions = ecs.read_storage::(); - if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) { - for (client, pos) in (&ecs.read_storage::(), &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::()).retrieve_entity_internal(uid.0); - let positions = ecs.read_storage::(); - if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) { - for (client, pos) in (&ecs.read_storage::(), &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::()).retrieve_entity_internal(uid.0); - let positions = ecs.read_storage::(); - if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) { - for (client, pos) in (&ecs.read_storage::(), &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::(), &ecs.read_storage::()).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::(), - &ecs.read_storage::(), - ) - .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::()).retrieve_entity_internal(uid.0); - if let Some((client, _)) = - (&ecs.read_storage::(), &ecs.read_storage::()) - .join() - .find(|(_, uid)| *uid == from) - { - client.send_fallible(ServerGeneral::ChatMsg(reply)); + let positions = ecs.read_storage::(); + if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) { + for (client, pos) in (&ecs.read_storage::(), &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::()).retrieve_entity_internal(uid.0); + + let positions = ecs.read_storage::(); + if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) { + for (client, pos) in (&ecs.read_storage::(), &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::()).retrieve_entity_internal(uid.0); + + let positions = ecs.read_storage::(); + if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) { + for (client, pos) in (&ecs.read_storage::(), &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::(), &ecs.read_storage::()).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::(), + &ecs.read_storage::(), + ) + .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::(), &ecs.read_storage::()) + .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/general.rs b/server/src/sys/msg/general.rs index 27a207ffb8..0588308b96 100644 --- a/server/src/sys/msg/general.rs +++ b/server/src/sys/msg/general.rs @@ -1,79 +1,35 @@ -use crate::{ - automod::{self, AutoMod}, - client::Client, -}; +use crate::client::Client; use common::{ - comp::{Admin, AdminRole, ChatMode, ChatType, Player}, + comp::{ChatMode, Player}, event::{EventBus, ServerEvent}, resources::Time, uid::Uid, }; use common_ecs::{Job, Origin, Phase, System}; -use common_net::msg::{ClientGeneral, ServerGeneral}; -use specs::{Entities, Join, Read, ReadStorage, WriteExpect}; -use std::time::Instant; +use common_net::msg::ClientGeneral; +use specs::{Entities, Join, Read, ReadStorage}; 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 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); - // Send chat message - server_emitter - .emit(ServerEvent::Chat(mode.new_message(*from, message))); - } 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(automod::ActionErr::TooLong) => { - let len = message.len(); - 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 - ), - )); - }, + if player.is_some() { + 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"); @@ -115,8 +71,6 @@ 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"; @@ -125,26 +79,20 @@ impl<'a> System<'a> for Sys { fn run( _job: &mut Job, - (entities, server_event_bus, time, uids, chat_modes, players, clients, admins, mut automod): Self::SystemData, + (entities, server_event_bus, time, uids, chat_modes, players, clients): Self::SystemData, ) { let mut server_emitter = server_event_bus.emitter(); - let now = Instant::now(); - for (entity, client, player, admin) in - (&entities, &clients, players.maybe(), admins.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, entity, client, player, - admin.map(|a| a.0), &uids, &chat_modes, msg, - now, - &mut automod, ) });