Skip to main content

max / makenotwork

9.4 KB · 284 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 }
46
47 impl MtChatModeration {
48 pub fn new(db: PgPool, actor_is_moderator: bool) -> Self {
49 Self {
50 db,
51 actor_is_moderator,
52 }
53 }
54
55 /// Refuse anything but a moderator.
56 fn require_moderator(&self) -> Result<(), ChatError> {
57 if self.actor_is_moderator {
58 return Ok(());
59 }
60 Err(ChatError::host(std::io::Error::other(
61 "chat moderation attempted without moderator powers",
62 )))
63 }
64 }
65
66 #[async_trait]
67 impl ChatModeration for MtChatModeration {
68 async fn delete_message(
69 &self,
70 actor: UserId,
71 room: &Room,
72 message: MessageId,
73 ) -> Result<(), ChatError> {
74 // Read the author first. Scoped by room, so a message elsewhere is
75 // simply absent.
76 let author = mt_db::queries::chat_message_author(&self.db, room.id.0, message.0)
77 .await
78 .map_err(host_error)?;
79
80 let Some(author) = author else {
81 // Already gone, or never in this room. Idempotent rather than an
82 // error: two moderators clicking the same message is normal, and
83 // the desired end state has been reached either way.
84 return Ok(());
85 };
86
87 // A moderator removes anyone's message; anybody else, only their own.
88 if author != actor.0 && !self.actor_is_moderator {
89 return Err(ChatError::host(std::io::Error::other(
90 "cannot delete another user's chat message",
91 )));
92 }
93
94 let mut tx = self.db.begin().await.map_err(host_error)?;
95
96 mt_db::mutations::delete_chat_message(&mut *tx, room.id.0, message.0)
97 .await
98 .map_err(host_error)?;
99
100 // Only a moderator's removal is an auditable act. A user deleting their
101 // own message is not moderation, and logging it would turn the mod log
102 // into a record of everything anyone ever thought better of.
103 if self.actor_is_moderator && author != actor.0 {
104 mt_db::mutations::insert_mod_log(
105 &mut *tx,
106 Some(room.id.0),
107 ModActor::User(actor.0),
108 ModAction::ChatDeleteMessage,
109 Some(author),
110 // `target_id` is a UUID column and a chat message id is a
111 // BIGINT, so the id goes in the reason text instead.
112 None,
113 Some(&format!("chat message {}", message.0)),
114 )
115 .await
116 .map_err(host_error)?;
117 }
118
119 tx.commit().await.map_err(host_error)?;
120 Ok(())
121 }
122
123 async fn purge_user(
124 &self,
125 actor: UserId,
126 room: &Room,
127 target: UserId,
128 ) -> Result<u64, ChatError> {
129 self.require_moderator()?;
130
131 let mut tx = self.db.begin().await.map_err(host_error)?;
132
133 let removed = mt_db::mutations::purge_author_messages(&mut *tx, room.id.0, target.0)
134 .await
135 .map_err(host_error)?;
136
137 // Logged on the same transaction as the deletion, so a purge can never
138 // land without its audit row. This is the whole reason `audit` exists
139 // in the forum path, and content destruction is the case that most
140 // needs it.
141 mt_db::mutations::insert_mod_log(
142 &mut *tx,
143 Some(room.id.0),
144 ModActor::User(actor.0),
145 ModAction::ChatPurge,
146 Some(target.0),
147 None,
148 Some(&format!("{removed} chat messages")),
149 )
150 .await
151 .map_err(host_error)?;
152
153 tx.commit().await.map_err(host_error)?;
154 Ok(removed)
155 }
156
157 async fn timeout_user(
158 &self,
159 actor: UserId,
160 room: &Room,
161 target: UserId,
162 duration: Duration,
163 ) -> Result<(), ChatError> {
164 self.require_moderator()?;
165
166 // A timeout is a mute that expires, which `community_bans` already
167 // models. Reusing it rather than adding a chat-specific table means the
168 // existing unmute path and expiry cleanup apply unchanged, and a muted
169 // user is muted in the forum too, which is the honest reading of a
170 // moderator silencing someone in a community.
171 let expires_at =
172 chrono::Utc::now() + chrono::Duration::from_std(duration).map_err(host_error)?;
173
174 let mut tx = self.db.begin().await.map_err(host_error)?;
175
176 mt_db::mutations::create_community_ban(
177 &mut *tx,
178 room.id.0,
179 target.0,
180 actor.0,
181 BanType::Mute,
182 Some("chat timeout"),
183 Some(expires_at),
184 )
185 .await
186 .map_err(host_error)?;
187
188 mt_db::mutations::insert_mod_log(
189 &mut *tx,
190 Some(room.id.0),
191 ModActor::User(actor.0),
192 ModAction::ChatTimeout,
193 Some(target.0),
194 None,
195 Some(&format!("{} seconds", duration.as_secs())),
196 )
197 .await
198 .map_err(host_error)?;
199
200 tx.commit().await.map_err(host_error)?;
201 Ok(())
202 }
203
204 async fn ban_user(
205 &self,
206 actor: UserId,
207 room: &Room,
208 target: UserId,
209 reason: Option<&str>,
210 ) -> Result<(), ChatError> {
211 self.require_moderator()?;
212
213 let mut tx = self.db.begin().await.map_err(host_error)?;
214
215 // A permanent community ban: no `expires_at`. Same row a forum ban
216 // writes, so this is not a chat-scoped punishment.
217 mt_db::mutations::create_community_ban(
218 &mut *tx,
219 room.id.0,
220 target.0,
221 actor.0,
222 BanType::Ban,
223 reason,
224 None,
225 )
226 .await
227 .map_err(host_error)?;
228
229 mt_db::mutations::insert_mod_log(
230 &mut *tx,
231 Some(room.id.0),
232 ModActor::User(actor.0),
233 ModAction::ChatBan,
234 Some(target.0),
235 None,
236 reason,
237 )
238 .await
239 .map_err(host_error)?;
240
241 tx.commit().await.map_err(host_error)?;
242 Ok(())
243 }
244
245 async fn log_action(
246 &self,
247 actor: UserId,
248 room_id: RoomId,
249 action: &str,
250 detail: Option<&str>,
251 ) -> Result<(), ChatError> {
252 // The actions above each log on their own transaction, alongside the
253 // mutation they record, which is stronger than anything this method can
254 // offer. It stays for a host that wants to note something extra, and
255 // maps the free-text action onto the closest logged variant rather than
256 // inventing an unparseable `mod_log.action` value.
257 let mapped = match action {
258 "delete" | "chat_delete_message" => ModAction::ChatDeleteMessage,
259 "timeout" | "chat_timeout" => ModAction::ChatTimeout,
260 "ban" | "chat_ban" => ModAction::ChatBan,
261 "purge" | "chat_purge" => ModAction::ChatPurge,
262 other => {
263 tracing::warn!(
264 action = other,
265 "unmapped chat mod action; logging as delete"
266 );
267 ModAction::ChatDeleteMessage
268 }
269 };
270
271 mt_db::mutations::insert_mod_log(
272 &self.db,
273 Some(room_id.0),
274 ModActor::User(actor.0),
275 mapped,
276 None,
277 None,
278 detail,
279 )
280 .await
281 .map_err(host_error)
282 }
283 }
284