//! The seams each host app implements. //! //! The crate knows nothing about communities, streams, subscriptions, or bans. //! Everything app-specific arrives through these four traits, which is what lets //! Multithreaded gate on `CommunityScope` and the MNW server gate on //! `SubscriptionGate` without either concept appearing here. //! //! `async_trait` rather than native AFIT: the returned futures need `Send` to be //! spawned onto tokio, and the MNW server already depends on the crate. use std::collections::HashMap; use std::time::Duration; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use crate::error::ChatError; use crate::ids::{MessageId, RoomId, UserId}; use crate::message::Message; use crate::room::Room; /// Resolves an app-specific key to a room. #[async_trait] pub trait ChatRooms: Send + Sync { /// Resolve a host-namespaced key: a community slug in Multithreaded, a stream /// id in the MNW server. /// /// Returns `Ok(None)` when no such room exists. A room whose chat is disabled /// should come back as [`crate::room::RoomState::Closed`] rather than `None`, /// so the host can decide whether "disabled" and "absent" look different. async fn resolve(&self, key: &str) -> Result, ChatError>; } /// Why a write was refused. Carried back so the client can say something /// specific instead of a bare refusal. #[derive(Debug, Clone, PartialEq, Eq)] pub enum DenyReason { /// Not signed in. Anonymous, /// Signed in, but not a member of this community. NotAMember, /// Banned from the room. Banned, /// Muted: still reads, cannot write. Muted, /// Suspended at the platform level. Suspended, /// The room requires a paid tier the user does not have. TierRequired, /// Sending too fast. RateLimited { retry_after: Duration }, } /// The outcome of a write check. #[derive(Debug, Clone, PartialEq, Eq)] pub enum WriteAccess { Allow, Deny(DenyReason), } impl WriteAccess { pub fn is_allowed(&self) -> bool { matches!(self, Self::Allow) } } /// Per-user read and write gating. #[async_trait] pub trait ChatAuthz: Send + Sync { /// Whether this viewer may read the room. /// /// `viewer` is `None` for a logged-out request, which is a real case: /// Multithreaded's `public_read` policy shows the room to anyone. async fn can_read(&self, viewer: Option, room: &Room) -> Result; /// Whether this user may send, and if not, why. async fn can_write(&self, user: UserId, room: &Room) -> Result; /// Whether this user holds moderator powers in the room. async fn is_moderator(&self, user: UserId, room: &Room) -> Result; } /// How a user is displayed. /// /// Serializable because it rides out on [`Message::author`](crate::Message). /// It is never stored: see that field for why a frozen copy would be wrong. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Identity { pub display_name: String, #[serde(skip_serializing_if = "Option::is_none")] pub avatar_url: Option, /// Role or badge text, if the host shows one. #[serde(skip_serializing_if = "Option::is_none")] pub flair: Option, } /// Display information for participants. #[async_trait] pub trait ChatIdentity: Send + Sync { /// Resolve many users at once. /// /// Batched because backlog replay needs every author in the window, and doing /// that one query at a time is an N+1 on the reconnect path. Neither app gets /// this for free: Multithreaded's `SessionUser` carries no `avatar_url`, so /// this is a join plus a cache. /// /// Users the host cannot resolve are omitted from the map rather than /// erroring. A deleted account should not take down the room. async fn identify(&self, users: &[UserId]) -> Result, ChatError>; /// Fill in [`Message::author`](crate::Message) across a batch. /// /// Provided rather than left to each host, because the host writing this /// loop by hand is exactly where the N+1 that [`ChatIdentity::identify`] is /// batched to avoid gets reintroduced. Distinct authors are collected first, /// so a room where one person is talking costs one lookup rather than one /// per message. /// /// Authors the host could not resolve are left as `None`, matching /// `identify`'s contract: a deleted account leaves its messages readable /// rather than emptying the room. async fn attach(&self, messages: &mut [Message]) -> Result<(), ChatError> { if messages.is_empty() { return Ok(()); } let mut distinct: Vec = messages.iter().map(|m| m.author_id).collect(); distinct.sort_unstable_by_key(|u| u.0); distinct.dedup(); let identities = self.identify(&distinct).await?; for message in messages { message.author = identities.get(&message.author_id).cloned(); } Ok(()) } } /// Moderation actions. /// /// There is no edit anywhere in this trait, and that is deliberate: chat is a /// stream nobody re-reads, so an edit path would be surface with no benefit and /// would drag an edit-history table behind it. /// /// Deletion is a real delete, not a tombstone. The row expires anyway, and a /// tombstone would outlive the content it describes, which is the opposite of /// what bounded retention is for. #[async_trait] pub trait ChatModeration: Send + Sync { /// Remove one message. `actor` is the author for a self-delete, or a /// moderator. Implementations must verify the actor is entitled to this /// message rather than trusting the caller. async fn delete_message( &self, actor: UserId, room: &Room, message: MessageId, ) -> Result<(), ChatError>; /// Remove every message by `target` in the room's current retention window, /// returning how many rows went. /// /// Issued as part of a ban. Implementations should do this as one statement /// keyed by (room, author); the caller broadcasts a single /// [`crate::event::ChatEvent::Purge`] rather than one event per message. async fn purge_user( &self, actor: UserId, room: &Room, target: UserId, ) -> Result; /// Block a user from sending for a bounded period. Reads are unaffected. async fn timeout_user( &self, actor: UserId, room: &Room, target: UserId, duration: Duration, ) -> Result<(), ChatError>; /// Ban a user from the room. /// /// The caller pairs this with [`ChatModeration::purge_user`]: a banned user's /// messages in the retention window are the damage, so the ban removes them. async fn ban_user( &self, actor: UserId, room: &Room, target: UserId, reason: Option<&str>, ) -> Result<(), ChatError>; /// Record an action in the host's moderation log. /// /// Separate from the actions above so a host can log in the same transaction /// as the mutation, which is what Multithreaded's `mod_log` expects. async fn log_action( &self, actor: UserId, room_id: RoomId, action: &str, detail: Option<&str>, ) -> Result<(), ChatError>; } #[cfg(test)] mod tests { use super::*; use crate::ids::MessageId; use std::sync::Mutex; use uuid::Uuid; /// Resolves everyone to their id as a display name, and records each batch /// it was handed so the tests can assert on the call shape. #[derive(Default)] struct Directory { batches: Mutex>>, unknown: Option, } #[async_trait] impl ChatIdentity for Directory { async fn identify(&self, users: &[UserId]) -> Result, ChatError> { self.batches.lock().unwrap().push(users.to_vec()); Ok(users .iter() .filter(|u| Some(**u) != self.unknown) .map(|u| { ( *u, Identity { display_name: u.to_string(), avatar_url: None, flair: None, }, ) }) .collect()) } } fn message(id: i64, author: UserId) -> Message { Message { id: MessageId(id), room_id: crate::ids::RoomId(Uuid::nil()), author_id: author, body_html: "hi".into(), created_at: 0, nonce: None, author: None, } } #[tokio::test] async fn attach_resolves_every_author() { let (a, b) = (UserId(Uuid::new_v4()), UserId(Uuid::new_v4())); let mut messages = vec![message(1, a), message(2, b)]; Directory::default().attach(&mut messages).await.unwrap(); assert_eq!( messages[0].author.as_ref().unwrap().display_name, a.to_string() ); assert_eq!( messages[1].author.as_ref().unwrap().display_name, b.to_string() ); } #[tokio::test] async fn attach_asks_once_per_distinct_author() { // The N+1 this exists to prevent: a room where one person is talking // must cost one lookup, not one per message. let loud = UserId(Uuid::new_v4()); let mut messages: Vec<_> = (1..=50).map(|i| message(i, loud)).collect(); let directory = Directory::default(); directory.attach(&mut messages).await.unwrap(); let batches = directory.batches.lock().unwrap(); assert_eq!(batches.len(), 1, "one call for the whole backlog"); assert_eq!(batches[0], vec![loud], "deduplicated to one id"); assert!(messages.iter().all(|m| m.author.is_some())); } #[tokio::test] async fn an_unresolvable_author_leaves_the_message_readable() { // A deleted account must not empty the room. let (gone, present) = (UserId(Uuid::new_v4()), UserId(Uuid::new_v4())); let mut messages = vec![message(1, gone), message(2, present)]; Directory { unknown: Some(gone), ..Default::default() } .attach(&mut messages) .await .unwrap(); assert!(messages[0].author.is_none()); assert_eq!(messages[0].body_html, "hi", "the message itself survives"); assert!(messages[1].author.is_some()); } #[tokio::test] async fn attach_on_an_empty_batch_queries_nothing() { let directory = Directory::default(); directory.attach(&mut []).await.unwrap(); assert!(directory.batches.lock().unwrap().is_empty()); } #[test] fn only_allow_is_allowed() { assert!(WriteAccess::Allow.is_allowed()); assert!(!WriteAccess::Deny(DenyReason::Muted).is_allowed()); assert!( !WriteAccess::Deny(DenyReason::RateLimited { retry_after: Duration::from_secs(3) }) .is_allowed() ); } }