Skip to main content

tuwunel_api/client/admin/media/
list_room_media.rs

1use std::iter::once;
2
3use axum::extract::State;
4use ruma::{Mxc, OwnedMxcUri, ServerName};
5use serde::Deserialize;
6use serde_json::Value as JsonValue;
7use synapse_admin_api::media::list_room_media::v1::{Request, Response};
8use tuwunel_core::{
9	Result,
10	matrix::Event,
11	utils::{ReadyExt, stream::TryIgnore},
12};
13
14use crate::{Ruma, client::admin::require_admin};
15
16type MxcLists = (Vec<OwnedMxcUri>, Vec<OwnedMxcUri>);
17
18#[derive(Deserialize)]
19struct ExtractUrls {
20	url: Option<String>,
21	info: Option<JsonValue>,
22}
23
24/// # `GET /_synapse/admin/v1/room/{room_id}/media`
25///
26/// Lists every MXC URI referenced by the room's unencrypted events, newest
27/// first, split by origin server. Unknown rooms yield empty lists.
28pub(crate) async fn admin_list_room_media_route(
29	State(services): State<crate::State>,
30	body: Ruma<Request>,
31) -> Result<Response> {
32	require_admin(&services, body.sender_user()).await?;
33
34	let server_name = services.globals.server_name();
35
36	let (local, remote) = services
37		.timeline
38		.pdus_rev(None, &body.room_id, None)
39		.ignore_err()
40		.ready_filter_map(|(_, pdu)| pdu.get_content().ok())
41		.ready_fold_default(|lists, content: ExtractUrls| {
42			collect_urls(lists, server_name, &content)
43		})
44		.await;
45
46	Ok(Response { local, remote })
47}
48
49/// An event contributes only when its content carries a string `url`, mirroring
50/// Synapse's contains_url row filter; `info.thumbnail_url` rides along.
51/// Candidates parsing as MXC URIs are appended by origin, newest first,
52/// duplicates preserved.
53fn collect_urls(
54	(mut local, mut remote): MxcLists,
55	server_name: &ServerName,
56	content: &ExtractUrls,
57) -> MxcLists {
58	let Some(url) = content.url.as_deref() else {
59		return (local, remote);
60	};
61
62	let thumbnail_url = content
63		.info
64		.as_ref()
65		.and_then(JsonValue::as_object)
66		.and_then(|info| info.get("thumbnail_url"))
67		.and_then(JsonValue::as_str);
68
69	once(url)
70		.chain(thumbnail_url)
71		.filter_map(|url| Mxc::try_from(url).ok().map(|mxc| (url, mxc)))
72		.for_each(|(url, mxc)| {
73			if mxc.server_name == server_name {
74				local.push(url.into());
75			} else {
76				remote.push(url.into());
77			}
78		});
79
80	(local, remote)
81}
82
83#[cfg(test)]
84mod tests {
85	use ruma::server_name;
86	use serde_json::json;
87
88	use super::{ExtractUrls, MxcLists, collect_urls};
89
90	fn collect(content: serde_json::Value) -> MxcLists {
91		let content: ExtractUrls = serde_json::from_value(content).expect("valid ExtractUrls");
92
93		collect_urls(MxcLists::default(), server_name!("example.org"), &content)
94	}
95
96	#[test]
97	fn url_and_thumbnail_split_by_origin() {
98		let (local, remote) = collect(json!({
99			"url": "mxc://example.org/abc",
100			"info": { "thumbnail_url": "mxc://remote.example/def" },
101		}));
102
103		assert_eq!(local, ["mxc://example.org/abc"]);
104		assert_eq!(remote, ["mxc://remote.example/def"]);
105	}
106
107	#[test]
108	fn thumbnail_only_event_contributes_nothing() {
109		let (local, remote) = collect(json!({
110			"info": { "thumbnail_url": "mxc://example.org/xyz" },
111		}));
112
113		assert!(local.is_empty(), "{local:?}");
114		assert!(remote.is_empty(), "{remote:?}");
115	}
116
117	#[test]
118	fn non_object_info_still_lists_url() {
119		let (local, remote) = collect(json!({
120			"url": "mxc://example.org/abc",
121			"info": "weird",
122		}));
123
124		assert_eq!(local, ["mxc://example.org/abc"]);
125		assert!(remote.is_empty(), "{remote:?}");
126	}
127
128	#[test]
129	fn non_mxc_and_invalid_urls_skipped() {
130		let (local, remote) = collect(json!({
131			"url": "https://example.org/pic.png",
132			"info": { "thumbnail_url": "mxc://example.org/has/slash" },
133		}));
134
135		assert!(local.is_empty(), "{local:?}");
136		assert!(remote.is_empty(), "{remote:?}");
137	}
138
139	#[test]
140	fn empty_url_still_lists_thumbnail() {
141		let (local, remote) = collect(json!({
142			"url": "",
143			"info": { "thumbnail_url": "mxc://remote.example/def" },
144		}));
145
146		assert!(local.is_empty(), "{local:?}");
147		assert_eq!(remote, ["mxc://remote.example/def"]);
148	}
149
150	#[test]
151	fn duplicates_preserved_across_events() {
152		let content: ExtractUrls =
153			serde_json::from_value(json!({ "url": "mxc://example.org/abc" }))
154				.expect("valid ExtractUrls");
155
156		let lists = collect_urls(MxcLists::default(), server_name!("example.org"), &content);
157		let (local, remote) = collect_urls(lists, server_name!("example.org"), &content);
158
159		assert_eq!(local, ["mxc://example.org/abc", "mxc://example.org/abc"]);
160		assert!(remote.is_empty(), "{remote:?}");
161	}
162
163	#[test]
164	fn non_string_url_fails_the_row_filter() {
165		let result = serde_json::from_value::<ExtractUrls>(json!({ "url": 5 }));
166
167		assert!(result.is_err());
168	}
169}