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