Skip to main content

tuwunel_api_client/report/
report_event.rs

1use std::borrow::Cow;
2
3use axum::extract::State;
4use futures::{StreamExt, TryFutureExt, pin_mut};
5use ruma::{
6	EventId, RoomAliasId, RoomId, UserId,
7	api::client::room::report_content,
8	events::{
9		StateEventType,
10		room::{create::RoomCreateEventContent, message::RoomMessageEventContent},
11	},
12	room::RoomType,
13};
14use serde::Deserialize;
15use tuwunel_core::{
16	Err, Result, debug_error, debug_info, info,
17	utils::{ReadyExt, html::escape, string::single_line},
18};
19use tuwunel_matrix::{Event, pdu::PduEvent};
20use tuwunel_service::Services;
21
22use super::REASON_MAX_LEN;
23use crate::{ClientIp, Ruma};
24
25#[cfg(test)]
26mod tests;
27
28const DETAIL_MAX_CHARS: usize = 500;
29
30struct Report<'a> {
31	reporter: &'a UserId,
32	reason: &'a str,
33	event_id: &'a EventId,
34	room_id: &'a RoomId,
35	room_type: &'a str,
36	room_alias: Option<&'a RoomAliasId>,
37	body: Option<&'a str>,
38	sender: &'a UserId,
39	sender_is_local: bool,
40}
41
42#[derive(Deserialize)]
43struct Content {
44	body: Option<String>,
45}
46
47/// # `POST /_matrix/client/v3/rooms/{roomId}/report/{eventId}`
48///
49/// Reports an inappropriate event to homeserver admins
50#[tracing::instrument(skip_all, fields(%client), name = "report_event")]
51pub(crate) async fn report_event_route(
52	State(services): State<crate::State>,
53	ClientIp(client): ClientIp,
54	body: Ruma<report_content::v3::Request>,
55) -> Result<report_content::v3::Response> {
56	let sender_user = body.sender_user();
57	let reason = body.reason.as_deref().unwrap_or("");
58
59	info!(
60		"Received event report by user {sender_user} for room {} and event ID {}, with reason: \
61		 \"{}\"",
62		body.room_id, body.event_id, reason,
63	);
64
65	// check if we know about the reported event ID or if it's invalid
66	let Ok(pdu) = services.timeline.get_pdu(&body.event_id).await else {
67		return Err!(Request(NotFound("Event ID is not known to us or Event ID is invalid")));
68	};
69
70	is_event_report_valid(
71		&services,
72		&pdu.event_id,
73		&body.room_id,
74		sender_user,
75		body.reason.as_ref(),
76		&pdu,
77	)
78	.await?;
79
80	let room_type = services
81		.state_accessor
82		.room_state_get_content(&pdu.room_id, &StateEventType::RoomCreate, "")
83		.map_ok(|content: RoomCreateEventContent| content.room_type)
84		.inspect_err(|error| debug_error!(%error, "Failed to read reported room type"))
85		.await;
86
87	let room_type = room_type
88		.as_ref()
89		.map(|kind| kind.as_ref().map_or("regular", RoomType::as_str))
90		.unwrap_or("unavailable");
91
92	let body = pdu
93		.get_content()
94		.ok()
95		.and_then(|content: Content| content.body);
96
97	let report = {
98		let aliases = services
99			.alias
100			.local_aliases_for_room(&pdu.room_id);
101
102		pin_mut!(aliases);
103		let room_alias = aliases.next().await;
104
105		format_report(&Report {
106			reporter: sender_user,
107			reason,
108			event_id: &pdu.event_id,
109			room_id: &pdu.room_id,
110			room_type,
111			room_alias,
112			body: body.as_deref(),
113			sender: &pdu.sender,
114			sender_is_local: services.globals.user_is_local(&pdu.sender),
115		})
116	};
117
118	services.admin.send_report_content(report).await;
119
120	Ok(report_content::v3::Response {})
121}
122
123/// in the following order:
124///
125/// check if the room ID from the URI matches the PDU's room ID
126/// check if report reasoning is less than or equal to 750 characters
127/// check if reporting user is in the reporting room
128async fn is_event_report_valid(
129	services: &Services,
130	event_id: &EventId,
131	room_id: &RoomId,
132	sender_user: &UserId,
133	reason: Option<&String>,
134	pdu: &PduEvent,
135) -> Result {
136	debug_info!(
137		"Checking if report from user {sender_user} for event {event_id} in room {room_id} is \
138		 valid"
139	);
140
141	if room_id != pdu.room_id {
142		return Err!(Request(NotFound("Event ID does not belong to the reported room",)));
143	}
144
145	if reason
146		.as_ref()
147		.is_some_and(|s| s.len() > REASON_MAX_LEN)
148	{
149		return Err!(Request(InvalidParam(
150			"Reason too long, should be {REASON_MAX_LEN} characters or fewer"
151		)));
152	}
153
154	if !services
155		.state_cache
156		.room_members(room_id)
157		.ready_any(|user_id| user_id == sender_user)
158		.await
159	{
160		return Err!(Request(NotFound("You are not in the room you are reporting.",)));
161	}
162
163	Ok(())
164}
165
166fn format_report(report: &Report<'_>) -> RoomMessageEventContent {
167	let event_id = single_line(report.event_id.as_str());
168	let room_alias = report
169		.room_alias
170		.map_or("none", RoomAliasId::as_str);
171
172	let (redact_hint, redact_id) = if report.sender_is_local {
173		("\n\nTo redact it: !admin users redact-event ", event_id.as_ref())
174	} else {
175		("", "")
176	};
177
178	let body = format!(
179		"@room Event report received from {}\nReport Reason: {}\n\nEvent ID: {}\nRoom ID: \
180		 {}\nRoom Type: {}\nRoom Alias: {}\nSent By: {}\nMessage: {}{}{}",
181		single_line(report.reporter.as_str()),
182		single_line(report.reason),
183		event_id,
184		single_line(report.room_id.as_str()),
185		excerpt(report.room_type),
186		excerpt(room_alias),
187		single_line(report.sender.as_str()),
188		excerpt(report.body.unwrap_or("unavailable")),
189		redact_hint,
190		redact_id,
191	);
192
193	let html = escape(&body).replace('\n', "<br>");
194
195	RoomMessageEventContent::text_html(body, html)
196}
197
198fn excerpt(value: &str) -> Cow<'_, str> {
199	let text = value
200		.char_indices()
201		.nth(DETAIL_MAX_CHARS)
202		.map_or(value, |(end, _)| value.split_at(end).0);
203
204	single_line(text)
205}