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
20pub(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}