tuwunel_api_client/report/
report_event.rs1use 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#[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 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
123async 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}