1use std::collections::BTreeMap;
2
3use axum::extract::State;
4use futures::{StreamExt, TryStreamExt};
5use ruma::{
6 EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, UInt,
7 UserId,
8 events::{
9 TimelineEventType,
10 room::{
11 member::{MembershipState, RoomMemberEventContent},
12 redaction::RoomRedactionEventContent,
13 },
14 },
15};
16use serde_json::Value as JsonValue;
17use synapse_admin_api::users::redact::v1::{Request, Response};
18use tuwunel_core::{
19 Err, Result,
20 matrix::{
21 Event,
22 pdu::{PduBuilder, PduEvent},
23 },
24 utils::{
25 BoolExt,
26 stream::{BroadbandExt, TryReadyExt},
27 },
28};
29
30use crate::{Ruma, client::admin::require_admin};
31
32const LIMIT_DEFAULT: usize = 1000;
34
35type FailedRedactions = BTreeMap<OwnedEventId, String>;
36
37struct RedactArgs {
38 user_id: OwnedUserId,
39 rooms: Vec<OwnedRoomId>,
40 redact_as: OwnedUserId,
41 reason: Option<String>,
42 limit: usize,
43 before_ts: Option<MilliSecondsSinceUnixEpoch>,
44 after_ts: Option<MilliSecondsSinceUnixEpoch>,
45}
46
47pub(crate) async fn admin_redact_user_route(
53 State(services): State<crate::State>,
54 body: Ruma<Request>,
55) -> Result<Response> {
56 require_admin(&services, body.sender_user()).await?;
57
58 let user_id = &body.user_id;
59
60 let in_progress = services
61 .tasks
62 .has_nonterminal(super::REDACT_USER_ACTION, user_id.as_str());
63
64 if in_progress {
65 return Err!(Request(InvalidParam("Redact already in progress for user {user_id}")));
66 }
67
68 let rooms = body
69 .rooms
70 .is_empty()
71 .then_async(async || joined_and_banned_rooms(&services, user_id).await)
72 .await;
73
74 let redact_as = (body.use_admin || !services.globals.user_is_local(user_id))
75 .then(|| body.sender_user().to_owned());
76
77 let Request {
78 user_id,
79 rooms: requested_rooms,
80 reason,
81 limit,
82 before_ts,
83 after_ts,
84 ..
85 } = body.body;
86
87 let rooms = rooms.unwrap_or(requested_rooms);
88 let resource_id = user_id.to_string();
89 let redact_as = redact_as.unwrap_or_else(|| user_id.clone());
90
91 let args = RedactArgs {
92 user_id,
93 rooms,
94 redact_as,
95 reason,
96 limit: resolve_limit(limit),
97 before_ts,
98 after_ts,
99 };
100
101 let redact_id = services
102 .tasks
103 .spawn(super::REDACT_USER_ACTION, resource_id, redact_events(services, args))
104 .to_string();
105
106 Ok(Response { redact_id })
107}
108
109async fn joined_and_banned_rooms(services: &crate::State, user_id: &UserId) -> Vec<OwnedRoomId> {
110 let joined = services
111 .state_cache
112 .rooms_joined(user_id)
113 .map(ToOwned::to_owned);
114
115 let banned = services
116 .state_cache
117 .rooms_left(user_id)
118 .map(ToOwned::to_owned)
119 .broad_filter_map(async |room_id| {
120 services
121 .state_accessor
122 .get_member(&room_id, user_id)
123 .await
124 .is_ok_and(|member| member.membership == MembershipState::Ban)
125 .then_some(room_id)
126 });
127
128 joined.chain(banned).collect().await
129}
130
131fn resolve_limit(limit: Option<UInt>) -> usize {
132 limit
133 .and_then(|limit| limit.try_into().ok())
134 .filter(|&limit| limit > 0)
135 .unwrap_or(LIMIT_DEFAULT)
136}
137
138async fn redact_events(services: crate::State, args: RedactArgs) -> Result<JsonValue> {
140 let mut failed = FailedRedactions::new();
141
142 for room_id in &args.rooms {
143 let event_ids: Vec<OwnedEventId> = services
144 .timeline
145 .pdus_rev(None, room_id, None)
146 .ready_try_filter(|(_, pdu)| is_candidate(pdu, &args))
147 .take(args.limit)
148 .ready_try_filter(|(_, pdu)| is_eligible(pdu))
149 .map_ok(|(_, pdu)| pdu.event_id)
150 .try_collect()
151 .await?;
152
153 for event_id in event_ids {
154 if let Err(e) = redact_one(&services, room_id, &event_id, &args).await {
155 failed.insert(event_id, e.to_string());
156 }
157 }
158 }
159
160 Ok(task_result(&failed))
161}
162
163fn is_candidate(pdu: &PduEvent, args: &RedactArgs) -> bool {
164 pdu.sender == args.user_id
165 && args
166 .before_ts
167 .is_none_or(|ts| pdu.origin_server_ts() <= ts)
168 && args
169 .after_ts
170 .is_none_or(|ts| pdu.origin_server_ts() >= ts)
171 && matches!(
172 pdu.kind,
173 TimelineEventType::RoomMember
174 | TimelineEventType::RoomMessage
175 | TimelineEventType::RoomEncrypted
176 )
177}
178
179fn is_eligible(pdu: &PduEvent) -> bool {
180 !pdu.is_redacted()
181 && (pdu.kind != TimelineEventType::RoomMember
182 || pdu
183 .get_content()
184 .is_ok_and(|member: RoomMemberEventContent| {
185 member.membership == MembershipState::Join
186 }))
187}
188
189async fn redact_one(
190 services: &crate::State,
191 room_id: &RoomId,
192 event_id: &EventId,
193 args: &RedactArgs,
194) -> Result<()> {
195 if !services
196 .state_accessor
197 .user_can_redact(event_id, &args.redact_as, room_id, false)
198 .await?
199 {
200 return Err!(Request(Forbidden(
201 "The redactor lacks the redaction power level in this room."
202 )));
203 }
204
205 let state_lock = services.state.mutex.lock(room_id).await;
206
207 services
208 .timeline
209 .build_and_append_pdu(
210 PduBuilder {
211 redacts: Some(event_id.to_owned()),
212 ..PduBuilder::timeline(&RoomRedactionEventContent {
213 redacts: Some(event_id.to_owned()),
214 reason: args.reason.clone(),
215 })
216 },
217 &args.redact_as,
218 room_id,
219 &state_lock,
220 )
221 .await
222 .map(|_| ())
223}
224
225fn task_result(failed_redactions: &FailedRedactions) -> JsonValue {
226 serde_json::json!({ "failed_redactions": failed_redactions })
227}
228
229#[cfg(test)]
230mod tests {
231 use ruma::{MilliSecondsSinceUnixEpoch, UInt, event_id, uint, user_id};
232 use serde_json::json;
233
234 use super::{
235 FailedRedactions, PduEvent, RedactArgs, is_candidate, is_eligible, resolve_limit,
236 task_result,
237 };
238
239 fn args(before_ts: Option<UInt>, after_ts: Option<UInt>) -> RedactArgs {
240 RedactArgs {
241 user_id: user_id!("@alice:example.com").to_owned(),
242 rooms: Vec::new(),
243 redact_as: user_id!("@alice:example.com").to_owned(),
244 reason: None,
245 limit: super::LIMIT_DEFAULT,
246 before_ts: before_ts.map(MilliSecondsSinceUnixEpoch),
247 after_ts: after_ts.map(MilliSecondsSinceUnixEpoch),
248 }
249 }
250
251 fn pdu(kind: &str, sender: &str, ts: u64, content: &serde_json::Value) -> PduEvent {
252 pdu_with_unsigned(kind, sender, ts, content, &json!({}))
253 }
254
255 fn pdu_with_unsigned(
256 kind: &str,
257 sender: &str,
258 ts: u64,
259 content: &serde_json::Value,
260 unsigned: &serde_json::Value,
261 ) -> PduEvent {
262 serde_json::from_value(json!({
263 "type": kind,
264 "content": content,
265 "event_id": "$e:example.com",
266 "room_id": "!room:example.com",
267 "sender": sender,
268 "prev_events": ["$prev:example.com"],
269 "auth_events": ["$auth:example.com"],
270 "origin_server_ts": ts,
271 "depth": 12,
272 "hashes": { "sha256": "thishashcoversallfieldsincasethisisredacted" },
273 "unsigned": unsigned,
274 }))
275 .expect("valid pdu")
276 }
277
278 fn redacted_message() -> PduEvent {
279 pdu_with_unsigned(
280 "m.room.message",
281 "@alice:example.com",
282 1000,
283 &json!({}),
284 &json!({ "redacted_because": {} }),
285 )
286 }
287
288 #[test]
289 fn resolve_limit_defaults_zero_and_absent_to_1000() {
290 assert_eq!(resolve_limit(None), super::LIMIT_DEFAULT);
291 assert_eq!(resolve_limit(Some(uint!(0))), super::LIMIT_DEFAULT);
292 assert_eq!(resolve_limit(Some(uint!(25))), 25);
293 }
294
295 #[test]
296 fn task_result_always_carries_the_failed_redactions_key() {
297 assert_eq!(task_result(&FailedRedactions::new()), json!({ "failed_redactions": {} }));
298
299 let one: FailedRedactions =
300 [(event_id!("$f:example.com").to_owned(), "boom".to_owned())].into();
301
302 let value = task_result(&one);
303
304 assert_eq!(value, json!({ "failed_redactions": { "$f:example.com": "boom" } }));
305
306 let parsed: FailedRedactions = serde_json::from_value(value["failed_redactions"].clone())
307 .expect("failed_redactions round-trips");
308
309 assert_eq!(parsed.len(), 1);
310 assert!(parsed.contains_key(event_id!("$f:example.com")));
311 }
312
313 #[test]
314 fn candidate_filters_sender_type_and_window() {
315 let args = args(Some(uint!(1500)), Some(uint!(500)));
316
317 assert!(is_candidate(
318 &pdu("m.room.message", "@alice:example.com", 1000, &json!({})),
319 &args
320 ));
321 assert!(!is_candidate(
322 &pdu("m.room.message", "@mallory:example.com", 1000, &json!({})),
323 &args
324 ));
325 assert!(!is_candidate(
326 &pdu("m.room.topic", "@alice:example.com", 1000, &json!({})),
327 &args
328 ));
329
330 assert!(is_candidate(
332 &pdu("m.room.message", "@alice:example.com", 1500, &json!({})),
333 &args
334 ));
335 assert!(is_candidate(
336 &pdu("m.room.message", "@alice:example.com", 500, &json!({})),
337 &args
338 ));
339 assert!(!is_candidate(
340 &pdu("m.room.message", "@alice:example.com", 1501, &json!({})),
341 &args
342 ));
343 assert!(!is_candidate(
344 &pdu("m.room.message", "@alice:example.com", 499, &json!({})),
345 &args
346 ));
347 }
348
349 #[test]
350 fn eligible_keeps_joins_and_skips_redacted() {
351 let join =
352 pdu("m.room.member", "@alice:example.com", 1000, &json!({ "membership": "join" }));
353
354 let leave =
355 pdu("m.room.member", "@alice:example.com", 1000, &json!({ "membership": "leave" }));
356
357 let invite =
358 pdu("m.room.member", "@alice:example.com", 1000, &json!({ "membership": "invite" }));
359
360 let message = pdu("m.room.message", "@alice:example.com", 1000, &json!({}));
361
362 assert!(is_eligible(&join));
363 assert!(!is_eligible(&leave));
364 assert!(!is_eligible(&invite));
365 assert!(is_eligible(&message));
366 assert!(!is_eligible(&redacted_message()));
367 }
368}