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
26pub(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 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 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
365fn 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 if error.kind() == NotFound || error.kind() == Unrecognized {
373 return fallback();
374 }
375
376 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 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 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}