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