Skip to main content

max / makenotwork

11.5 KB · 340 lines History Blame Raw
1 //! `ChatModeration`: deletes, timeouts, bans and purges, on the existing stack.
2 //!
3 //! Nothing here is a new moderation system. Bans and timeouts are rows in
4 //! `community_bans` (a timeout is a `mute` with an `expires_at`, which is what
5 //! that table already models), and every action lands in `mod_log` through the
6 //! same `insert_mod_log` the forum uses. A chat ban and a forum ban are the
7 //! same ban, so a user banned from chat is banned, full stop.
8 //!
9 //! # Entitlement is checked here, not by the caller
10 //!
11 //! The crate's trait says implementations must verify the actor is entitled to
12 //! the message rather than trusting the caller, and that is not delegation for
13 //! its own sake: the check needs the row, and only the host can read it. A
14 //! self-delete has to compare the actor against the message's author, and doing
15 //! that above this layer would mean passing an "is this really the author"
16 //! boolean down a call chain, which is the shape that rots.
17 //!
18 //! # Every statement names the room
19 //!
20 //! Chat message ids are BIGINT and guessable, so a lookup by id alone would let
21 //! one community's moderator reach another's messages. Every query and mutation
22 //! in `mt_db::…::chat` takes `community_id` in its `WHERE`, so a message in a
23 //! different room is not "found then rejected", it is never read. That is the
24 //! same C1 invariant `CommunityScope` enforces for UUID-keyed resources,
25 //! reached by scoping the query rather than by unwrapping a loaded row.
26
27 use std::time::Duration;
28
29 use async_trait::async_trait;
30 use livechat::{ChatError, ChatModeration, MessageId, Room, RoomId, UserId};
31 use mt_core::types::{BanType, ModAction, ModActor};
32 use sqlx::PgPool;
33
34 use super::host_error;
35
36 pub struct MtChatModeration {
37 db: PgPool,
38 /// Whether the actor holds moderator powers in this room, decided once by
39 /// the route from `ChatAuthz::is_moderator`.
40 ///
41 /// Carried rather than re-queried because the two entitlements differ:
42 /// a moderator may remove anyone's message, an author only their own. A
43 /// gate built for a member simply cannot express the moderator actions.
44 actor_is_moderator: bool,
45 /// Whether the actor owns the community, decided by the route through
46 /// `require_owner`.
47 ///
48 /// A separate flag rather than a rank, because only one action needs it and
49 /// the distinction is real: moderators moderate people, and emptying the
50 /// room is not moderation of anyone. Collapsing the two into "at least
51 /// moderator" would hand every moderator a delete-everything button.
52 actor_is_owner: bool,
53 }
54
55 impl MtChatModeration {
56 pub fn new(db: PgPool, actor_is_moderator: bool) -> Self {
57 Self {
58 db,
59 actor_is_moderator,
60 actor_is_owner: false,
61 }
62 }
63
64 /// The gate for an owner-only action, built where `require_owner` already ran.
65 ///
66 /// An owner holds moderator powers too, so this is not a narrower gate than
67 /// [`MtChatModeration::new`] produces; it is the same one plus the room-wide
68 /// wipe.
69 pub fn for_owner(db: PgPool) -> Self {
70 Self {
71 db,
72 actor_is_moderator: true,
73 actor_is_owner: true,
74 }
75 }
76
77 /// Refuse anything but a moderator.
78 fn require_moderator(&self) -> Result<(), ChatError> {
79 if self.actor_is_moderator {
80 return Ok(());
81 }
82 Err(ChatError::host(std::io::Error::other(
83 "chat moderation attempted without moderator powers",
84 )))
85 }
86 }
87
88 #[async_trait]
89 impl ChatModeration for MtChatModeration {
90 async fn delete_message(
91 &self,
92 actor: UserId,
93 room: &Room,
94 message: MessageId,
95 ) -> Result<(), ChatError> {
96 // Read the author first. Scoped by room, so a message elsewhere is
97 // simply absent.
98 let author = mt_db::queries::chat_message_author(&self.db, room.id.0, message.0)
99 .await
100 .map_err(host_error)?;
101
102 let Some(author) = author else {
103 // Already gone, or never in this room. Idempotent rather than an
104 // error: two moderators clicking the same message is normal, and
105 // the desired end state has been reached either way.
106 return Ok(());
107 };
108
109 // A moderator removes anyone's message; anybody else, only their own.
110 if author != actor.0 && !self.actor_is_moderator {
111 return Err(ChatError::host(std::io::Error::other(
112 "cannot delete another user's chat message",
113 )));
114 }
115
116 let mut tx = self.db.begin().await.map_err(host_error)?;
117
118 mt_db::mutations::delete_chat_message(&mut *tx, room.id.0, message.0)
119 .await
120 .map_err(host_error)?;
121
122 // Only a moderator's removal is an auditable act. A user deleting their
123 // own message is not moderation, and logging it would turn the mod log
124 // into a record of everything anyone ever thought better of.
125 if self.actor_is_moderator && author != actor.0 {
126 mt_db::mutations::insert_mod_log(
127 &mut *tx,
128 Some(room.id.0),
129 ModActor::User(actor.0),
130 ModAction::ChatDeleteMessage,
131 Some(author),
132 // `target_id` is a UUID column and a chat message id is a
133 // BIGINT, so the id goes in the reason text instead.
134 None,
135 Some(&format!("chat message {}", message.0)),
136 )
137 .await
138 .map_err(host_error)?;
139 }
140
141 tx.commit().await.map_err(host_error)?;
142 Ok(())
143 }
144
145 async fn purge_user(
146 &self,
147 actor: UserId,
148 room: &Room,
149 target: UserId,
150 ) -> Result<u64, ChatError> {
151 self.require_moderator()?;
152
153 let mut tx = self.db.begin().await.map_err(host_error)?;
154
155 let removed = mt_db::mutations::purge_author_messages(&mut *tx, room.id.0, target.0)
156 .await
157 .map_err(host_error)?;
158
159 // Logged on the same transaction as the deletion, so a purge can never
160 // land without its audit row. This is the whole reason `audit` exists
161 // in the forum path, and content destruction is the case that most
162 // needs it.
163 mt_db::mutations::insert_mod_log(
164 &mut *tx,
165 Some(room.id.0),
166 ModActor::User(actor.0),
167 ModAction::ChatPurge,
168 Some(target.0),
169 None,
170 Some(&format!("{removed} chat messages")),
171 )
172 .await
173 .map_err(host_error)?;
174
175 tx.commit().await.map_err(host_error)?;
176 Ok(removed)
177 }
178
179 async fn wipe_room(&self, actor: UserId, room: &Room) -> Result<u64, ChatError> {
180 if !self.actor_is_owner {
181 return Err(ChatError::host(std::io::Error::other(
182 "chat wipe attempted without ownership",
183 )));
184 }
185
186 let mut tx = self.db.begin().await.map_err(host_error)?;
187
188 let removed = mt_db::mutations::wipe_chat_room(&mut *tx, room.id.0)
189 .await
190 .map_err(host_error)?;
191
192 // Same transaction as the deletion, for the reason `purge_user` gives:
193 // destroying content without an audit row is the one combination worth
194 // making structurally impossible. `target_id` is None because a wipe is
195 // aimed at the room, not at a person.
196 mt_db::mutations::insert_mod_log(
197 &mut *tx,
198 Some(room.id.0),
199 ModActor::User(actor.0),
200 ModAction::ChatWipe,
201 None,
202 None,
203 Some(&format!("{removed} chat messages")),
204 )
205 .await
206 .map_err(host_error)?;
207
208 tx.commit().await.map_err(host_error)?;
209 Ok(removed)
210 }
211
212 async fn timeout_user(
213 &self,
214 actor: UserId,
215 room: &Room,
216 target: UserId,
217 duration: Duration,
218 ) -> Result<(), ChatError> {
219 self.require_moderator()?;
220
221 // A timeout is a mute that expires, which `community_bans` already
222 // models. Reusing it rather than adding a chat-specific table means the
223 // existing unmute path and expiry cleanup apply unchanged, and a muted
224 // user is muted in the forum too, which is the honest reading of a
225 // moderator silencing someone in a community.
226 let expires_at =
227 chrono::Utc::now() + chrono::Duration::from_std(duration).map_err(host_error)?;
228
229 let mut tx = self.db.begin().await.map_err(host_error)?;
230
231 mt_db::mutations::create_community_ban(
232 &mut *tx,
233 room.id.0,
234 target.0,
235 actor.0,
236 BanType::Mute,
237 Some("chat timeout"),
238 Some(expires_at),
239 )
240 .await
241 .map_err(host_error)?;
242
243 mt_db::mutations::insert_mod_log(
244 &mut *tx,
245 Some(room.id.0),
246 ModActor::User(actor.0),
247 ModAction::ChatTimeout,
248 Some(target.0),
249 None,
250 Some(&format!("{} seconds", duration.as_secs())),
251 )
252 .await
253 .map_err(host_error)?;
254
255 tx.commit().await.map_err(host_error)?;
256 Ok(())
257 }
258
259 async fn ban_user(
260 &self,
261 actor: UserId,
262 room: &Room,
263 target: UserId,
264 reason: Option<&str>,
265 ) -> Result<(), ChatError> {
266 self.require_moderator()?;
267
268 let mut tx = self.db.begin().await.map_err(host_error)?;
269
270 // A permanent community ban: no `expires_at`. Same row a forum ban
271 // writes, so this is not a chat-scoped punishment.
272 mt_db::mutations::create_community_ban(
273 &mut *tx,
274 room.id.0,
275 target.0,
276 actor.0,
277 BanType::Ban,
278 reason,
279 None,
280 )
281 .await
282 .map_err(host_error)?;
283
284 mt_db::mutations::insert_mod_log(
285 &mut *tx,
286 Some(room.id.0),
287 ModActor::User(actor.0),
288 ModAction::ChatBan,
289 Some(target.0),
290 None,
291 reason,
292 )
293 .await
294 .map_err(host_error)?;
295
296 tx.commit().await.map_err(host_error)?;
297 Ok(())
298 }
299
300 async fn log_action(
301 &self,
302 actor: UserId,
303 room_id: RoomId,
304 action: &str,
305 detail: Option<&str>,
306 ) -> Result<(), ChatError> {
307 // The actions above each log on their own transaction, alongside the
308 // mutation they record, which is stronger than anything this method can
309 // offer. It stays for a host that wants to note something extra, and
310 // maps the free-text action onto the closest logged variant rather than
311 // inventing an unparseable `mod_log.action` value.
312 let mapped = match action {
313 "delete" | "chat_delete_message" => ModAction::ChatDeleteMessage,
314 "timeout" | "chat_timeout" => ModAction::ChatTimeout,
315 "ban" | "chat_ban" => ModAction::ChatBan,
316 "purge" | "chat_purge" => ModAction::ChatPurge,
317 "wipe" | "chat_wipe" => ModAction::ChatWipe,
318 other => {
319 tracing::warn!(
320 action = other,
321 "unmapped chat mod action; logging as delete"
322 );
323 ModAction::ChatDeleteMessage
324 }
325 };
326
327 mt_db::mutations::insert_mod_log(
328 &self.db,
329 Some(room_id.0),
330 ModActor::User(actor.0),
331 mapped,
332 None,
333 None,
334 detail,
335 )
336 .await
337 .map_err(host_error)
338 }
339 }
340