Skip to main content

max / makenotwork

4.5 KB · 134 lines History Blame Raw
1 //! Message body validation and the stored message shape.
2
3 use serde::{Deserialize, Serialize};
4
5 use crate::error::ChatError;
6 use crate::ids::{MessageId, Nonce, RoomId, UserId};
7 use crate::traits::Identity;
8
9 /// Longest message a room accepts.
10 ///
11 /// Enforced here, on the server, as the authority. The client enforces the same
12 /// number for feedback while typing, but that is a courtesy and not a control.
13 pub const MAX_MESSAGE_LEN: usize = 500;
14
15 /// A validated message body.
16 ///
17 /// Constructing one is the only way to reach the send path, so "did anyone check
18 /// the length" is not a question a caller can get wrong.
19 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20 #[serde(transparent)]
21 pub struct MessageBody(String);
22
23 impl MessageBody {
24 /// Trims surrounding whitespace, then rejects empty or overlong bodies.
25 ///
26 /// Length is counted in `chars`, not bytes, so the limit means the same thing
27 /// to someone typing in a script that is not Latin.
28 ///
29 /// # Errors
30 ///
31 /// [`ChatError::MessageEmpty`] or [`ChatError::MessageTooLong`].
32 pub fn parse(raw: &str) -> Result<Self, ChatError> {
33 let trimmed = raw.trim();
34 if trimmed.is_empty() {
35 return Err(ChatError::MessageEmpty);
36 }
37 let len = trimmed.chars().count();
38 if len > MAX_MESSAGE_LEN {
39 return Err(ChatError::MessageTooLong {
40 len,
41 max: MAX_MESSAGE_LEN,
42 });
43 }
44 Ok(Self(trimmed.to_owned()))
45 }
46
47 pub fn as_str(&self) -> &str {
48 &self.0
49 }
50 }
51
52 /// A message as stored and as broadcast.
53 ///
54 /// `body_html` is rendered once at insert by the host, through docengine's chat
55 /// preset, and never re-rendered: there is no edit path anywhere in the system.
56 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57 pub struct Message {
58 pub id: MessageId,
59 pub room_id: RoomId,
60 pub author_id: UserId,
61 pub body_html: String,
62 /// Unix seconds. Wall clock is adequate: a single process assigns every id,
63 /// and `MessageId` is what actually orders the room.
64 pub created_at: i64,
65 /// Echoed to the sender so an optimistically rendered message reconciles.
66 /// Absent for every other recipient and for backlog replay.
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub nonce: Option<Nonce>,
69 /// Who wrote it, as the room should display them.
70 ///
71 /// Populated by the host on the way out and **never persisted**: a display
72 /// name, avatar, or flair that changes must change everywhere it appears,
73 /// and a copy frozen into the message row at insert would not. The row keeps
74 /// `author_id`; everything shown next to it is resolved at send and replay
75 /// time through [`ChatIdentity::attach`](crate::ChatIdentity::attach).
76 ///
77 /// Optional and skipped when absent, following `nonce` above, so adding it
78 /// did not change a single frame a client already knew how to parse. A
79 /// client that receives a message without one falls back to whatever it
80 /// already shows for `author_id`.
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub author: Option<Identity>,
83 }
84
85 #[cfg(test)]
86 mod tests {
87 use super::*;
88
89 #[test]
90 fn trims_then_rejects_empty() {
91 assert!(matches!(
92 MessageBody::parse(" \n\t "),
93 Err(ChatError::MessageEmpty)
94 ));
95 assert!(matches!(
96 MessageBody::parse(""),
97 Err(ChatError::MessageEmpty)
98 ));
99 }
100
101 #[test]
102 fn trims_surrounding_whitespace() {
103 assert_eq!(MessageBody::parse(" hi ").unwrap().as_str(), "hi");
104 }
105
106 #[test]
107 fn boundary_is_inclusive() {
108 let at = "x".repeat(MAX_MESSAGE_LEN);
109 assert!(MessageBody::parse(&at).is_ok());
110
111 let over = "x".repeat(MAX_MESSAGE_LEN + 1);
112 assert!(matches!(
113 MessageBody::parse(&over),
114 Err(ChatError::MessageTooLong { len, max })
115 if len == MAX_MESSAGE_LEN + 1 && max == MAX_MESSAGE_LEN
116 ));
117 }
118
119 #[test]
120 fn length_counts_chars_not_bytes() {
121 // Four bytes each: a body at the char limit is well over it in bytes,
122 // and must still be accepted.
123 let emoji_free_multibyte = "\u{1F00}".repeat(MAX_MESSAGE_LEN);
124 assert!(emoji_free_multibyte.len() > MAX_MESSAGE_LEN);
125 assert!(MessageBody::parse(&emoji_free_multibyte).is_ok());
126 }
127
128 #[test]
129 fn length_is_measured_after_trimming() {
130 let padded = format!(" {} ", "x".repeat(MAX_MESSAGE_LEN));
131 assert!(MessageBody::parse(&padded).is_ok());
132 }
133 }
134