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