Skip to main content

tuwunel_service/membership/
auto_accept.rs

1//! Acceptance of incoming invites on behalf of local users.
2//!
3//! Arrival sites queue an invite from inside the room's state lock; the
4//! service worker performs the joins once that lock is gone.
5
6use std::time::Duration;
7
8use futures::{FutureExt, StreamExt};
9use ruma::{
10	OwnedRoomId, OwnedUserId, RoomId, UserId, events::invite_permission_config::InvitePermission,
11};
12use tokio::time::sleep;
13use tuwunel_core::{
14	debug, debug_warn, implement,
15	utils::{
16		future::{ReadyBoolExt, and5},
17		result::LogErr,
18		stream::automatic_width,
19	},
20};
21
22use super::{Join, Service};
23
24/// One invite waiting for the worker to act on it.
25///
26/// The identifiers are owned rather than borrowed because the queue outlives
27/// the arrival site that filled it, which holds them only for the length of
28/// its own critical section.
29pub(super) struct Pending {
30	room_id: OwnedRoomId,
31	user_id: OwnedUserId,
32	sender: OwnedUserId,
33	is_direct: bool,
34}
35
36/// Join attempts made before the invite is left for its user to answer.
37///
38/// The first attempt runs immediately and each retry doubles the wait from
39/// one second. An invite is answered over federation before the inviting
40/// server has applied it to its own state, so an early join can arrive there
41/// unauthorized.
42const ATTEMPTS: u32 = 5;
43
44/// Queue an arriving invite for acceptance on the invited user's behalf.
45///
46/// Configured policy and the two users' locality decide whether it is queued
47/// at all. Nothing is awaited and nothing is locked, so an arrival site can
48/// call this while it holds the room's state mutex.
49#[implement(Service)]
50pub fn auto_accept(&self, room_id: &RoomId, user_id: &UserId, sender: &UserId, is_direct: bool) {
51	let config = &self.services.config;
52
53	let accepts = config.auto_accept_invites
54		&& (is_direct || !config.auto_accept_invites_direct_only)
55		&& self.services.globals.user_is_local(user_id)
56		&& (!config.auto_accept_invites_local_only
57			|| self.services.globals.user_is_local(sender));
58
59	if !accepts {
60		return;
61	}
62
63	self.queue
64		.0
65		.send(Pending {
66			room_id: room_id.to_owned(),
67			user_id: user_id.to_owned(),
68			sender: sender.to_owned(),
69			is_direct,
70		})
71		.ok();
72}
73
74/// Service worker, joining queued invites up to the concurrency band.
75///
76/// The queue itself stays unbounded so an arrival site never blocks under the
77/// room's state mutex. Shutdown abandons whatever is in flight, which leaves
78/// the invite for its user to answer.
79#[implement(Service)]
80pub(super) async fn accept_worker(&self) {
81	let accepting = self
82		.queue
83		.1
84		.stream()
85		.for_each_concurrent(automatic_width(), async |invite| self.accept(invite).await);
86
87	tokio::select! {
88		() = accepting => {},
89		() = self.services.server.until_shutdown() => {},
90	}
91}
92
93#[implement(Service)]
94#[tracing::instrument(
95	name = "auto_accept",
96	level = "debug",
97	skip_all,
98	fields(
99		%room_id,
100		%user_id,
101	),
102)]
103async fn accept(&self, Pending { room_id, user_id, sender, is_direct }: Pending) {
104	if !self
105		.join_invited(&room_id, &user_id, &sender)
106		.await
107	{
108		return;
109	}
110
111	debug!("Accepted the invitation on the user's behalf.");
112
113	if is_direct {
114		self.services
115			.account_data
116			.mark_direct(&user_id, &sender, &room_id)
117			.await
118			.log_err()
119			.ok();
120	}
121}
122
123/// Join the room, reattempting for as long as the invite still stands.
124///
125/// Withdrawing the invite ends the attempts, and so does the last delay
126/// elapsing, which leaves the room for its user to join by hand.
127#[implement(Service)]
128async fn join_invited(&self, room_id: &RoomId, user_id: &UserId, sender: &UserId) -> bool {
129	for attempt in 0..ATTEMPTS {
130		let delay = attempt
131			.checked_sub(1)
132			.map_or(Duration::ZERO, |retry| Duration::from_secs(1 << retry));
133
134		sleep(delay).await;
135
136		if !self.acceptable(room_id, user_id, sender).await {
137			return false;
138		}
139
140		let joined = self
141			.join(Join {
142				sender_user: user_id,
143				room_id,
144				orig_room_id: None,
145				reason: None,
146				servers: &[],
147				is_appservice: false,
148				extra_content: None,
149			})
150			.await;
151
152		match joined {
153			| Ok(()) => return true,
154			| Err(e) => debug_warn!(?e, "Automatic invite acceptance attempt failed"),
155		}
156	}
157
158	false
159}
160
161/// Whether the invite still stands and its user is one we may act for.
162///
163/// Deactivated, suspended, and locked accounts are refused their own requests
164/// by the middleware, so answering an invite for one would route around it.
165/// An invite the user's filtering configuration blocks or ignores never
166/// reaches them in sync (MSC4380, MSC4155), and a join on their behalf would
167/// surface the room regardless, so those are skipped as well. Every attempt
168/// re-reads this, since the delays leave room for any of it to change
169/// underneath.
170///
171/// The conjunction short-circuits, so the invite leads as the loop's own
172/// exit condition, and `invite_permission` trails on four concurrent reads
173/// and their deserialization.
174#[implement(Service)]
175async fn acceptable(&self, room_id: &RoomId, user_id: &UserId, sender: &UserId) -> bool {
176	let state_cache = &self.services.state_cache;
177	let users = &self.services.users;
178
179	let invited = state_cache.is_invited(user_id, room_id);
180	let active = users.is_active_local(user_id);
181	let unsuspended = users.is_suspended(user_id).is_false();
182	let unlocked = users.is_locked(user_id).is_false();
183	let permitted = users
184		.invite_permission(sender, user_id)
185		.map(|permission| permission.eq(&InvitePermission::Allow));
186
187	and5(invited, active, unsuspended, unlocked, permitted).await
188}