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
27pub(super) enum Fetch {
33 Extern,
34 Preview(Agent),
35}
36
37#[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#[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 Ok(Fetched { media, animates: None })
287}
288
289fn 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 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 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
397fn 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 if error.kind() == NotFound || error.kind() == Unrecognized {
405 return fallback();
406 }
407
408 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 error
420}
421
422#[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 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 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}