//! Message body validation and the stored message shape. use serde::{Deserialize, Serialize}; use crate::error::ChatError; use crate::ids::{MessageId, Nonce, RoomId, UserId}; /// Longest message a room accepts. /// /// Enforced here, on the server, as the authority. The client enforces the same /// number for feedback while typing, but that is a courtesy and not a control. pub const MAX_MESSAGE_LEN: usize = 500; /// A validated message body. /// /// Constructing one is the only way to reach the send path, so "did anyone check /// the length" is not a question a caller can get wrong. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] pub struct MessageBody(String); impl MessageBody { /// Trims surrounding whitespace, then rejects empty or overlong bodies. /// /// Length is counted in `chars`, not bytes, so the limit means the same thing /// to someone typing in a script that is not Latin. /// /// # Errors /// /// [`ChatError::MessageEmpty`] or [`ChatError::MessageTooLong`]. pub fn parse(raw: &str) -> Result { let trimmed = raw.trim(); if trimmed.is_empty() { return Err(ChatError::MessageEmpty); } let len = trimmed.chars().count(); if len > MAX_MESSAGE_LEN { return Err(ChatError::MessageTooLong { len, max: MAX_MESSAGE_LEN, }); } Ok(Self(trimmed.to_owned())) } pub fn as_str(&self) -> &str { &self.0 } } /// A message as stored and as broadcast. /// /// `body_html` is rendered once at insert by the host, through docengine's chat /// preset, and never re-rendered: there is no edit path anywhere in the system. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Message { pub id: MessageId, pub room_id: RoomId, pub author_id: UserId, pub body_html: String, /// Unix seconds. Wall clock is adequate: a single process assigns every id, /// and `MessageId` is what actually orders the room. pub created_at: i64, /// Echoed to the sender so an optimistically rendered message reconciles. /// Absent for every other recipient and for backlog replay. #[serde(skip_serializing_if = "Option::is_none")] pub nonce: Option, } #[cfg(test)] mod tests { use super::*; #[test] fn trims_then_rejects_empty() { assert!(matches!( MessageBody::parse(" \n\t "), Err(ChatError::MessageEmpty) )); assert!(matches!( MessageBody::parse(""), Err(ChatError::MessageEmpty) )); } #[test] fn trims_surrounding_whitespace() { assert_eq!(MessageBody::parse(" hi ").unwrap().as_str(), "hi"); } #[test] fn boundary_is_inclusive() { let at = "x".repeat(MAX_MESSAGE_LEN); assert!(MessageBody::parse(&at).is_ok()); let over = "x".repeat(MAX_MESSAGE_LEN + 1); assert!(matches!( MessageBody::parse(&over), Err(ChatError::MessageTooLong { len, max }) if len == MAX_MESSAGE_LEN + 1 && max == MAX_MESSAGE_LEN )); } #[test] fn length_counts_chars_not_bytes() { // Four bytes each: a body at the char limit is well over it in bytes, // and must still be accepted. let emoji_free_multibyte = "\u{1F00}".repeat(MAX_MESSAGE_LEN); assert!(emoji_free_multibyte.len() > MAX_MESSAGE_LEN); assert!(MessageBody::parse(&emoji_free_multibyte).is_ok()); } #[test] fn length_is_measured_after_trimming() { let padded = format!(" {} ", "x".repeat(MAX_MESSAGE_LEN)); assert!(MessageBody::parse(&padded).is_ok()); } }