Skip to main content

tuwunel_service/media/
remote.rs

1use std::{fmt::Debug, time::Duration};
2
3use http::header::{CONTENT_DISPOSITION, CONTENT_TYPE, HeaderValue};
4use ruma::{
5	Mxc, ServerName,
6	api::{
7		OutgoingRequest,
8		client::media,
9		error::ErrorKind::{NotFound, Unrecognized},
10		federation,
11		federation::authenticated_media::{Content, FileOrLocation},
12	},
13};
14use tuwunel_core::{
15	Err, Error, Result, debug_warn, err, implement,
16	utils::content_disposition::make_content_disposition,
17};
18use url::Url;
19
20use super::{Dim, Media};
21use crate::{
22	client::read_response_capped,
23	federation::scheme::{FedAuth, FedPath},
24};
25
26#[implement(super::Service)]
27#[tracing::instrument(level = "debug", skip(self))]
28pub async fn fetch_remote_thumbnail(
29	&self,
30	mxc: &Mxc<'_>,
31	server: Option<&ServerName>,
32	timeout_ms: Duration,
33	dim: &Dim,
34) -> Result<Media> {
35	self.check_fetch_authorized(mxc)?;
36
37	let result = self
38		.fetch_thumbnail_authenticated(mxc, server, timeout_ms, dim)
39		.await;
40
41	if let Err(Error::Request(NotFound, ..)) = &result
42		&& self.services.server.config.request_legacy_media
43	{
44		return self
45			.fetch_thumbnail_unauthenticated(mxc, server, timeout_ms, dim)
46			.await;
47	}
48
49	result
50}
51
52#[implement(super::Service)]
53#[tracing::instrument(level = "debug", skip(self))]
54pub async fn fetch_remote_content(
55	&self,
56	mxc: &Mxc<'_>,
57	server: Option<&ServerName>,
58	timeout_ms: Duration,
59) -> Result<Media> {
60	self.check_fetch_authorized(mxc)?;
61
62	let result = self
63		.fetch_content_authenticated(mxc, server, timeout_ms)
64		.await;
65
66	if let Err(Error::Request(NotFound, ..)) = &result
67		&& self.services.server.config.request_legacy_media
68	{
69		return self
70			.fetch_content_unauthenticated(mxc, server, timeout_ms)
71			.await;
72	}
73
74	result
75}
76
77#[implement(super::Service)]
78async fn fetch_thumbnail_authenticated(
79	&self,
80	mxc: &Mxc<'_>,
81	server: Option<&ServerName>,
82	timeout_ms: Duration,
83	dim: &Dim,
84) -> Result<Media> {
85	use federation::authenticated_media::get_content_thumbnail::v1::{Request, Response};
86
87	let request = Request {
88		media_id: mxc.media_id.into(),
89		method: dim.method.clone().into(),
90		width: dim.width.into(),
91		height: dim.height.into(),
92		animated: true.into(),
93		timeout_ms,
94	};
95
96	let Response { content, .. } = self
97		.federation_request(mxc, server, request)
98		.await?;
99
100	match content {
101		| FileOrLocation::File(content) =>
102			self.handle_thumbnail_file(mxc, dim, content)
103				.await,
104		| FileOrLocation::Location(location) => self.handle_location(mxc, &location).await,
105	}
106}
107
108#[implement(super::Service)]
109async fn fetch_content_authenticated(
110	&self,
111	mxc: &Mxc<'_>,
112	server: Option<&ServerName>,
113	timeout_ms: Duration,
114) -> Result<Media> {
115	use federation::authenticated_media::get_content::v1::{Request, Response};
116
117	let request = Request {
118		media_id: mxc.media_id.into(),
119		timeout_ms,
120	};
121
122	let Response { content, .. } = self
123		.federation_request(mxc, server, request)
124		.await?;
125
126	match content {
127		| FileOrLocation::File(content) => self.handle_content_file(mxc, content).await,
128		| FileOrLocation::Location(location) => self.handle_location(mxc, &location).await,
129	}
130}
131
132#[expect(deprecated)]
133#[implement(super::Service)]
134async fn fetch_thumbnail_unauthenticated(
135	&self,
136	mxc: &Mxc<'_>,
137	server: Option<&ServerName>,
138	timeout_ms: Duration,
139	dim: &Dim,
140) -> Result<Media> {
141	use media::get_content_thumbnail::v3::{Request, Response};
142
143	let request = Request {
144		allow_remote: true,
145		allow_redirect: true,
146		animated: true.into(),
147		method: dim.method.clone().into(),
148		width: dim.width.into(),
149		height: dim.height.into(),
150		server_name: mxc.server_name.into(),
151		media_id: mxc.media_id.into(),
152		timeout_ms,
153	};
154
155	let Response {
156		file, content_type, content_disposition, ..
157	} = self
158		.federation_request(mxc, server, request)
159		.await?;
160
161	let content = Content { file, content_type, content_disposition };
162
163	self.handle_thumbnail_file(mxc, dim, content)
164		.await
165}
166
167#[expect(deprecated)]
168#[implement(super::Service)]
169async fn fetch_content_unauthenticated(
170	&self,
171	mxc: &Mxc<'_>,
172	server: Option<&ServerName>,
173	timeout_ms: Duration,
174) -> Result<Media> {
175	use media::get_content::v3::{Request, Response};
176
177	let request = Request {
178		allow_remote: true,
179		allow_redirect: true,
180		server_name: mxc.server_name.into(),
181		media_id: mxc.media_id.into(),
182		timeout_ms,
183	};
184
185	let Response {
186		file, content_type, content_disposition, ..
187	} = self
188		.federation_request(mxc, server, request)
189		.await?;
190
191	let content = Content { file, content_type, content_disposition };
192
193	self.handle_content_file(mxc, content).await
194}
195
196#[implement(super::Service)]
197async fn handle_thumbnail_file(
198	&self,
199	mxc: &Mxc<'_>,
200	dim: &Dim,
201	content: Content,
202) -> Result<Media> {
203	let content_disposition = make_content_disposition(
204		content.content_disposition.as_ref(),
205		content.content_type.as_deref(),
206		None,
207	);
208
209	self.upload_thumbnail(
210		mxc,
211		Some(&content_disposition),
212		content.content_type.as_deref(),
213		dim,
214		&content.file,
215	)
216	.await
217	.map(|()| Media {
218		content: content.file,
219		content_type: content.content_type.map(Into::into),
220		content_disposition: Some(content_disposition),
221	})
222}
223
224#[implement(super::Service)]
225async fn handle_content_file(&self, mxc: &Mxc<'_>, content: Content) -> Result<Media> {
226	let content_disposition = make_content_disposition(
227		content.content_disposition.as_ref(),
228		content.content_type.as_deref(),
229		None,
230	);
231
232	self.create(
233		mxc,
234		None,
235		Some(&content_disposition),
236		content.content_type.as_deref(),
237		&content.file,
238	)
239	.await
240	.map(|()| Media {
241		content: content.file,
242		content_type: content.content_type.map(Into::into),
243		content_disposition: Some(content_disposition),
244	})
245}
246
247#[implement(super::Service)]
248async fn handle_location(&self, mxc: &Mxc<'_>, location: &str) -> Result<Media> {
249	self.location_request(location)
250		.await
251		.map_err(|error| {
252			err!(Request(NotFound(
253				debug_warn!(%mxc, ?location, ?error, "Fetching media from location failed")
254			)))
255		})
256}
257
258#[implement(super::Service)]
259async fn location_request(&self, location: &str) -> Result<Media> {
260	let url = Url::parse(location)
261		.map_err(|e| err!(Request(Unknown("Invalid media location URL: {e}"))))?;
262
263	self.check_url_host(&url)?;
264
265	let response = self
266		.services
267		.client
268		.extern_media
269		.get(url.as_str())
270		.send()
271		.await?;
272
273	if let Some(remote_addr) = response.remote_addr()
274		&& !self
275			.services
276			.client
277			.valid_cidr_range_ip(remote_addr.ip())
278	{
279		return Err!(Request(Forbidden("Requesting from this address is forbidden")));
280	}
281
282	let content_type = response
283		.headers()
284		.get(CONTENT_TYPE)
285		.map(HeaderValue::to_str)
286		.and_then(Result::ok)
287		.map(str::to_owned);
288
289	let content_disposition = response
290		.headers()
291		.get(CONTENT_DISPOSITION)
292		.map(HeaderValue::as_bytes)
293		.map(TryFrom::try_from)
294		.and_then(Result::ok);
295
296	let limit = self.services.server.config.max_response_size;
297	let content = read_response_capped(response, limit).await?;
298
299	Ok(Media {
300		content: content.to_vec(),
301		content_type: content_type.clone(),
302		content_disposition: Some(make_content_disposition(
303			content_disposition.as_ref(),
304			content_type.as_deref(),
305			None,
306		)),
307	})
308}
309
310#[implement(super::Service)]
311async fn federation_request<Request>(
312	&self,
313	mxc: &Mxc<'_>,
314	server: Option<&ServerName>,
315	request: Request,
316) -> Result<Request::IncomingResponse>
317where
318	Request: OutgoingRequest + Send + Debug,
319	Request::Authentication: FedAuth,
320	Request::PathBuilder: FedPath,
321{
322	self.services
323		.federation
324		.execute(server.unwrap_or(mxc.server_name), request)
325		.await
326		.map_err(|error| handle_federation_error(mxc, server, error))
327}
328
329// Handles and adjusts the error for the caller to determine if they should
330// request the fallback endpoint or give up.
331fn handle_federation_error(mxc: &Mxc<'_>, server: Option<&ServerName>, error: Error) -> Error {
332	let fallback =
333		|| err!(Request(NotFound(debug_error!(%mxc, ?server, ?error, "Remote media not found"))));
334
335	// Matrix server responses for fallback always taken.
336	if error.kind() == NotFound || error.kind() == Unrecognized {
337		return fallback();
338	}
339
340	// If we get these from any middleware we'll try the other endpoint rather than
341	// giving up too early.
342	if error.status_code().is_redirection()
343		|| error.status_code().is_client_error()
344		|| error.status_code().is_server_error()
345	{
346		return fallback();
347	}
348
349	// Reached for 5xx errors. This is where we don't fallback given the likelihood
350	// the other endpoint will also be a 5xx and we're wasting time.
351	error
352}
353
354#[implement(super::Service)]
355#[expect(deprecated)]
356pub async fn fetch_remote_thumbnail_legacy(
357	&self,
358	body: &media::get_content_thumbnail::v3::Request,
359) -> Result<media::get_content_thumbnail::v3::Response> {
360	let mxc = Mxc {
361		server_name: &body.server_name,
362		media_id: &body.media_id,
363	};
364
365	self.check_legacy_freeze()?;
366	self.check_fetch_authorized(&mxc)?;
367	let response = self
368		.services
369		.federation
370		.execute(mxc.server_name, media::get_content_thumbnail::v3::Request {
371			allow_remote: body.allow_remote,
372			height: body.height,
373			width: body.width,
374			method: body.method.clone(),
375			server_name: body.server_name.clone(),
376			media_id: body.media_id.clone(),
377			timeout_ms: body.timeout_ms,
378			allow_redirect: body.allow_redirect,
379			animated: body.animated,
380		})
381		.await?;
382
383	let dim = Dim::from_ruma(body.width, body.height, body.method.clone())?;
384	self.upload_thumbnail(&mxc, None, response.content_type.as_deref(), &dim, &response.file)
385		.await?;
386
387	Ok(response)
388}
389
390#[implement(super::Service)]
391#[expect(deprecated)]
392pub async fn fetch_remote_content_legacy(
393	&self,
394	mxc: &Mxc<'_>,
395	allow_redirect: bool,
396	timeout_ms: Duration,
397) -> Result<media::get_content::v3::Response, Error> {
398	self.check_legacy_freeze()?;
399	self.check_fetch_authorized(mxc)?;
400	let response = self
401		.services
402		.federation
403		.execute(mxc.server_name, media::get_content::v3::Request {
404			allow_remote: true,
405			server_name: mxc.server_name.into(),
406			media_id: mxc.media_id.into(),
407			timeout_ms,
408			allow_redirect,
409		})
410		.await?;
411
412	let content_disposition = make_content_disposition(
413		response.content_disposition.as_ref(),
414		response.content_type.as_deref(),
415		None,
416	);
417
418	self.create(
419		mxc,
420		None,
421		Some(&content_disposition),
422		response.content_type.as_deref(),
423		&response.file,
424	)
425	.await?;
426
427	Ok(response)
428}
429
430#[implement(super::Service)]
431fn check_fetch_authorized(&self, mxc: &Mxc<'_>) -> Result {
432	if self
433		.services
434		.server
435		.config
436		.prevent_media_downloads_from
437		.is_match(mxc.server_name.host())
438		|| self
439			.services
440			.server
441			.config
442			.is_forbidden_remote_server_name(mxc.server_name)
443	{
444		// we'll lie to the client and say the blocked server's media was not found and
445		// log. the client has no way of telling anyways so this is a security bonus.
446		debug_warn!(%mxc, "Received request for media on blocklisted server");
447		return Err!(Request(NotFound("Media not found.")));
448	}
449
450	Ok(())
451}
452
453#[implement(super::Service)]
454fn check_legacy_freeze(&self) -> Result {
455	self.services
456		.server
457		.config
458		.freeze_legacy_media
459		.then_some(())
460		.ok_or(err!(Request(NotFound("Remote media is frozen."))))
461}