Skip to main content

tuwunel_service/admin/
respond.rs

1use futures::{FutureExt, TryStreamExt};
2use ruma::{
3	EventId, OwnedEventId, RoomId, UserId,
4	events::{
5		relation::{InReplyTo, Reply as ReplyRelation, Thread},
6		room::{
7			encrypted::Relation as EncryptedRelation,
8			message::{
9				Relation, RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
10			},
11		},
12	},
13};
14use serde::Deserialize;
15use tuwunel_core::{
16	Error, Event, Result, error,
17	error::default_log,
18	implement,
19	pdu::{MAX_PDU_BYTES, PduBuilder},
20	utils::{json::serialized_len, stream::IterStream, string::chunk},
21};
22
23use super::CommandOutput;
24use crate::rooms::state::RoomMutexGuard;
25
26/// Room event relation carried by an admin response.
27type MessageRelation = Relation<RoomMessageEventContentWithoutRelation>;
28
29/// Envelope overhead reserved above the message content: prev and auth event
30/// ids at their maximum, 255-byte sender and room ids, and the signatures
31/// block, just under 10 KiB in the worst legal case.
32const EVENT_RESERVE: usize = 10_240;
33
34/// Largest serialized message content that still fits a single event.
35const CONTENT_BUDGET: usize = MAX_PDU_BYTES - EVENT_RESERVE;
36
37/// Serialized size reserved for an `m.relates_to` relation carrying a
38/// maximum-length event id, so a segment measured without its relation still
39/// fits once the relation is attached.
40const RELATION_RESERVE: usize = 512;
41
42/// Largest serialized segment content, before its relation, that fits an event.
43const SEGMENT_BUDGET: usize = CONTENT_BUDGET - RELATION_RESERVE;
44
45/// How a command's output events relate back to the command event.
46enum Mode {
47	/// Thread children rooted at the given event (the command or its own
48	/// thread root).
49	Thread(OwnedEventId),
50
51	/// Replies: the first to the command, each subsequent to the one before it.
52	Reply(OwnedEventId),
53}
54
55#[derive(Deserialize)]
56struct ExtractRelatesTo {
57	#[serde(rename = "m.relates_to")]
58	relates_to: EncryptedRelation,
59}
60
61#[implement(super::Service)]
62pub(super) async fn handle_response(
63	&self,
64	output: CommandOutput,
65	reply_id: Option<&EventId>,
66) -> Result {
67	let Some(reply_id) = reply_id else {
68		return Ok(());
69	};
70
71	let Ok(pdu) = self.services.timeline.get_pdu(reply_id).await else {
72		error!(?reply_id, "Missing admin command in_reply_to event");
73		return Ok(());
74	};
75
76	let response_sender = if self.is_admin_room(pdu.room_id()).await {
77		&self.services.globals.server_user
78	} else {
79		pdu.sender()
80	};
81
82	let threads = self.services.server.config.admin_output_threads;
83	let mode = command_thread(&pdu)
84		.or_else(|| threads.then(|| reply_id.to_owned()))
85		.map_or_else(|| Mode::Reply(reply_id.to_owned()), Mode::Thread);
86
87	self.respond(&output, pdu.room_id(), response_sender, &mode)
88		.boxed()
89		.await
90}
91
92/// Splits the output across up to `admin_output_max_events` reply or thread
93/// events; output needing more events, or any output when the limit is zero,
94/// is uploaded and posted as a single file attachment instead.
95#[implement(super::Service)]
96async fn respond(
97	&self,
98	output: &CommandOutput,
99	room_id: &RoomId,
100	sender: &UserId,
101	mode: &Mode,
102) -> Result {
103	let markdown = matches!(output, CommandOutput::Markdown(_));
104	let max_events = self
105		.services
106		.server
107		.config
108		.admin_output_max_events;
109
110	let segments = (max_events != 0)
111		.then(|| {
112			chunk(output.as_str(), markdown, |text: &str| fits_segment(text, markdown))
113				.take(max_events.saturating_add(1))
114				.collect::<Vec<_>>()
115		})
116		.filter(|segments| segments.len() <= max_events);
117
118	match segments {
119		| Some(segments) =>
120			self.send_segments(&segments, room_id, sender, mode, markdown)
121				.boxed()
122				.await,
123		| None => {
124			let mut content = self.attach(output).await.unwrap_or_else(|e| {
125				error!(%e, "Failed to attach oversized admin command output");
126				notice(
127					&format!(
128						"Failed to attach command output: \"{e}\"\n\nThe original admin command \
129						 may have finished successfully, but we could not return the output."
130					),
131					markdown,
132				)
133			});
134
135			content.relates_to = Some(mode.relation(None));
136
137			self.respond_to_room(content, room_id, sender)
138				.boxed()
139				.await
140		},
141	}
142}
143
144/// Appends every segment under one room lock, chaining each reply to the event
145/// id returned by the previous append.
146#[implement(super::Service)]
147async fn send_segments(
148	&self,
149	segments: &[String],
150	room_id: &RoomId,
151	sender: &UserId,
152	mode: &Mode,
153	markdown: bool,
154) -> Result {
155	assert!(self.user_is_admin(sender).await, "sender is not admin");
156
157	let state_lock = self.services.state.mutex.lock(room_id).await;
158
159	let result = segments
160		.iter()
161		.try_stream()
162		.try_fold(None, async |previous: Option<OwnedEventId>, segment| {
163			let mut content = notice(segment, markdown);
164			content.relates_to = Some(mode.relation(previous.as_deref()));
165
166			self.services
167				.timeline
168				.build_and_append_pdu(
169					PduBuilder::timeline(&content),
170					sender,
171					room_id,
172					&state_lock,
173				)
174				.await
175				.map(Some)
176		})
177		.await;
178
179	if let Err(e) = result {
180		self.handle_response_error(e, room_id, sender, &state_lock)
181			.boxed()
182			.await
183			.unwrap_or_else(default_log);
184	}
185
186	Ok(())
187}
188
189#[implement(super::Service)]
190pub(super) async fn respond_to_room(
191	&self,
192	content: RoomMessageEventContent,
193	room_id: &RoomId,
194	user_id: &UserId,
195) -> Result {
196	assert!(self.user_is_admin(user_id).await, "sender is not admin");
197
198	let state_lock = self.services.state.mutex.lock(room_id).await;
199
200	if let Err(e) = self
201		.services
202		.timeline
203		.build_and_append_pdu(PduBuilder::timeline(&content), user_id, room_id, &state_lock)
204		.await
205	{
206		self.handle_response_error(e, room_id, user_id, &state_lock)
207			.boxed()
208			.await
209			.unwrap_or_else(default_log);
210	}
211
212	Ok(())
213}
214
215#[implement(super::Service)]
216async fn handle_response_error(
217	&self,
218	e: Error,
219	room_id: &RoomId,
220	user_id: &UserId,
221	state_lock: &RoomMutexGuard,
222) -> Result {
223	error!(%e, "Failed to build and append admin room response PDU");
224	let content = RoomMessageEventContent::text_plain(format!(
225		"Failed to build and append admin room PDU: \"{e}\"\n\nThe original admin command may \
226		 have finished successfully, but we could not return the output."
227	));
228
229	self.services
230		.timeline
231		.build_and_append_pdu(PduBuilder::timeline(&content), user_id, room_id, state_lock)
232		.boxed()
233		.await?;
234
235	Ok(())
236}
237
238/// The thread root of the command event when it was itself sent inside a
239/// thread, so the output joins that thread rather than starting a new relation.
240fn command_thread(pdu: &impl Event) -> Option<OwnedEventId> {
241	pdu.get_content()
242		.ok()
243		.and_then(|content: ExtractRelatesTo| match content.relates_to {
244			| EncryptedRelation::Thread(thread) => Some(thread.event_id),
245			| _ => None,
246		})
247}
248
249fn fits_segment(text: &str, markdown: bool) -> bool {
250	serialized_len(&notice(text, markdown)).is_ok_and(|len| len <= SEGMENT_BUDGET)
251}
252
253impl Mode {
254	/// The relation for the next event: a thread child of the root, or a reply
255	/// to the previous segment when there is one, else to the command.
256	fn relation(&self, previous: Option<&EventId>) -> MessageRelation {
257		match self {
258			| Self::Thread(root) => thread_relation(root),
259			| Self::Reply(command) => reply_relation(previous.unwrap_or(command)),
260		}
261	}
262}
263
264fn notice(text: &str, markdown: bool) -> RoomMessageEventContent {
265	match markdown {
266		| true => RoomMessageEventContent::notice_markdown(text),
267		| false => RoomMessageEventContent::notice_plain(text),
268	}
269}
270
271fn thread_relation(root: &EventId) -> MessageRelation {
272	Relation::Thread(Thread::without_fallback(root.to_owned()))
273}
274
275fn reply_relation(event_id: &EventId) -> MessageRelation {
276	Relation::Reply(ReplyRelation {
277		in_reply_to: InReplyTo { event_id: event_id.to_owned() },
278	})
279}
280
281#[cfg(test)]
282mod tests {
283	use ruma::{EventId, ID_MAX_BYTES};
284
285	use super::{RELATION_RESERVE, notice, reply_relation, thread_relation};
286
287	/// The reserve must cover the largest relation (a maximum-length event id),
288	/// so a segment measured without its relation still fits once attached.
289	#[test]
290	fn relation_reserve_covers_max_length_event_id() {
291		let raw = format!("${}", "a".repeat(ID_MAX_BYTES.saturating_sub(1)));
292		let event_id = <&EventId>::try_from(raw.as_str()).expect("valid max-length event id");
293		let bare = serde_json::to_string(&notice("body", true))
294			.expect("serialize")
295			.len();
296
297		for relation in [reply_relation(event_id), thread_relation(event_id)] {
298			let mut content = notice("body", true);
299			content.relates_to = Some(relation);
300
301			let full = serde_json::to_string(&content)
302				.expect("serialize")
303				.len();
304
305			assert!(full.saturating_sub(bare) <= RELATION_RESERVE);
306		}
307	}
308}