Skip to main content

max / makenotwork

11.1 KB · 326 lines History Blame Raw
1 //! The seams each host app implements.
2 //!
3 //! The crate knows nothing about communities, streams, subscriptions, or bans.
4 //! Everything app-specific arrives through these four traits, which is what lets
5 //! Multithreaded gate on `CommunityScope` and the MNW server gate on
6 //! `SubscriptionGate` without either concept appearing here.
7 //!
8 //! `async_trait` rather than native AFIT: the returned futures need `Send` to be
9 //! spawned onto tokio, and the MNW server already depends on the crate.
10
11 use std::collections::HashMap;
12 use std::time::Duration;
13
14 use async_trait::async_trait;
15 use serde::{Deserialize, Serialize};
16
17 use crate::error::ChatError;
18 use crate::ids::{MessageId, RoomId, UserId};
19 use crate::message::Message;
20 use crate::room::Room;
21
22 /// Resolves an app-specific key to a room.
23 #[async_trait]
24 pub trait ChatRooms: Send + Sync {
25 /// Resolve a host-namespaced key: a community slug in Multithreaded, a stream
26 /// id in the MNW server.
27 ///
28 /// Returns `Ok(None)` when no such room exists. A room whose chat is disabled
29 /// should come back as [`crate::room::RoomState::Closed`] rather than `None`,
30 /// so the host can decide whether "disabled" and "absent" look different.
31 async fn resolve(&self, key: &str) -> Result<Option<Room>, ChatError>;
32 }
33
34 /// Why a write was refused. Carried back so the client can say something
35 /// specific instead of a bare refusal.
36 #[derive(Debug, Clone, PartialEq, Eq)]
37 pub enum DenyReason {
38 /// Not signed in.
39 Anonymous,
40 /// Signed in, but not a member of this community.
41 NotAMember,
42 /// Banned from the room.
43 Banned,
44 /// Muted: still reads, cannot write.
45 Muted,
46 /// Suspended at the platform level.
47 Suspended,
48 /// The room requires a paid tier the user does not have.
49 TierRequired,
50 /// Sending too fast.
51 RateLimited { retry_after: Duration },
52 }
53
54 /// The outcome of a write check.
55 #[derive(Debug, Clone, PartialEq, Eq)]
56 pub enum WriteAccess {
57 Allow,
58 Deny(DenyReason),
59 }
60
61 impl WriteAccess {
62 pub fn is_allowed(&self) -> bool {
63 matches!(self, Self::Allow)
64 }
65 }
66
67 /// Per-user read and write gating.
68 #[async_trait]
69 pub trait ChatAuthz: Send + Sync {
70 /// Whether this viewer may read the room.
71 ///
72 /// `viewer` is `None` for a logged-out request, which is a real case:
73 /// Multithreaded's `public_read` policy shows the room to anyone.
74 async fn can_read(&self, viewer: Option<UserId>, room: &Room) -> Result<bool, ChatError>;
75
76 /// Whether this user may send, and if not, why.
77 async fn can_write(&self, user: UserId, room: &Room) -> Result<WriteAccess, ChatError>;
78
79 /// Whether this user holds moderator powers in the room.
80 async fn is_moderator(&self, user: UserId, room: &Room) -> Result<bool, ChatError>;
81 }
82
83 /// How a user is displayed.
84 ///
85 /// Serializable because it rides out on [`Message::author`](crate::Message).
86 /// It is never stored: see that field for why a frozen copy would be wrong.
87 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88 pub struct Identity {
89 pub display_name: String,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub avatar_url: Option<String>,
92 /// Role or badge text, if the host shows one.
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub flair: Option<String>,
95 }
96
97 /// Display information for participants.
98 #[async_trait]
99 pub trait ChatIdentity: Send + Sync {
100 /// Resolve many users at once.
101 ///
102 /// Batched because backlog replay needs every author in the window, and doing
103 /// that one query at a time is an N+1 on the reconnect path. Neither app gets
104 /// this for free: Multithreaded's `SessionUser` carries no `avatar_url`, so
105 /// this is a join plus a cache.
106 ///
107 /// Users the host cannot resolve are omitted from the map rather than
108 /// erroring. A deleted account should not take down the room.
109 async fn identify(&self, users: &[UserId]) -> Result<HashMap<UserId, Identity>, ChatError>;
110
111 /// Fill in [`Message::author`](crate::Message) across a batch.
112 ///
113 /// Provided rather than left to each host, because the host writing this
114 /// loop by hand is exactly where the N+1 that [`ChatIdentity::identify`] is
115 /// batched to avoid gets reintroduced. Distinct authors are collected first,
116 /// so a room where one person is talking costs one lookup rather than one
117 /// per message.
118 ///
119 /// Authors the host could not resolve are left as `None`, matching
120 /// `identify`'s contract: a deleted account leaves its messages readable
121 /// rather than emptying the room.
122 async fn attach(&self, messages: &mut [Message]) -> Result<(), ChatError> {
123 if messages.is_empty() {
124 return Ok(());
125 }
126
127 let mut distinct: Vec<UserId> = messages.iter().map(|m| m.author_id).collect();
128 distinct.sort_unstable_by_key(|u| u.0);
129 distinct.dedup();
130
131 let identities = self.identify(&distinct).await?;
132 for message in messages {
133 message.author = identities.get(&message.author_id).cloned();
134 }
135 Ok(())
136 }
137 }
138
139 /// Moderation actions.
140 ///
141 /// There is no edit anywhere in this trait, and that is deliberate: chat is a
142 /// stream nobody re-reads, so an edit path would be surface with no benefit and
143 /// would drag an edit-history table behind it.
144 ///
145 /// Deletion is a real delete, not a tombstone. The row expires anyway, and a
146 /// tombstone would outlive the content it describes, which is the opposite of
147 /// what bounded retention is for.
148 #[async_trait]
149 pub trait ChatModeration: Send + Sync {
150 /// Remove one message. `actor` is the author for a self-delete, or a
151 /// moderator. Implementations must verify the actor is entitled to this
152 /// message rather than trusting the caller.
153 async fn delete_message(
154 &self,
155 actor: UserId,
156 room: &Room,
157 message: MessageId,
158 ) -> Result<(), ChatError>;
159
160 /// Remove every message by `target` in the room's current retention window,
161 /// returning how many rows went.
162 ///
163 /// Issued as part of a ban. Implementations should do this as one statement
164 /// keyed by (room, author); the caller broadcasts a single
165 /// [`crate::event::ChatEvent::Purge`] rather than one event per message.
166 async fn purge_user(
167 &self,
168 actor: UserId,
169 room: &Room,
170 target: UserId,
171 ) -> Result<u64, ChatError>;
172
173 /// Block a user from sending for a bounded period. Reads are unaffected.
174 async fn timeout_user(
175 &self,
176 actor: UserId,
177 room: &Room,
178 target: UserId,
179 duration: Duration,
180 ) -> Result<(), ChatError>;
181
182 /// Ban a user from the room.
183 ///
184 /// The caller pairs this with [`ChatModeration::purge_user`]: a banned user's
185 /// messages in the retention window are the damage, so the ban removes them.
186 async fn ban_user(
187 &self,
188 actor: UserId,
189 room: &Room,
190 target: UserId,
191 reason: Option<&str>,
192 ) -> Result<(), ChatError>;
193
194 /// Record an action in the host's moderation log.
195 ///
196 /// Separate from the actions above so a host can log in the same transaction
197 /// as the mutation, which is what Multithreaded's `mod_log` expects.
198 async fn log_action(
199 &self,
200 actor: UserId,
201 room_id: RoomId,
202 action: &str,
203 detail: Option<&str>,
204 ) -> Result<(), ChatError>;
205 }
206
207 #[cfg(test)]
208 mod tests {
209 use super::*;
210 use crate::ids::MessageId;
211 use std::sync::Mutex;
212 use uuid::Uuid;
213
214 /// Resolves everyone to their id as a display name, and records each batch
215 /// it was handed so the tests can assert on the call shape.
216 #[derive(Default)]
217 struct Directory {
218 batches: Mutex<Vec<Vec<UserId>>>,
219 unknown: Option<UserId>,
220 }
221
222 #[async_trait]
223 impl ChatIdentity for Directory {
224 async fn identify(&self, users: &[UserId]) -> Result<HashMap<UserId, Identity>, ChatError> {
225 self.batches.lock().unwrap().push(users.to_vec());
226 Ok(users
227 .iter()
228 .filter(|u| Some(**u) != self.unknown)
229 .map(|u| {
230 (
231 *u,
232 Identity {
233 display_name: u.to_string(),
234 avatar_url: None,
235 flair: None,
236 },
237 )
238 })
239 .collect())
240 }
241 }
242
243 fn message(id: i64, author: UserId) -> Message {
244 Message {
245 id: MessageId(id),
246 room_id: crate::ids::RoomId(Uuid::nil()),
247 author_id: author,
248 body_html: "hi".into(),
249 created_at: 0,
250 nonce: None,
251 author: None,
252 }
253 }
254
255 #[tokio::test]
256 async fn attach_resolves_every_author() {
257 let (a, b) = (UserId(Uuid::new_v4()), UserId(Uuid::new_v4()));
258 let mut messages = vec![message(1, a), message(2, b)];
259
260 Directory::default().attach(&mut messages).await.unwrap();
261
262 assert_eq!(
263 messages[0].author.as_ref().unwrap().display_name,
264 a.to_string()
265 );
266 assert_eq!(
267 messages[1].author.as_ref().unwrap().display_name,
268 b.to_string()
269 );
270 }
271
272 #[tokio::test]
273 async fn attach_asks_once_per_distinct_author() {
274 // The N+1 this exists to prevent: a room where one person is talking
275 // must cost one lookup, not one per message.
276 let loud = UserId(Uuid::new_v4());
277 let mut messages: Vec<_> = (1..=50).map(|i| message(i, loud)).collect();
278
279 let directory = Directory::default();
280 directory.attach(&mut messages).await.unwrap();
281
282 let batches = directory.batches.lock().unwrap();
283 assert_eq!(batches.len(), 1, "one call for the whole backlog");
284 assert_eq!(batches[0], vec![loud], "deduplicated to one id");
285 assert!(messages.iter().all(|m| m.author.is_some()));
286 }
287
288 #[tokio::test]
289 async fn an_unresolvable_author_leaves_the_message_readable() {
290 // A deleted account must not empty the room.
291 let (gone, present) = (UserId(Uuid::new_v4()), UserId(Uuid::new_v4()));
292 let mut messages = vec![message(1, gone), message(2, present)];
293
294 Directory {
295 unknown: Some(gone),
296 ..Default::default()
297 }
298 .attach(&mut messages)
299 .await
300 .unwrap();
301
302 assert!(messages[0].author.is_none());
303 assert_eq!(messages[0].body_html, "hi", "the message itself survives");
304 assert!(messages[1].author.is_some());
305 }
306
307 #[tokio::test]
308 async fn attach_on_an_empty_batch_queries_nothing() {
309 let directory = Directory::default();
310 directory.attach(&mut []).await.unwrap();
311 assert!(directory.batches.lock().unwrap().is_empty());
312 }
313
314 #[test]
315 fn only_allow_is_allowed() {
316 assert!(WriteAccess::Allow.is_allowed());
317 assert!(!WriteAccess::Deny(DenyReason::Muted).is_allowed());
318 assert!(
319 !WriteAccess::Deny(DenyReason::RateLimited {
320 retry_after: Duration::from_secs(3)
321 })
322 .is_allowed()
323 );
324 }
325 }
326