Skip to main content

tuwunel_api/client/admin/users/
redact.rs

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
32/// Synapse defaults an omitted or zero limit to 1000 events per room.
33const 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
47/// # `POST /_synapse/admin/v1/user/{user_id}/redact`
48///
49/// Schedules a background task redacting the user's messages, encrypted events,
50/// and join events in the given rooms (all joined and banned rooms when empty),
51/// returning the task id for the redact-status endpoint.
52pub(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
138// The detached task needs its own static services handle.
139async 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		// The window is inclusive at both bounds.
331		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}