Skip to main content

tuwunel_api_client/
send.rs

1use axum::extract::State;
2use futures::future::{try_join, try_join3};
3use ruma::{
4	OwnedEventId, RoomId, UserId,
5	api::client::message::{
6		send_message_event, send_message_event::v3::Response as SendMessageResponse,
7	},
8	events::{
9		AnyMessageLikeEventContent, MessageLikeEventType,
10		reaction::ReactionEventContent,
11		room::{encrypted::Relation, redaction::RoomRedactionEventContent},
12	},
13	serde::Raw,
14};
15use serde::Deserialize;
16use serde_json::from_str;
17use tuwunel_core::{Err, Result, debug_warn, err, result::NotFound, warn};
18use tuwunel_matrix::{Event, PduEvent, pdu::PduBuilder};
19use tuwunel_service::{
20	Services,
21	transaction_ids::{RoomScope, RoomTxn},
22};
23
24use crate::{
25	Ruma,
26	utils::{is_self_redaction, txnid_event_id, txnid_unsigned},
27};
28
29#[derive(Deserialize)]
30struct ExtractRelatesTo {
31	#[serde(rename = "m.relates_to")]
32	relates_to: Relation,
33}
34
35/// # `PUT /_matrix/client/v3/rooms/{roomId}/send/{eventType}/{txnId}`
36///
37/// Send a message event into the room.
38///
39/// - Is a NOOP if the txn id was already used before and returns the same event
40///   id again
41/// - The only requirement for the content is that it has to be valid json
42/// - Tries to send the event into the room, auth rules will determine if it is
43///   allowed
44pub(crate) async fn send_message_event_route(
45	State(services): State<crate::State>,
46	body: Ruma<send_message_event::v3::Request>,
47) -> Result<send_message_event::v3::Response> {
48	let sender_user = body.sender_user();
49	let sender_device = body.sender_device.as_deref();
50	let appservice_info = body.appservice_info.as_ref();
51	let redacts_id =
52		check_send_policy(&services, sender_user, &body.event_type, &body.body.body).await?;
53
54	let state_lock = services.state.mutex.lock(&body.room_id).await;
55	let event_type = body.event_type.to_cow_str();
56	let txn = RoomTxn {
57		user_id: sender_user,
58		device_id: sender_device,
59		txn_id: &body.txn_id,
60		room_id: &body.room_id,
61	};
62
63	let content_checks = check_send_content(
64		&services,
65		sender_user,
66		&body.room_id,
67		&body.event_type,
68		&body.body.body,
69	);
70
71	let (existing_txnid, ()) =
72		try_join(check_existing_txnid(&services, txn, &event_type), content_checks).await?;
73
74	if let Some(existing_txnid) = existing_txnid {
75		return Ok(existing_txnid);
76	}
77
78	let content = from_str(body.body.body.json().get())
79		.map_err(|e| err!(Request(BadJson("Invalid JSON body: {e}"))))?;
80
81	let pdu = PduBuilder {
82		event_type: body.event_type.clone().into(),
83		content,
84		unsigned: txnid_unsigned(&body.txn_id).into(),
85		timestamp: appservice_info.and(body.timestamp),
86		redacts: redacts_id,
87		..Default::default()
88	};
89
90	let event_id = services
91		.timeline
92		.build_and_append_pdu(pdu, sender_user, &body.room_id, &state_lock)
93		.await?;
94
95	services.transaction_ids.add_room_txnid(
96		txn,
97		RoomScope::Send(&event_type),
98		event_id.as_bytes(),
99	);
100
101	drop(state_lock);
102
103	Ok(SendMessageResponse { event_id })
104}
105
106/// Applies the sender's policy for sending a message event, before the room
107/// lock.
108///
109/// Refuses encrypted events when encryption is disabled, local redactions
110/// when the server disables them, and a suspended sender's events other than
111/// self-redactions. Returns the target of a redaction for the top-level
112/// `redacts` field.
113#[tracing::instrument(level = "debug", skip_all)]
114pub(crate) async fn check_send_policy(
115	services: &Services,
116	sender_user: &UserId,
117	event_type: &MessageLikeEventType,
118	content: &Raw<AnyMessageLikeEventContent>,
119) -> Result<Option<OwnedEventId>> {
120	// Forbid m.room.encrypted if encryption is disabled
121	if *event_type == MessageLikeEventType::RoomEncrypted && !services.config.encryption.allow {
122		return Err!(Request(Forbidden("Encryption has been disabled")));
123	}
124
125	// MSC4169: clients sending m.room.redaction via /send put `redacts` in
126	// `content`. Pre-v11 auth rules read it from the top level; lift it so
127	// `redacts_id(...)` resolves regardless of room version. Mirrors the
128	// /redact handler.
129	let redaction_content = || {
130		content
131			.deserialize_as_unchecked::<RoomRedactionEventContent>()
132			.inspect_err(|_| {
133				debug_warn!(
134					%sender_user,
135					event = %content.json(),
136					"Client sent invalid redaction event"
137				);
138			})
139			.ok()
140	};
141
142	let redacts_id = event_type
143		.eq(&MessageLikeEventType::RoomRedaction)
144		.then(redaction_content)
145		.flatten()
146		.and_then(|content| content.redacts);
147
148	if *event_type == MessageLikeEventType::RoomRedaction
149		&& services.config.rooms.redaction.disable_local
150		&& !services.admin.user_is_admin(sender_user).await
151	{
152		warn!(
153			%sender_user,
154			?redacts_id,
155			"Local redactions are disabled, non-admin user attempted to redact an event"
156		);
157
158		return Err!(Request(Forbidden("Redactions are disabled on this server.")));
159	}
160
161	if services.users.is_suspended(sender_user).await {
162		if *event_type != MessageLikeEventType::RoomRedaction {
163			return Err!(Request(UserSuspended(
164				"Cannot send non-redaction events while suspended."
165			)));
166		}
167
168		let is_self = match &redacts_id {
169			| None => false,
170			| Some(redacts_id) => is_self_redaction(services, sender_user, redacts_id).await,
171		};
172
173		if !is_self {
174			return Err!(Request(UserSuspended("Can only redact own events while suspended.")));
175		}
176	}
177
178	Ok(redacts_id)
179}
180
181/// Applies the room's content rules for sending a message event, under the
182/// room lock.
183///
184/// Refuses a duplicate reaction, a call invite in a public room, and a thread
185/// rooted at an event that itself carries a relation.
186#[tracing::instrument(level = "debug", skip_all)]
187pub(crate) async fn check_send_content(
188	services: &Services,
189	sender_user: &UserId,
190	room_id: &RoomId,
191	event_type: &MessageLikeEventType,
192	content: &Raw<AnyMessageLikeEventContent>,
193) -> Result {
194	try_join3(
195		check_duplicate_reaction(services, event_type, sender_user, content),
196		check_public_call_invite(services, event_type, room_id),
197		check_nested_thread(services, content),
198	)
199	.await
200	.map(|((), (), ())| ())
201}
202
203async fn check_public_call_invite(
204	services: &Services,
205	event_type: &MessageLikeEventType,
206	room_id: &RoomId,
207) -> Result {
208	if *event_type != MessageLikeEventType::CallInvite {
209		return Ok(());
210	}
211
212	if !services.directory.is_public_room(room_id).await {
213		return Ok(());
214	}
215
216	Err!(Request(Forbidden("Room call invites are not allowed in public rooms")))
217}
218
219// Forbid duplicate reactions
220async fn check_duplicate_reaction(
221	services: &Services,
222	event_type: &MessageLikeEventType,
223	sender_user: &UserId,
224	body: &Raw<AnyMessageLikeEventContent>,
225) -> Result {
226	if *event_type != MessageLikeEventType::Reaction {
227		return Ok(());
228	}
229
230	let Ok(content) = body.deserialize_as_unchecked::<ReactionEventContent>() else {
231		return Ok(());
232	};
233
234	if !services
235		.pdu_metadata
236		.event_has_relation(
237			&content.relates_to.event_id,
238			Some(sender_user),
239			None,
240			Some(&content.relates_to.key),
241		)
242		.await
243	{
244		return Ok(());
245	}
246
247	Err!(Request(DuplicateAnnotation("Duplicate reactions are not allowed.")))
248}
249
250// MSC3440/Matrix 1.4: a thread may only target an event which itself carries
251// no rel_type; the spec assigns this rejection 400 M_UNKNOWN.
252async fn check_nested_thread(
253	services: &Services,
254	body: &Raw<AnyMessageLikeEventContent>,
255) -> Result {
256	let Ok(ExtractRelatesTo { relates_to: Relation::Thread(thread) }) =
257		body.deserialize_as_unchecked()
258	else {
259		return Ok(());
260	};
261
262	let Ok(root) = services.timeline.get_pdu(&thread.event_id).await else {
263		return Ok(());
264	};
265
266	let nested = root
267		.get_content()
268		.is_ok_and(|content: ExtractRelatesTo| content.relates_to.rel_type().is_some());
269
270	if !nested {
271		return Ok(());
272	}
273
274	Err!(Request(Unknown("Cannot start threads from an event with a relation.")))
275}
276
277/// Check if this is a new transaction id.
278///
279/// Returns Some when the transaction id exists and the send must then be
280/// terminated by returning the contained result.
281async fn check_existing_txnid(
282	services: &Services,
283	txn: RoomTxn<'_>,
284	event_type: &str,
285) -> Result<Option<SendMessageResponse>> {
286	let scope = RoomScope::Send(event_type);
287	let response = services
288		.transaction_ids
289		.existing_room_txnid(txn, scope)
290		.await;
291
292	if let Some(response) = response.optional()? {
293		return txnid_response(&response).map(Some);
294	}
295
296	let response = services
297		.transaction_ids
298		.existing_txnid(txn.user_id, txn.device_id, txn.txn_id)
299		.await;
300
301	let Some(response) = response.optional()? else {
302		return Ok(None);
303	};
304
305	let Some(response) = legacy_txnid_response(&response)? else {
306		return Ok(None);
307	};
308
309	let event_id = &response.event_id;
310	let Some(pdu) = services
311		.timeline
312		.get_non_outlier_pdu(event_id)
313		.await
314		.optional()?
315	else {
316		return Ok(None);
317	};
318
319	if !legacy_txnid_matches(&pdu, txn.room_id, event_type, txn.user_id) {
320		return Ok(None);
321	}
322
323	services
324		.transaction_ids
325		.add_room_txnid(txn, scope, event_id.as_bytes());
326
327	Ok(Some(response))
328}
329
330fn txnid_response(response: &[u8]) -> Result<SendMessageResponse> {
331	txnid_event_id(response).map(SendMessageResponse::new)
332}
333
334fn legacy_txnid_response(response: &[u8]) -> Result<Option<SendMessageResponse>> {
335	if response.is_empty() {
336		return Ok(None);
337	}
338
339	txnid_response(response).map(Some)
340}
341
342fn legacy_txnid_matches(
343	pdu: &PduEvent,
344	room_id: &RoomId,
345	event_type: &str,
346	sender_user: &UserId,
347) -> bool {
348	pdu.room_id == room_id && pdu.kind.to_cow_str() == event_type && pdu.sender == sender_user
349}
350
351#[cfg(test)]
352mod tests {
353	use ruma::{event_id, room_id, user_id};
354	use serde_json::json;
355
356	use super::{PduEvent, legacy_txnid_matches, legacy_txnid_response};
357
358	#[test]
359	fn legacy_response_requires_a_valid_nonempty_event_id() {
360		assert!(
361			legacy_txnid_response(b"")
362				.expect("empty marker is valid")
363				.is_none()
364		);
365
366		legacy_txnid_response(b"not an event ID").expect_err("invalid event ID");
367		legacy_txnid_response(&[0xFF]).expect_err("invalid UTF-8");
368
369		let response = legacy_txnid_response(b"$event:example.com")
370			.expect("valid event ID")
371			.expect("nonempty response");
372
373		assert_eq!(response.event_id, event_id!("$event:example.com"));
374	}
375
376	#[test]
377	fn legacy_response_requires_matching_provenance() {
378		let pdu = pdu();
379		assert!(legacy_txnid_matches(
380			&pdu,
381			room_id!("!room:example.com"),
382			"m.room.message",
383			user_id!("@alice:example.com"),
384		));
385
386		assert!(!legacy_txnid_matches(
387			&pdu,
388			room_id!("!other:example.com"),
389			"m.room.message",
390			user_id!("@alice:example.com"),
391		));
392
393		assert!(!legacy_txnid_matches(
394			&pdu,
395			room_id!("!room:example.com"),
396			"m.room.encrypted",
397			user_id!("@alice:example.com"),
398		));
399
400		assert!(!legacy_txnid_matches(
401			&pdu,
402			room_id!("!room:example.com"),
403			"m.room.message",
404			user_id!("@bob:example.com"),
405		));
406	}
407
408	fn pdu() -> PduEvent {
409		serde_json::from_value(json!({
410			"type": "m.room.message",
411			"content": {},
412			"event_id": "$event:example.com",
413			"room_id": "!room:example.com",
414			"sender": "@alice:example.com",
415			"prev_events": ["$prev:example.com"],
416			"auth_events": ["$auth:example.com"],
417			"origin_server_ts": 1,
418			"depth": 1,
419			"hashes": { "sha256": "thishashcoversallfieldsincasethisisredacted" },
420		}))
421		.expect("valid PDU")
422	}
423}