//! `ChatModeration`: deletes, timeouts, bans and purges, on the existing stack. //! //! Nothing here is a new moderation system. Bans and timeouts are rows in //! `community_bans` (a timeout is a `mute` with an `expires_at`, which is what //! that table already models), and every action lands in `mod_log` through the //! same `insert_mod_log` the forum uses. A chat ban and a forum ban are the //! same ban, so a user banned from chat is banned, full stop. //! //! # Entitlement is checked here, not by the caller //! //! The crate's trait says implementations must verify the actor is entitled to //! the message rather than trusting the caller, and that is not delegation for //! its own sake: the check needs the row, and only the host can read it. A //! self-delete has to compare the actor against the message's author, and doing //! that above this layer would mean passing an "is this really the author" //! boolean down a call chain, which is the shape that rots. //! //! # Every statement names the room //! //! Chat message ids are BIGINT and guessable, so a lookup by id alone would let //! one community's moderator reach another's messages. Every query and mutation //! in `mt_db::…::chat` takes `community_id` in its `WHERE`, so a message in a //! different room is not "found then rejected", it is never read. That is the //! same C1 invariant `CommunityScope` enforces for UUID-keyed resources, //! reached by scoping the query rather than by unwrapping a loaded row. use std::time::Duration; use async_trait::async_trait; use livechat::{ChatError, ChatModeration, MessageId, Room, RoomId, UserId}; use mt_core::types::{BanType, ModAction, ModActor}; use sqlx::PgPool; use super::host_error; pub struct MtChatModeration { db: PgPool, /// Whether the actor holds moderator powers in this room, decided once by /// the route from `ChatAuthz::is_moderator`. /// /// Carried rather than re-queried because the two entitlements differ: /// a moderator may remove anyone's message, an author only their own. A /// gate built for a member simply cannot express the moderator actions. actor_is_moderator: bool, /// Whether the actor owns the community, decided by the route through /// `require_owner`. /// /// A separate flag rather than a rank, because only one action needs it and /// the distinction is real: moderators moderate people, and emptying the /// room is not moderation of anyone. Collapsing the two into "at least /// moderator" would hand every moderator a delete-everything button. actor_is_owner: bool, } impl MtChatModeration { pub fn new(db: PgPool, actor_is_moderator: bool) -> Self { Self { db, actor_is_moderator, actor_is_owner: false, } } /// The gate for an owner-only action, built where `require_owner` already ran. /// /// An owner holds moderator powers too, so this is not a narrower gate than /// [`MtChatModeration::new`] produces; it is the same one plus the room-wide /// wipe. pub fn for_owner(db: PgPool) -> Self { Self { db, actor_is_moderator: true, actor_is_owner: true, } } /// Refuse anything but a moderator. fn require_moderator(&self) -> Result<(), ChatError> { if self.actor_is_moderator { return Ok(()); } Err(ChatError::host(std::io::Error::other( "chat moderation attempted without moderator powers", ))) } } #[async_trait] impl ChatModeration for MtChatModeration { async fn delete_message( &self, actor: UserId, room: &Room, message: MessageId, ) -> Result<(), ChatError> { // Read the author first. Scoped by room, so a message elsewhere is // simply absent. let author = mt_db::queries::chat_message_author(&self.db, room.id.0, message.0) .await .map_err(host_error)?; let Some(author) = author else { // Already gone, or never in this room. Idempotent rather than an // error: two moderators clicking the same message is normal, and // the desired end state has been reached either way. return Ok(()); }; // A moderator removes anyone's message; anybody else, only their own. if author != actor.0 && !self.actor_is_moderator { return Err(ChatError::host(std::io::Error::other( "cannot delete another user's chat message", ))); } let mut tx = self.db.begin().await.map_err(host_error)?; mt_db::mutations::delete_chat_message(&mut *tx, room.id.0, message.0) .await .map_err(host_error)?; // Only a moderator's removal is an auditable act. A user deleting their // own message is not moderation, and logging it would turn the mod log // into a record of everything anyone ever thought better of. if self.actor_is_moderator && author != actor.0 { mt_db::mutations::insert_mod_log( &mut *tx, Some(room.id.0), ModActor::User(actor.0), ModAction::ChatDeleteMessage, Some(author), // `target_id` is a UUID column and a chat message id is a // BIGINT, so the id goes in the reason text instead. None, Some(&format!("chat message {}", message.0)), ) .await .map_err(host_error)?; } tx.commit().await.map_err(host_error)?; Ok(()) } async fn purge_user( &self, actor: UserId, room: &Room, target: UserId, ) -> Result { self.require_moderator()?; let mut tx = self.db.begin().await.map_err(host_error)?; let removed = mt_db::mutations::purge_author_messages(&mut *tx, room.id.0, target.0) .await .map_err(host_error)?; // Logged on the same transaction as the deletion, so a purge can never // land without its audit row. This is the whole reason `audit` exists // in the forum path, and content destruction is the case that most // needs it. mt_db::mutations::insert_mod_log( &mut *tx, Some(room.id.0), ModActor::User(actor.0), ModAction::ChatPurge, Some(target.0), None, Some(&format!("{removed} chat messages")), ) .await .map_err(host_error)?; tx.commit().await.map_err(host_error)?; Ok(removed) } async fn wipe_room(&self, actor: UserId, room: &Room) -> Result { if !self.actor_is_owner { return Err(ChatError::host(std::io::Error::other( "chat wipe attempted without ownership", ))); } let mut tx = self.db.begin().await.map_err(host_error)?; let removed = mt_db::mutations::wipe_chat_room(&mut *tx, room.id.0) .await .map_err(host_error)?; // Same transaction as the deletion, for the reason `purge_user` gives: // destroying content without an audit row is the one combination worth // making structurally impossible. `target_id` is None because a wipe is // aimed at the room, not at a person. mt_db::mutations::insert_mod_log( &mut *tx, Some(room.id.0), ModActor::User(actor.0), ModAction::ChatWipe, None, None, Some(&format!("{removed} chat messages")), ) .await .map_err(host_error)?; tx.commit().await.map_err(host_error)?; Ok(removed) } async fn timeout_user( &self, actor: UserId, room: &Room, target: UserId, duration: Duration, ) -> Result<(), ChatError> { self.require_moderator()?; // A timeout is a mute that expires, which `community_bans` already // models. Reusing it rather than adding a chat-specific table means the // existing unmute path and expiry cleanup apply unchanged, and a muted // user is muted in the forum too, which is the honest reading of a // moderator silencing someone in a community. let expires_at = chrono::Utc::now() + chrono::Duration::from_std(duration).map_err(host_error)?; let mut tx = self.db.begin().await.map_err(host_error)?; mt_db::mutations::create_community_ban( &mut *tx, room.id.0, target.0, actor.0, BanType::Mute, Some("chat timeout"), Some(expires_at), ) .await .map_err(host_error)?; mt_db::mutations::insert_mod_log( &mut *tx, Some(room.id.0), ModActor::User(actor.0), ModAction::ChatTimeout, Some(target.0), None, Some(&format!("{} seconds", duration.as_secs())), ) .await .map_err(host_error)?; tx.commit().await.map_err(host_error)?; Ok(()) } async fn ban_user( &self, actor: UserId, room: &Room, target: UserId, reason: Option<&str>, ) -> Result<(), ChatError> { self.require_moderator()?; let mut tx = self.db.begin().await.map_err(host_error)?; // A permanent community ban: no `expires_at`. Same row a forum ban // writes, so this is not a chat-scoped punishment. mt_db::mutations::create_community_ban( &mut *tx, room.id.0, target.0, actor.0, BanType::Ban, reason, None, ) .await .map_err(host_error)?; mt_db::mutations::insert_mod_log( &mut *tx, Some(room.id.0), ModActor::User(actor.0), ModAction::ChatBan, Some(target.0), None, reason, ) .await .map_err(host_error)?; tx.commit().await.map_err(host_error)?; Ok(()) } async fn log_action( &self, actor: UserId, room_id: RoomId, action: &str, detail: Option<&str>, ) -> Result<(), ChatError> { // The actions above each log on their own transaction, alongside the // mutation they record, which is stronger than anything this method can // offer. It stays for a host that wants to note something extra, and // maps the free-text action onto the closest logged variant rather than // inventing an unparseable `mod_log.action` value. let mapped = match action { "delete" | "chat_delete_message" => ModAction::ChatDeleteMessage, "timeout" | "chat_timeout" => ModAction::ChatTimeout, "ban" | "chat_ban" => ModAction::ChatBan, "purge" | "chat_purge" => ModAction::ChatPurge, "wipe" | "chat_wipe" => ModAction::ChatWipe, other => { tracing::warn!( action = other, "unmapped chat mod action; logging as delete" ); ModAction::ChatDeleteMessage } }; mt_db::mutations::insert_mod_log( &self.db, Some(room_id.0), ModActor::User(actor.0), mapped, None, None, detail, ) .await .map_err(host_error) } }