Skip to main content

tuwunel_api/client/admin/media/
mod.rs

1//! Synapse admin API: media endpoints.
2
3mod delete_media;
4mod delete_media_by_date_size;
5mod delete_user_media;
6mod list_room_media;
7mod list_user_media;
8mod purge_media_cache;
9mod query_media;
10mod user_media_statistics;
11
12use ruma::{
13	UInt,
14	api::Direction::{self, Backward, Forward},
15};
16use synapse_admin_api::media::list_user_media::v1::MediaSortOrder;
17use tuwunel_service::media::UserMediaEntry;
18
19pub(crate) use self::{
20	delete_media::admin_delete_media_route,
21	delete_media_by_date_size::admin_delete_media_by_date_size_route,
22	delete_user_media::admin_delete_user_media_route,
23	list_room_media::admin_list_room_media_route, list_user_media::admin_list_user_media_route,
24	purge_media_cache::admin_purge_media_cache_route, query_media::admin_query_media_route,
25	user_media_statistics::admin_user_media_statistics_route,
26};
27
28/// Rejects a `before_ts` earlier than this, mirroring Synapse's "year 1970"
29/// lower bound on the millisecond cutoff.
30pub(super) const MIN_BEFORE_TS: u64 = 30_000_000_000;
31
32/// Sort field selector, mapping the wire `order_by` onto the derivable columns.
33#[derive(Clone, Copy)]
34pub(super) enum Column {
35	MediaId,
36	UploadName,
37	CreatedTs,
38	MediaLength,
39	MediaType,
40}
41
42/// Sort key for a media row under the requested `order_by` column.
43#[derive(PartialEq, Eq, PartialOrd, Ord)]
44enum SortKey<'a> {
45	Str(&'a str),
46	OptStr(Option<&'a str>),
47	Num(Option<u64>),
48}
49
50/// Selects one page of a user's media, applying the same sort, direction, and
51/// windowing as Synapse's shared list/delete selection.
52pub(super) fn select_page(
53	entries: Vec<UserMediaEntry>,
54	order_by: Option<&MediaSortOrder>,
55	dir: Option<Direction>,
56	from: usize,
57	limit: usize,
58) -> Vec<UserMediaEntry> {
59	paginate(entries, column_of(order_by), direction_of(order_by, dir), from, limit)
60}
61
62fn column_of(order_by: Option<&MediaSortOrder>) -> Column {
63	match order_by {
64		| Some(MediaSortOrder::MediaId) => Column::MediaId,
65		| Some(MediaSortOrder::UploadName) => Column::UploadName,
66		| Some(MediaSortOrder::MediaLength) => Column::MediaLength,
67		| Some(MediaSortOrder::MediaType) => Column::MediaType,
68		| _ => Column::CreatedTs,
69	}
70}
71
72/// Default direction is newest first (`Backward`) when the client sets neither
73/// `order_by` nor `dir`, matching Synapse's back-compat behaviour.
74fn direction_of(order_by: Option<&MediaSortOrder>, dir: Option<Direction>) -> Direction {
75	match (order_by, dir) {
76		| (None, None) => Backward,
77		| (_, Some(dir)) => dir,
78		| (Some(_), None) => Forward,
79	}
80}
81
82fn paginate(
83	mut entries: Vec<UserMediaEntry>,
84	column: Column,
85	dir: Direction,
86	from: usize,
87	limit: usize,
88) -> Vec<UserMediaEntry> {
89	entries.sort_by(|a, b| {
90		sort_key(column, a)
91			.cmp(&sort_key(column, b))
92			.then_with(|| a.mxc.cmp(&b.mxc))
93	});
94
95	if matches!(dir, Backward) {
96		entries.reverse();
97	}
98
99	entries
100		.into_iter()
101		.skip(from)
102		.take(limit)
103		.collect()
104}
105
106fn sort_key(column: Column, entry: &UserMediaEntry) -> SortKey<'_> {
107	match column {
108		| Column::MediaId => SortKey::Str(entry.mxc.media_id().unwrap_or_default()),
109		| Column::UploadName => SortKey::OptStr(entry.upload_name.as_deref()),
110		| Column::MediaType => SortKey::OptStr(entry.media_type.as_deref()),
111		| Column::MediaLength => SortKey::Num(entry.media_length),
112		| Column::CreatedTs => SortKey::Num(Some(entry.created_ts)),
113	}
114}
115
116pub(super) fn usize_from(value: UInt) -> usize {
117	usize::try_from(u64::from(value)).unwrap_or(usize::MAX)
118}
119
120#[cfg(test)]
121mod tests {
122	use ruma::{
123		api::Direction::{Backward, Forward},
124		user_id,
125	};
126
127	use super::{Column, UserMediaEntry, paginate};
128
129	fn entry(media_id: &str, created_ts: u64, media_length: Option<u64>) -> UserMediaEntry {
130		UserMediaEntry {
131			mxc: format!("mxc://example.org/{media_id}").into(),
132			media_type: Some("image/png".to_owned()),
133			upload_name: Some(format!("{media_id}.png")),
134			media_length,
135			created_ts,
136			user_id: Some(user_id!("@alice:example.org").to_owned()),
137		}
138	}
139
140	fn ids(entries: &[UserMediaEntry]) -> Vec<&str> {
141		entries
142			.iter()
143			.map(|e| e.mxc.media_id().unwrap_or_default())
144			.collect()
145	}
146
147	#[test]
148	fn created_ts_backward_is_newest_first() {
149		let entries = vec![entry("a", 10, None), entry("b", 30, None), entry("c", 20, None)];
150
151		let page = paginate(entries, Column::CreatedTs, Backward, 0, 100);
152
153		assert_eq!(ids(&page), ["b", "c", "a"]);
154	}
155
156	#[test]
157	fn created_ts_forward_is_oldest_first() {
158		let entries = vec![entry("a", 10, None), entry("b", 30, None), entry("c", 20, None)];
159
160		let page = paginate(entries, Column::CreatedTs, Forward, 0, 100);
161
162		assert_eq!(ids(&page), ["a", "c", "b"]);
163	}
164
165	#[test]
166	fn from_and_limit_window_the_page() {
167		let entries = vec![entry("a", 10, None), entry("b", 30, None), entry("c", 20, None)];
168
169		let page = paginate(entries, Column::CreatedTs, Forward, 1, 1);
170
171		assert_eq!(ids(&page), ["c"]);
172	}
173
174	#[test]
175	fn media_length_backward_puts_largest_first() {
176		let entries =
177			vec![entry("a", 10, Some(5)), entry("b", 10, Some(30)), entry("c", 10, Some(20))];
178
179		let page = paginate(entries, Column::MediaLength, Backward, 0, 100);
180
181		assert_eq!(ids(&page), ["b", "c", "a"]);
182	}
183}