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