Skip to main content

tuwunel_api_admin/misc/
send_server_notice.rs

1use axum::extract::State;
2use futures::{FutureExt, StreamExt, TryFutureExt, TryStreamExt, future::ready};
3use ruma::{
4	DeviceId, OwnedEventId, OwnedRoomId, RoomId, RoomVersionId, TransactionId, UserId,
5	events::{
6		room::{
7			create::RoomCreateEventContent,
8			guest_access::{GuestAccess, RoomGuestAccessEventContent},
9			history_visibility::{HistoryVisibility, RoomHistoryVisibilityEventContent},
10			join_rules::{JoinRule, RoomJoinRulesEventContent},
11			member::{MembershipState, RoomMemberEventContent},
12			message::RoomMessageEventContent,
13			name::RoomNameEventContent,
14			power_levels::RoomPowerLevelsEventContent,
15		},
16		tag::TagName,
17	},
18	serde::Raw,
19};
20use synapse_admin_api::server_notices::send::{
21	by_txn,
22	v1::{self, Response},
23};
24use tuwunel_api_client::{notice_tag, room_is_notice};
25use tuwunel_core::{
26	Err, Result, err,
27	utils::{
28		BoolExt, FutureBoolExt,
29		future::ReadyBoolExt,
30		str_from_bytes,
31		stream::{IterStream, ReadyExt},
32	},
33};
34use tuwunel_matrix::pdu::PduBuilder;
35use tuwunel_service::Services;
36
37use crate::RumaAdmin;
38
39/// Sends a notice through `POST /_synapse/admin/v1/send_server_notice`.
40///
41/// Sends a server notice into the target user's system room, creating the room
42/// on demand, and returns the sent event's ID.
43/// Server notices are always enabled because tuwunel has no separate
44/// enablement setting.
45pub(crate) async fn admin_send_server_notice_route(
46	State(services): State<crate::State>,
47	body: RumaAdmin<v1::Request>,
48) -> Result<Response> {
49	let request = body.body;
50
51	send_notice(
52		&services,
53		&request.user_id,
54		request.event_type.as_deref(),
55		request.state_key.as_deref(),
56		request.content,
57	)
58	.map_ok(Response::new)
59	.await
60}
61
62/// Sends a notice through `PUT /_synapse/admin/v1/send_server_notice/{txn_id}`.
63///
64/// Sends a server notice once for each transaction ID and returns the recorded
65/// event ID on replay.
66pub(crate) async fn admin_send_server_notice_txn_route(
67	State(services): State<crate::State>,
68	body: RumaAdmin<by_txn::Request>,
69) -> Result<Response> {
70	let sender_user = body
71		.sender_user
72		.expect("user must be authenticated for this handler");
73
74	let sender_device = body.sender_device;
75	let request = body.body;
76
77	check_existing_txnid(&services, &sender_user, sender_device.as_deref(), &request.txn_id)
78		.await
79		.map(|response| ready(response).right_future())
80		.unwrap_or_else(|| {
81			send_notice_txn(&services, &sender_user, sender_device.as_deref(), request)
82				.left_future()
83		})
84		.await
85}
86
87/// Sends a new transaction and records its event ID for subsequent retries.
88///
89/// The caller checks administrator authorization and existing transactions first.
90async fn send_notice_txn(
91	services: &Services,
92	sender_user: &UserId,
93	sender_device: Option<&DeviceId>,
94	request: by_txn::Request,
95) -> Result<Response> {
96	let event_id = send_notice(
97		services,
98		&request.user_id,
99		request.event_type.as_deref(),
100		request.state_key.as_deref(),
101		request.content,
102	)
103	.await?;
104
105	services.transaction_ids.add_txnid(
106		sender_user,
107		sender_device,
108		&request.txn_id,
109		event_id.as_bytes(),
110	);
111
112	Ok(Response::new(event_id))
113}
114
115/// Sends an event from the server identity into the target's notice room.
116///
117/// Reuses prior rooms and invites recipients who are neither joined nor invited.
118/// Membership and notice events are appended under the same room lock.
119async fn send_notice(
120	services: &Services,
121	target: &UserId,
122	event_type: Option<&str>,
123	state_key: Option<&str>,
124	content: Raw<RoomMessageEventContent>,
125) -> Result<OwnedEventId> {
126	if !services.globals.user_is_local(target) {
127		return Err!(Request(InvalidParam("Server notices can only be sent to local users")));
128	}
129
130	if !services.users.exists(target).await {
131		return Err!(Request(NotFound("User not found")));
132	}
133
134	let room_id = find_notice_room(services, target)
135		.then(|room| {
136			room.map(|room_id| ready(Ok(room_id)).right_future())
137				.unwrap_or_else(|| {
138					create_notice_room(services, target)
139						.boxed() // Cold room-creation layout cut.
140						.left_future()
141				})
142		})
143		.await?;
144
145	let server_user = services.globals.server_user.as_ref();
146	let state_lock = services.state.mutex.lock(&room_id).await;
147
148	let is_joined = services.state_cache.is_joined(target, &room_id);
149	let is_invited = services.state_cache.is_invited(target, &room_id);
150	let needs_invite = is_joined.is_false().and(is_invited.is_false());
151
152	if needs_invite.await {
153		let pdu = PduBuilder::state(
154			target.as_str(),
155			&RoomMemberEventContent::new(MembershipState::Invite),
156		);
157
158		services
159			.timeline
160			.build_and_append_pdu(pdu, server_user, &room_id, &state_lock)
161			.boxed() // Cold invitation layout cut.
162			.await?;
163	}
164
165	let content = Raw::from_raw_value(content.json());
166
167	let pdu = PduBuilder {
168		event_type: event_type.unwrap_or("m.room.message").into(),
169		content,
170		state_key: state_key.map(Into::into),
171		..Default::default()
172	};
173
174	let event_id = services
175		.timeline
176		.build_and_append_pdu(pdu, server_user, &room_id, &state_lock)
177		.await?;
178
179	drop(state_lock);
180
181	Ok(event_id)
182}
183
184/// Finds the first marked room in joined, invited, then left membership order.
185///
186/// Owns each cursor item before awaiting marker reads and stops at the first
187/// match without collecting candidate rooms.
188#[tracing::instrument(level = "trace", skip_all)]
189async fn find_notice_room(services: &Services, target: &UserId) -> Option<OwnedRoomId> {
190	let server_user = services.globals.server_user.as_ref();
191	let admin_room = services
192		.admin
193		.get_admin_room()
194		.map(Result::ok)
195		.await;
196
197	let tag = notice_tag(&services.config.admin.room.tag);
198
199	services
200		.state_cache
201		.get_shared_rooms(server_user, target)
202		.map(ToOwned::to_owned)
203		.chain(
204			services
205				.state_cache
206				.rooms_invited(target)
207				.map(ToOwned::to_owned),
208		)
209		.chain(
210			services
211				.state_cache
212				.rooms_left(target)
213				.map(ToOwned::to_owned),
214		)
215		.ready_filter(|room_id| admin_room.as_ref() != Some(room_id))
216		.filter_map(|room_id| notice_candidate(services, target, &tag, room_id))
217		.take(1)
218		.ready_fold(None, |_, room_id| Some(room_id))
219		.await
220}
221
222/// Retains a candidate room only when its notice marker can be verified.
223///
224/// A named future keeps the borrowed membership stream compatible with Send.
225#[tracing::instrument(level = "trace", skip_all)]
226async fn notice_candidate(
227	services: &Services,
228	target: &UserId,
229	tag: &TagName,
230	room_id: OwnedRoomId,
231) -> Option<OwnedRoomId> {
232	room_is_notice(services, &services.globals.server_user, target, tag, &room_id)
233		.map(|notice| notice.is_ok_and(|notice| notice))
234		.await
235		.then_some(room_id)
236}
237
238/// Creates a private room whose recipient can read notices but cannot post.
239///
240/// Initial state is appended in authorization order before the target's room tag
241/// is written. Invitation is left to the caller after the room lock is released.
242#[tracing::instrument(level = "debug", skip_all)]
243async fn create_notice_room(services: &Services, target: &UserId) -> Result<OwnedRoomId> {
244	let room_id = RoomId::new_v1(services.globals.server_name());
245
246	let _short_id = services
247		.short
248		.get_or_create_shortroomid(&room_id)
249		.await;
250
251	let state_lock = services.state.mutex.lock(&room_id).await;
252	let server_user: &UserId = services.globals.server_user.as_ref();
253
254	let content = RoomCreateEventContent {
255		room_version: RoomVersionId::V11,
256		..RoomCreateEventContent::new_v11()
257	};
258
259	[
260		PduBuilder::state(String::new(), &content),
261		PduBuilder::state(
262			server_user.as_str(),
263			&RoomMemberEventContent::new(MembershipState::Join),
264		),
265		PduBuilder::state(String::new(), &notice_power_levels(server_user)),
266		PduBuilder::state(String::new(), &RoomJoinRulesEventContent::new(JoinRule::Invite)),
267		PduBuilder::state(
268			String::new(),
269			&RoomHistoryVisibilityEventContent::new(HistoryVisibility::Shared),
270		),
271		PduBuilder::state(String::new(), &RoomGuestAccessEventContent::new(GuestAccess::CanJoin)),
272		PduBuilder::state(String::new(), &RoomNameEventContent::new("Server Notices".to_owned())),
273	]
274	.into_iter()
275	.try_stream()
276	.try_for_each(|pdu| {
277		services
278			.timeline
279			.build_and_append_pdu(pdu, server_user, &room_id, &state_lock)
280			.map_ok(|_| ())
281	})
282	.await?;
283
284	drop(state_lock);
285
286	services
287		.account_data
288		.set_room_tag(target, &room_id, notice_tag(&services.config.admin.room.tag), None)
289		.await?;
290
291	Ok(room_id)
292}
293
294/// Reserves posting and room administration for the server identity.
295///
296/// Recipients retain the ability to join and subsequently leave the room.
297fn notice_power_levels(server_user: &UserId) -> RoomPowerLevelsEventContent {
298	RoomPowerLevelsEventContent {
299		users: [(server_user.into(), 100.into())].into(),
300		users_default: (-10).into(),
301		..Default::default()
302	}
303}
304
305/// Replays a stored notice response for the requesting user and device.
306///
307/// Empty transaction data belongs to an incompatible endpoint; invalid event IDs
308/// indicate corrupt stored data rather than a fresh transaction.
309async fn check_existing_txnid(
310	services: &Services,
311	sender_user: &UserId,
312	sender_device: Option<&DeviceId>,
313	txn_id: &TransactionId,
314) -> Option<Result<Response>> {
315	services
316		.transaction_ids
317		.existing_txnid(sender_user, sender_device, txn_id)
318		.map_ok(|response| notice_response(&response))
319		.map(Result::ok)
320		.await
321}
322
323/// Decodes a cached event ID while distinguishing incompatible transaction data.
324///
325/// Empty values identify to-device transactions; malformed IDs are database errors.
326fn notice_response(response: &[u8]) -> Result<Response> {
327	response
328		.is_empty()
329		.is_false()
330		.ok_or_else(|| {
331			err!(Request(InvalidParam(
332				"Tried to use txn_id already used for an incompatible endpoint."
333			)))
334		})
335		.and_then(|()| {
336			str_from_bytes(response)
337				.ok()
338				.and_then(|event_id| event_id.try_into().ok())
339				.map(Response::new)
340				.ok_or_else(|| err!(Database("Invalid event_id in txn_id data: {response:?}.")))
341		})
342}
343
344#[cfg(test)]
345mod tests {
346	use ruma::{Int, user_id};
347
348	use super::notice_power_levels;
349
350	#[test]
351	fn power_levels_mute_the_target() {
352		let server = user_id!("@server:example.com");
353		let power_levels = notice_power_levels(server);
354
355		assert_eq!(power_levels.users.get(server), Some(&Int::from(100)));
356		assert_eq!(power_levels.users_default, Int::from(-10));
357		assert_eq!(power_levels.events_default, Int::from(0));
358		assert!(power_levels.users_default < power_levels.events_default);
359	}
360}