Skip to main content

max / makenotwork

11.7 KB · 338 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 /// Remove every message in the room, returning how many rows went.
174 ///
175 /// The owner's wipe-now, not a moderator's tool: retention already bounds
176 /// how long chat lives, and this is the separate power to decide it should
177 /// not have lived that long. Implementations must verify `actor` owns the
178 /// room, and should do it as one statement keyed by the room.
179 ///
180 /// Distinct from shortening retention, which restamps expiry and lets the
181 /// sweep catch up. This is immediate and takes messages still inside their
182 /// window.
183 async fn wipe_room(&self, actor: UserId, room: &Room) -> Result<u64, ChatError>;
184
185 /// Block a user from sending for a bounded period. Reads are unaffected.
186 async fn timeout_user(
187 &self,
188 actor: UserId,
189 room: &Room,
190 target: UserId,
191 duration: Duration,
192 ) -> Result<(), ChatError>;
193
194 /// Ban a user from the room.
195 ///
196 /// The caller pairs this with [`ChatModeration::purge_user`]: a banned user's
197 /// messages in the retention window are the damage, so the ban removes them.
198 async fn ban_user(
199 &self,
200 actor: UserId,
201 room: &Room,
202 target: UserId,
203 reason: Option<&str>,
204 ) -> Result<(), ChatError>;
205
206 /// Record an action in the host's moderation log.
207 ///
208 /// Separate from the actions above so a host can log in the same transaction
209 /// as the mutation, which is what Multithreaded's `mod_log` expects.
210 async fn log_action(
211 &self,
212 actor: UserId,
213 room_id: RoomId,
214 action: &str,
215 detail: Option<&str>,
216 ) -> Result<(), ChatError>;
217 }
218
219 #[cfg(test)]
220 mod tests {
221 use super::*;
222 use crate::ids::MessageId;
223 use std::sync::Mutex;
224 use uuid::Uuid;
225
226 /// Resolves everyone to their id as a display name, and records each batch
227 /// it was handed so the tests can assert on the call shape.
228 #[derive(Default)]
229 struct Directory {
230 batches: Mutex<Vec<Vec<UserId>>>,
231 unknown: Option<UserId>,
232 }
233
234 #[async_trait]
235 impl ChatIdentity for Directory {
236 async fn identify(&self, users: &[UserId]) -> Result<HashMap<UserId, Identity>, ChatError> {
237 self.batches.lock().unwrap().push(users.to_vec());
238 Ok(users
239 .iter()
240 .filter(|u| Some(**u) != self.unknown)
241 .map(|u| {
242 (
243 *u,
244 Identity {
245 display_name: u.to_string(),
246 avatar_url: None,
247 flair: None,
248 },
249 )
250 })
251 .collect())
252 }
253 }
254
255 fn message(id: i64, author: UserId) -> Message {
256 Message {
257 id: MessageId(id),
258 room_id: crate::ids::RoomId(Uuid::nil()),
259 author_id: author,
260 body_html: "hi".into(),
261 created_at: 0,
262 nonce: None,
263 author: None,
264 }
265 }
266
267 #[tokio::test]
268 async fn attach_resolves_every_author() {
269 let (a, b) = (UserId(Uuid::new_v4()), UserId(Uuid::new_v4()));
270 let mut messages = vec![message(1, a), message(2, b)];
271
272 Directory::default().attach(&mut messages).await.unwrap();
273
274 assert_eq!(
275 messages[0].author.as_ref().unwrap().display_name,
276 a.to_string()
277 );
278 assert_eq!(
279 messages[1].author.as_ref().unwrap().display_name,
280 b.to_string()
281 );
282 }
283
284 #[tokio::test]
285 async fn attach_asks_once_per_distinct_author() {
286 // The N+1 this exists to prevent: a room where one person is talking
287 // must cost one lookup, not one per message.
288 let loud = UserId(Uuid::new_v4());
289 let mut messages: Vec<_> = (1..=50).map(|i| message(i, loud)).collect();
290
291 let directory = Directory::default();
292 directory.attach(&mut messages).await.unwrap();
293
294 let batches = directory.batches.lock().unwrap();
295 assert_eq!(batches.len(), 1, "one call for the whole backlog");
296 assert_eq!(batches[0], vec![loud], "deduplicated to one id");
297 assert!(messages.iter().all(|m| m.author.is_some()));
298 }
299
300 #[tokio::test]
301 async fn an_unresolvable_author_leaves_the_message_readable() {
302 // A deleted account must not empty the room.
303 let (gone, present) = (UserId(Uuid::new_v4()), UserId(Uuid::new_v4()));
304 let mut messages = vec![message(1, gone), message(2, present)];
305
306 Directory {
307 unknown: Some(gone),
308 ..Default::default()
309 }
310 .attach(&mut messages)
311 .await
312 .unwrap();
313
314 assert!(messages[0].author.is_none());
315 assert_eq!(messages[0].body_html, "hi", "the message itself survives");
316 assert!(messages[1].author.is_some());
317 }
318
319 #[tokio::test]
320 async fn attach_on_an_empty_batch_queries_nothing() {
321 let directory = Directory::default();
322 directory.attach(&mut []).await.unwrap();
323 assert!(directory.batches.lock().unwrap().is_empty());
324 }
325
326 #[test]
327 fn only_allow_is_allowed() {
328 assert!(WriteAccess::Allow.is_allowed());
329 assert!(!WriteAccess::Deny(DenyReason::Muted).is_allowed());
330 assert!(
331 !WriteAccess::Deny(DenyReason::RateLimited {
332 retry_after: Duration::from_secs(3)
333 })
334 .is_allowed()
335 );
336 }
337 }
338