Skip to main content

tuwunel_api_admin/media/
user_media_statistics.rs

1use std::collections::BTreeMap;
2
3use axum::extract::State;
4use futures::StreamExt;
5use ruma::{
6	OwnedUserId, UInt,
7	api::Direction::{self, Backward, Forward},
8};
9use synapse_admin_api::statistics::user_media_statistics::v1::{
10	Request, Response, UserMediaSortOrder, UserMediaStat,
11};
12use tuwunel_core::{
13	Err, Result,
14	utils::{IterStream, ReadyExt, math::ruma_from_usize, stream::BroadbandExt},
15};
16
17use super::{SortKey, usize_from};
18use crate::{Ruma, require_admin};
19
20/// # `GET /_synapse/admin/v1/statistics/users/media`
21///
22/// Media creation times derive from storage-object modification time, so the
23/// `from_ts`/`until_ts` window selects by mtime and media missing from every
24/// storage provider are not counted.
25pub(crate) async fn admin_user_media_statistics_route(
26	State(services): State<crate::State>,
27	body: Ruma<Request>,
28) -> Result<Response> {
29	require_admin(&services, body.sender_user()).await?;
30
31	let order_by = body
32		.order_by
33		.as_ref()
34		.unwrap_or(&UserMediaSortOrder::UserId);
35
36	if !matches!(
37		order_by,
38		UserMediaSortOrder::MediaLength
39			| UserMediaSortOrder::MediaCount
40			| UserMediaSortOrder::UserId
41			| UserMediaSortOrder::Displayname
42	) {
43		return Err!(Request(InvalidParam(
44			"Query parameter order_by must be one of media_length, media_count, user_id, \
45			 displayname."
46		)));
47	}
48
49	let from_ts = body.from_ts.map_or(0, u64::from);
50	let until_ts = body.until_ts.map(u64::from);
51	if until_ts.is_some_and(|until_ts| until_ts <= from_ts) {
52		return Err!(Request(InvalidParam(
53			"Query parameter until_ts must be greater than from_ts."
54		)));
55	}
56
57	let search_term = body.search_term.as_deref();
58	if search_term.is_some_and(str::is_empty) {
59		return Err!(Request(InvalidParam(
60			"Query parameter search_term cannot be an empty string."
61		)));
62	}
63
64	let usage = services
65		.media
66		.upload_stats()
67		.ready_filter(|stat| in_window(stat.created_ts, from_ts, until_ts))
68		.ready_fold(BTreeMap::new(), |mut usage: BTreeMap<OwnedUserId, (u64, u64)>, stat| {
69			let (count, length) = usage.entry(stat.user_id).or_default();
70			*count = count.saturating_add(1);
71			*length = length.saturating_add(stat.media_length);
72			usage
73		})
74		.await;
75
76	let rows: Vec<UserMediaStat> = usage
77		.into_iter()
78		.stream()
79		.broad_then(async |(user_id, (count, length))| {
80			let displayname = services.profile.displayname(&user_id).await.ok();
81			let stat = UserMediaStat::new(
82				user_id,
83				UInt::new_saturating(count),
84				UInt::new_saturating(length),
85			);
86
87			UserMediaStat { displayname, ..stat }
88		})
89		.ready_filter(|row| search_term.is_none_or(|term| row_matches(row, term)))
90		.collect()
91		.await;
92
93	let from = body.from.map_or(0, usize_from);
94	let limit = body.limit.map_or(100, usize_from);
95	let dir = body.dir.unwrap_or(Forward);
96
97	Ok(into_response(rows, order_by, dir, from, limit))
98}
99
100fn into_response(
101	rows: Vec<UserMediaStat>,
102	order_by: &UserMediaSortOrder,
103	dir: Direction,
104	from: usize,
105	limit: usize,
106) -> Response {
107	let total = rows.len();
108	let users = paginate(rows, order_by, dir, from, limit);
109	let end = from.saturating_add(users.len());
110	let next_token = (end < total).then(|| ruma_from_usize(end));
111
112	Response {
113		users,
114		next_token,
115		total: ruma_from_usize(total),
116	}
117}
118
119fn in_window(created_ts: u64, from_ts: u64, until_ts: Option<u64>) -> bool {
120	created_ts >= from_ts && until_ts.is_none_or(|until_ts| created_ts <= until_ts)
121}
122
123fn row_matches(row: &UserMediaStat, term: &str) -> bool {
124	row.user_id.localpart().contains(term)
125		|| row
126			.displayname
127			.as_deref()
128			.is_some_and(|name| name.contains(term))
129}
130
131fn paginate(
132	mut rows: Vec<UserMediaStat>,
133	order_by: &UserMediaSortOrder,
134	dir: Direction,
135	from: usize,
136	limit: usize,
137) -> Vec<UserMediaStat> {
138	rows.sort_unstable_by(|a, b| {
139		sort_key(order_by, a)
140			.cmp(&sort_key(order_by, b))
141			.then_with(|| a.user_id.cmp(&b.user_id))
142	});
143
144	if matches!(dir, Backward) {
145		rows.reverse();
146	}
147
148	rows.into_iter().skip(from).take(limit).collect()
149}
150
151fn sort_key<'a>(order_by: &UserMediaSortOrder, row: &'a UserMediaStat) -> SortKey<'a> {
152	match order_by {
153		| UserMediaSortOrder::MediaLength => SortKey::Num(Some(row.media_length.into())),
154		| UserMediaSortOrder::MediaCount => SortKey::Num(Some(row.media_count.into())),
155		| UserMediaSortOrder::Displayname => SortKey::OptStr(row.displayname.as_deref()),
156		| _ => SortKey::Str(row.user_id.as_str()),
157	}
158}
159
160#[cfg(test)]
161mod tests {
162	use ruma::{
163		UInt,
164		api::Direction::{Backward, Forward},
165	};
166
167	use super::{
168		UserMediaSortOrder, UserMediaStat, in_window, into_response, paginate, row_matches,
169	};
170
171	fn row(local: &str, displayname: Option<&str>, count: u32, length: u32) -> UserMediaStat {
172		let user_id = format!("@{local}:example.org")
173			.try_into()
174			.unwrap();
175
176		UserMediaStat {
177			displayname: displayname.map(ToOwned::to_owned),
178			..UserMediaStat::new(user_id, count.into(), length.into())
179		}
180	}
181
182	fn ids(rows: &[UserMediaStat]) -> Vec<&str> {
183		rows.iter()
184			.map(|row| row.user_id.localpart())
185			.collect()
186	}
187	#[test]
188	fn user_id_forward_orders_lexically() {
189		let rows = vec![row("b", None, 1, 1), row("a", None, 1, 1), row("c", None, 1, 1)];
190
191		let page = paginate(rows, &UserMediaSortOrder::UserId, Forward, 0, 100);
192
193		assert_eq!(ids(&page), ["a", "b", "c"]);
194	}
195
196	#[test]
197	fn backward_reverses_order() {
198		let rows = vec![row("b", None, 1, 1), row("a", None, 1, 1), row("c", None, 1, 1)];
199
200		let page = paginate(rows, &UserMediaSortOrder::UserId, Backward, 0, 100);
201
202		assert_eq!(ids(&page), ["c", "b", "a"]);
203	}
204
205	#[test]
206	fn media_length_orders_numerically() {
207		let rows = vec![row("a", None, 1, 5), row("b", None, 1, 30), row("c", None, 1, 20)];
208
209		let forward = paginate(rows.clone(), &UserMediaSortOrder::MediaLength, Forward, 0, 100);
210
211		assert_eq!(ids(&forward), ["a", "c", "b"]);
212
213		let backward = paginate(rows, &UserMediaSortOrder::MediaLength, Backward, 0, 100);
214
215		assert_eq!(ids(&backward), ["b", "c", "a"]);
216	}
217
218	#[test]
219	fn equal_counts_tiebreak_by_user_id() {
220		let rows = vec![row("c", None, 5, 1), row("a", None, 5, 1), row("b", None, 5, 1)];
221
222		let page = paginate(rows, &UserMediaSortOrder::MediaCount, Forward, 0, 100);
223
224		assert_eq!(ids(&page), ["a", "b", "c"]);
225	}
226
227	#[test]
228	fn displayname_none_sorts_first_forward() {
229		let rows = vec![row("a", Some("zed"), 1, 1), row("b", None, 1, 1)];
230
231		let page = paginate(rows, &UserMediaSortOrder::Displayname, Forward, 0, 100);
232
233		assert_eq!(ids(&page), ["b", "a"]);
234	}
235
236	#[test]
237	fn from_and_limit_window_the_page() {
238		let rows = vec![row("a", None, 1, 1), row("b", None, 1, 1), row("c", None, 1, 1)];
239
240		let page = paginate(rows, &UserMediaSortOrder::UserId, Forward, 1, 1);
241
242		assert_eq!(ids(&page), ["b"]);
243	}
244
245	#[test]
246	fn oversized_aggregate_saturates_on_wire() {
247		assert_eq!(UInt::new_saturating(42), UInt::from(42_u32));
248		assert_eq!(UInt::new_saturating(u64::MAX), UInt::MAX);
249	}
250
251	#[test]
252	fn response_reports_total_and_next_token() {
253		let rows = vec![row("a", None, 1, 1), row("b", None, 1, 1), row("c", None, 1, 1)];
254
255		let response = into_response(rows, &UserMediaSortOrder::UserId, Forward, 0, 1);
256
257		assert_eq!(ids(&response.users), ["a"]);
258		assert_eq!(response.total, UInt::from(3_u32));
259		assert_eq!(response.next_token, Some(UInt::from(1_u32)));
260	}
261
262	#[test]
263	fn final_page_omits_next_token() {
264		let rows = vec![row("a", None, 1, 1), row("b", None, 1, 1), row("c", None, 1, 1)];
265
266		let response = into_response(rows, &UserMediaSortOrder::UserId, Forward, 1, 2);
267
268		assert_eq!(ids(&response.users), ["b", "c"]);
269		assert_eq!(response.total, UInt::from(3_u32));
270		assert_eq!(response.next_token, None);
271	}
272
273	#[test]
274	fn zero_limit_repeats_from_as_next_token() {
275		let rows = vec![row("a", None, 1, 1), row("b", None, 1, 1), row("c", None, 1, 1)];
276
277		let response = into_response(rows, &UserMediaSortOrder::UserId, Forward, 1, 0);
278
279		assert!(response.users.is_empty());
280		assert_eq!(response.total, UInt::from(3_u32));
281		assert_eq!(response.next_token, Some(UInt::from(1_u32)));
282	}
283
284	#[test]
285	fn window_bounds_are_inclusive() {
286		assert!(in_window(5, 5, Some(5)));
287		assert!(!in_window(4, 5, None));
288		assert!(!in_window(6, 0, Some(5)));
289		assert!(in_window(0, 0, None));
290	}
291
292	#[test]
293	fn search_matches_localpart_or_displayname() {
294		assert!(row_matches(&row("alice", None, 1, 1), "lic"));
295		assert!(row_matches(&row("bob", Some("Wonderland"), 1, 1), "onder"));
296		assert!(!row_matches(&row("alice", Some("alice"), 1, 1), "example"));
297		assert!(!row_matches(&row("alice", Some("alice"), 1, 1), "Alice"));
298	}
299}