Skip to main content

tuwunel_service/rooms/metadata/
mod.rs

1//! Tracks room existence, public visibility, and local moderation markers.
2//!
3//! Existence is inferred from stored timeline rows, while room IDs come from
4//! the short-ID index. Disabled and banned markers have independent lifecycles.
5
6use std::sync::Arc;
7
8use futures::{FutureExt, Stream, StreamExt, pin_mut};
9use ruma::{OwnedRoomId, OwnedUserId, RoomId, UserId, events::room::join_rules::JoinRule};
10use tuwunel_core::{
11	Result, implement,
12	utils::{
13		future::BoolExt,
14		stream::{TryIgnore, WidebandExt},
15	},
16};
17use tuwunel_database::Map;
18
19/// Provides room inventory and local moderation metadata.
20///
21/// The service combines short-room and timeline indexes with directory and
22/// join-rule state, plus persistent disabled and banned marker maps.
23pub struct Service {
24	db: Data,
25	services: Arc<crate::services::OnceServices>,
26}
27
28struct Data {
29	disabledroomids: Arc<Map>,
30	bannedroomids: Arc<Map>,
31	roomid_shortroomid: Arc<Map>,
32	pduid_pdu: Arc<Map>,
33}
34
35impl crate::Service for Service {
36	fn build(args: &crate::Args<'_>) -> Result<Arc<Self>> {
37		Ok(Arc::new(Self {
38			db: Data {
39				disabledroomids: args.db["disabledroomids"].clone(),
40				bannedroomids: args.db["bannedroomids"].clone(),
41				roomid_shortroomid: args.db["roomid_shortroomid"].clone(),
42				pduid_pdu: args.db["pduid_pdu"].clone(),
43			},
44			services: args.services.clone(),
45		}))
46	}
47
48	fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
49}
50
51/// Reports whether at least one timeline PDU is stored for a room.
52///
53/// A room without a short ID is absent. Database scan errors are skipped, so a
54/// failed or empty scan also returns `false`.
55#[implement(Service)]
56pub async fn exists(&self, room_id: &RoomId) -> bool {
57	let Ok(prefix) = self.services.short.get_shortroomid(room_id).await else {
58		return false;
59	};
60
61	// Look for PDUs in that room.
62	let keys = self
63		.db
64		.pduid_pdu
65		.keys_prefix_raw(&prefix)
66		.ignore_err();
67
68	pin_mut!(keys);
69	keys.next().await.is_some()
70}
71
72/// Streams public room IDs whose encoded IDs begin with a prefix.
73///
74/// IDs are owned before the asynchronous public-room check. Invalid index rows
75/// are skipped, and public means directory-listed or governed by a public join rule.
76#[implement(Service)]
77pub fn public_ids_prefix<'a>(
78	&'a self,
79	prefix: &'a str,
80) -> impl Stream<Item = OwnedRoomId> + Send + 'a {
81	self.ids_prefix(prefix)
82		.map(ToOwned::to_owned)
83		.wide_filter_map(async |room_id| self.is_public(&room_id).await.then_some(room_id))
84}
85
86/// Streams indexed room IDs whose encoded IDs begin with a prefix.
87///
88/// Each borrowed ID is valid only until the stream is polled again and must be
89/// copied before retention. Unparsable rows are skipped.
90#[implement(Service)]
91pub fn ids_prefix<'a>(&'a self, prefix: &'a str) -> impl Stream<Item = &RoomId> + Send + 'a {
92	self.db
93		.roomid_shortroomid
94		.keys_raw_prefix(prefix)
95		.ignore_err()
96}
97
98/// Streams every room ID present in the short-room-ID index.
99///
100/// Indexed rooms need not still contain timeline events. Each borrowed ID is
101/// valid only until the next poll, and unparsable rows are skipped.
102#[implement(Service)]
103pub fn iter_ids(&self) -> impl Stream<Item = &RoomId> + Send + '_ {
104	self.db.roomid_shortroomid.keys().ignore_err()
105}
106
107/// Reports whether a room is publicly discoverable or joinable.
108///
109/// An explicit directory listing or a valid public join rule is sufficient.
110/// Missing or invalid join-rule state falls back to invite and does not grant access.
111#[implement(Service)]
112pub async fn is_public(&self, room_id: &RoomId) -> bool {
113	let listed_public = self.services.directory.is_public_room(room_id);
114
115	let join_rule_public = self
116		.services
117		.state_accessor
118		.get_join_rules(room_id)
119		.map(|rule| matches!(rule, JoinRule::Public));
120
121	pin_mut!(listed_public, join_rule_public);
122	listed_public.or(join_rule_public).await
123}
124
125/// Disables inbound federation processing for a room.
126///
127/// The persistent marker is independent of the room's banned status and may be
128/// written before the server has stored the room.
129#[implement(Service)]
130#[inline]
131pub fn disable_room(&self, room_id: &RoomId) { self.db.disabledroomids.insert(room_id, []); }
132
133/// Re-enables inbound federation processing for a room.
134///
135/// Removing the disabled marker does not alter any banned marker for the room.
136#[implement(Service)]
137#[inline]
138pub fn enable_room(&self, room_id: &RoomId) { self.db.disabledroomids.remove(room_id); }
139
140/// Bans a room without recording a responsible user.
141///
142/// The empty marker overwrites any previously stored blocker attribution. A
143/// room may be banned before any of its events are stored locally.
144#[implement(Service)]
145#[inline]
146pub fn ban_room(&self, room_id: &RoomId) { self.db.bannedroomids.insert(room_id, []); }
147
148/// Removes a room's local ban marker.
149///
150/// Unbanning does not re-enable federation if the independent disabled marker
151/// remains present.
152#[implement(Service)]
153#[inline]
154pub fn unban_room(&self, room_id: &RoomId) { self.db.bannedroomids.remove(room_id); }
155
156/// Bans a room and records the user responsible for the block.
157///
158/// The user ID replaces any existing empty or attributed ban value. The room
159/// need not otherwise exist locally.
160#[implement(Service)]
161#[inline]
162pub fn block_room(&self, room_id: &RoomId, blocker: &UserId) {
163	self.db
164		.bannedroomids
165		.insert(room_id, blocker.as_bytes());
166}
167
168/// Returns the user attributed to a room's ban marker.
169///
170/// Empty legacy or unattributed markers, invalid user IDs, missing rows, and
171/// database errors all yield `None`; use [`Self::is_banned`] to test the marker.
172#[implement(Service)]
173pub async fn banned_room_blocker(&self, room_id: &RoomId) -> Option<OwnedUserId> {
174	self.db
175		.bannedroomids
176		.get(room_id)
177		.await
178		.ok()
179		.and_then(|blocker| {
180			str::from_utf8(&blocker)
181				.ok()
182				.and_then(|mxid| UserId::parse(mxid).ok())
183		})
184}
185
186/// Streams every room with a local ban marker.
187///
188/// Each borrowed room ID is valid only until the stream is polled again and
189/// must be copied before retention. Unparsable rows are skipped.
190#[implement(Service)]
191pub fn list_banned_rooms(&self) -> impl Stream<Item = &RoomId> + Send + '_ {
192	self.db.bannedroomids.keys().ignore_err()
193}
194
195/// Reports whether a room has a disabled marker.
196///
197/// Missing rows and database errors both return `false`.
198#[implement(Service)]
199#[inline]
200pub async fn is_disabled(&self, room_id: &RoomId) -> bool {
201	self.db.disabledroomids.get(room_id).await.is_ok()
202}
203
204/// Reports whether a room has a banned marker.
205///
206/// Both empty bans and user-attributed blocks count. Missing rows and database
207/// errors return `false`.
208#[implement(Service)]
209#[inline]
210pub async fn is_banned(&self, room_id: &RoomId) -> bool {
211	self.db.bannedroomids.get(room_id).await.is_ok()
212}