matrix_sdk/media.rs
1// Copyright 2021 Kévin Commaille
2// Copyright 2022 The Matrix.org Foundation C.I.C.
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8// http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16//! High-level media API.
17
18#[cfg(feature = "e2e-encryption")]
19use std::io::Read;
20use std::{fmt, sync::Arc, time::Duration};
21#[cfg(not(target_family = "wasm"))]
22use std::{fs::File, path::Path};
23
24use eyeball::SharedObservable;
25use futures_util::future::try_join;
26use matrix_sdk_base::media::store::IgnoreMediaRetentionPolicy;
27pub use matrix_sdk_base::media::{store::MediaRetentionPolicy, *};
28use matrix_sdk_common::{BoxFuture, SendOutsideWasm, SyncOutsideWasm};
29use mime::Mime;
30use ruma::{
31 MilliSecondsSinceUnixEpoch, MxcUri, OwnedMxcUri, TransactionId, UInt,
32 api::{
33 Metadata,
34 client::{authenticated_media, media},
35 error::ErrorKind,
36 },
37 assign,
38 events::room::{MediaSource, ThumbnailInfo},
39};
40#[cfg(not(target_family = "wasm"))]
41use tempfile::{Builder as TempFileBuilder, NamedTempFile, TempDir};
42#[cfg(not(target_family = "wasm"))]
43use tokio::{fs::File as TokioFile, io::AsyncWriteExt};
44
45use crate::{
46 Client, Error, Result, TransmissionProgress, attachment::Thumbnail,
47 client::futures::SendMediaUploadRequest, config::RequestConfig,
48};
49
50/// A conservative upload speed of 1Mbps
51const DEFAULT_UPLOAD_SPEED: u64 = 125_000;
52/// 5 min minimal upload request timeout, used to clamp the request timeout.
53const MIN_UPLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(60 * 5);
54/// The server name used to generate local MXC URIs.
55// This mustn't represent a potentially valid media server, otherwise it'd be
56// possible for an attacker to return malicious content under some
57// preconditions (e.g. the cache store has been cleared before the upload
58// took place). To mitigate against this, we use the .localhost TLD,
59// which is guaranteed to be on the local machine. As a result, the only attack
60// possible would be coming from the user themselves, which we consider a
61// non-threat.
62const LOCAL_MXC_SERVER_NAME: &str = "send-queue.localhost";
63
64/// A high-level API to interact with the media API.
65#[derive(Debug, Clone)]
66pub struct Media {
67 /// The underlying HTTP client.
68 client: Client,
69 media_fetcher: Arc<dyn MediaFetcher>,
70}
71
72/// A file handle that takes ownership of a media file on disk. When the handle
73/// is dropped, the file will be removed from the disk.
74#[derive(Debug)]
75#[cfg(not(target_family = "wasm"))]
76pub struct MediaFileHandle {
77 /// The temporary file that contains the media.
78 file: NamedTempFile,
79 /// An intermediary temporary directory used in certain cases.
80 ///
81 /// Only stored for its `Drop` semantics.
82 _directory: Option<TempDir>,
83}
84
85#[cfg(not(target_family = "wasm"))]
86impl MediaFileHandle {
87 /// Get the media file's path.
88 pub fn path(&self) -> &Path {
89 self.file.path()
90 }
91
92 /// Persist the media file to the given path.
93 pub fn persist(self, path: &Path) -> Result<File, PersistError> {
94 self.file.persist(path).map_err(|e| PersistError {
95 error: e.error,
96 file: Self { file: e.file, _directory: self._directory },
97 })
98 }
99}
100
101/// Error returned when [`MediaFileHandle::persist`] fails.
102#[cfg(not(target_family = "wasm"))]
103pub struct PersistError {
104 /// The underlying IO error.
105 pub error: std::io::Error,
106 /// The temporary file that couldn't be persisted.
107 pub file: MediaFileHandle,
108}
109
110#[cfg(not(any(target_family = "wasm", tarpaulin_include)))]
111impl fmt::Debug for PersistError {
112 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113 write!(f, "PersistError({:?})", self.error)
114 }
115}
116
117#[cfg(not(any(target_family = "wasm", tarpaulin_include)))]
118impl fmt::Display for PersistError {
119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120 write!(f, "failed to persist temporary file: {}", self.error)
121 }
122}
123
124/// A preallocated MXC URI created by [`Media::create_content_uri()`], and
125/// to be used with [`Media::upload_preallocated()`].
126#[derive(Debug)]
127pub struct PreallocatedMxcUri {
128 /// The URI for the media URI.
129 pub uri: OwnedMxcUri,
130 /// The expiration date for the media URI.
131 expire_date: Option<MilliSecondsSinceUnixEpoch>,
132}
133
134/// An error that happened in the realm of media.
135#[derive(Debug, thiserror::Error)]
136pub enum MediaError {
137 /// A preallocated MXC URI has expired.
138 #[error("a preallocated MXC URI has expired")]
139 ExpiredPreallocatedMxcUri,
140
141 /// Preallocated media already had content, cannot overwrite.
142 #[error("preallocated media already had content, cannot overwrite")]
143 CannotOverwriteMedia,
144
145 /// Local-only media content was not found.
146 #[error("local-only media content was not found")]
147 LocalMediaNotFound,
148
149 /// The provided media is too large to upload.
150 #[error(
151 "The provided media is too large to upload. \
152 Maximum upload length is {max} bytes, tried to upload {current} bytes"
153 )]
154 MediaTooLargeToUpload {
155 /// The `max_upload_size` value for this homeserver.
156 max: UInt,
157 /// The size of the current media to upload.
158 current: UInt,
159 },
160
161 /// Fetching the `max_upload_size` value from the homeserver failed.
162 #[error("Fetching the `max_upload_size` value from the homeserver failed: {0}")]
163 FetchMaxUploadSizeFailed(String),
164}
165
166/// A generic trait for fetching media content.
167pub trait MediaFetcher: SendOutsideWasm + SyncOutsideWasm {
168 /// Fetches the media content for the given [`MediaRequestParameters`].
169 /// Returns either a byte array or an [`crate::Error`].
170 fn fetch_media_content<'a>(
171 &'a self,
172 client: &'a Client,
173 request: &'a MediaRequestParameters,
174 ) -> BoxFuture<'a, Result<Vec<u8>, Error>>;
175}
176
177impl fmt::Debug for dyn MediaFetcher {
178 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179 f.write_str("MediaFetcher")
180 }
181}
182
183impl Media {
184 pub(crate) fn new(client: Client, media_fetcher: Arc<dyn MediaFetcher>) -> Self {
185 Self { client, media_fetcher: media_fetcher.clone() }
186 }
187
188 /// Upload some media to the server.
189 ///
190 /// # Arguments
191 ///
192 /// * `content_type` - The type of the media, this will be used as the
193 /// content-type header.
194 ///
195 /// * `data` - Vector of bytes to be uploaded to the server.
196 ///
197 /// * `request_config` - Optional request configuration for the HTTP client,
198 /// overriding the default. If not provided, a reasonable timeout value is
199 /// inferred.
200 ///
201 /// # Examples
202 ///
203 /// ```no_run
204 /// # use std::fs;
205 /// # use matrix_sdk::{Client, ruma::room_id};
206 /// # use url::Url;
207 /// # use mime;
208 /// # async {
209 /// # let homeserver = Url::parse("http://localhost:8080")?;
210 /// # let mut client = Client::new(homeserver).await?;
211 /// let image = fs::read("/home/example/my-cat.jpg")?;
212 ///
213 /// let response =
214 /// client.media().upload(&mime::IMAGE_JPEG, image, None).await?;
215 ///
216 /// println!("Cat URI: {}", response.content_uri);
217 /// # anyhow::Ok(()) };
218 /// ```
219 pub fn upload(
220 &self,
221 content_type: &Mime,
222 data: Vec<u8>,
223 request_config: Option<RequestConfig>,
224 ) -> SendMediaUploadRequest {
225 let request_config = request_config.unwrap_or_else(|| {
226 self.client.request_config().timeout(Self::reasonable_upload_timeout(&data))
227 });
228
229 let request = assign!(media::create_content::v3::Request::new(data), {
230 content_type: Some(content_type.essence_str().to_owned()),
231 });
232
233 let request = self.client.send(request).with_request_config(request_config);
234 SendMediaUploadRequest::new(request)
235 }
236
237 /// Returns a reasonable upload timeout for an upload, based on the size of
238 /// the data to be uploaded.
239 pub(crate) fn reasonable_upload_timeout(data: &[u8]) -> Duration {
240 std::cmp::max(
241 Duration::from_secs(data.len() as u64 / DEFAULT_UPLOAD_SPEED),
242 MIN_UPLOAD_REQUEST_TIMEOUT,
243 )
244 }
245
246 /// Preallocates an MXC URI for a media that will be uploaded soon.
247 ///
248 /// This preallocates an URI *before* any content is uploaded to the server.
249 /// The resulting preallocated MXC URI can then be consumed with
250 /// [`Media::upload_preallocated`].
251 ///
252 /// # Examples
253 ///
254 /// ```no_run
255 /// # use std::fs;
256 /// # use matrix_sdk::{Client, ruma::room_id};
257 /// # use url::Url;
258 /// # use mime;
259 /// # async {
260 /// # let homeserver = Url::parse("http://localhost:8080")?;
261 /// # let mut client = Client::new(homeserver).await?;
262 ///
263 /// let preallocated = client.media().create_content_uri().await?;
264 /// println!("Cat URI: {}", preallocated.uri);
265 ///
266 /// let image = fs::read("/home/example/my-cat.jpg")?;
267 /// client
268 /// .media()
269 /// .upload_preallocated(preallocated, &mime::IMAGE_JPEG, image)
270 /// .await?;
271 ///
272 /// # anyhow::Ok(()) };
273 /// ```
274 pub async fn create_content_uri(&self) -> Result<PreallocatedMxcUri> {
275 // Note: this request doesn't have any parameters.
276 let request = media::create_mxc_uri::v1::Request::default();
277
278 let response = self.client.send(request).await?;
279
280 Ok(PreallocatedMxcUri {
281 uri: response.content_uri,
282 expire_date: response.unused_expires_at,
283 })
284 }
285
286 /// Fills the content of a preallocated MXC URI with the given content type
287 /// and data.
288 ///
289 /// The URI must have been preallocated with [`Self::create_content_uri`].
290 /// See this method's documentation for a full example.
291 pub async fn upload_preallocated(
292 &self,
293 uri: PreallocatedMxcUri,
294 content_type: &Mime,
295 data: Vec<u8>,
296 ) -> Result<()> {
297 // Do a best-effort at reporting an expired MXC URI here; otherwise the server
298 // may complain about it later.
299 if let Some(expire_date) = uri.expire_date
300 && MilliSecondsSinceUnixEpoch::now() >= expire_date
301 {
302 return Err(Error::Media(MediaError::ExpiredPreallocatedMxcUri));
303 }
304
305 let timeout = std::cmp::max(
306 Duration::from_secs(data.len() as u64 / DEFAULT_UPLOAD_SPEED),
307 MIN_UPLOAD_REQUEST_TIMEOUT,
308 );
309
310 let request = assign!(media::create_content_async::v3::Request::from_url(&uri.uri, data)?, {
311 content_type: Some(content_type.as_ref().to_owned()),
312 });
313
314 let request_config = self.client.request_config().timeout(timeout);
315
316 if let Err(err) = self.client.send(request).with_request_config(request_config).await {
317 match err.client_api_error_kind() {
318 Some(ErrorKind::CannotOverwriteMedia) => {
319 Err(Error::Media(MediaError::CannotOverwriteMedia))
320 }
321
322 // Unfortunately, the spec says a server will return 404 for either an expired MXC
323 // ID or a non-existing MXC ID. Do a best-effort guess to recognize an expired MXC
324 // ID based on the error string, which will work with Synapse (as of 2024-10-23).
325 Some(ErrorKind::Unknown) if err.to_string().contains("expired") => {
326 Err(Error::Media(MediaError::ExpiredPreallocatedMxcUri))
327 }
328
329 _ => Err(err.into()),
330 }
331 } else {
332 Ok(())
333 }
334 }
335
336 /// Gets a media file by copying it to a temporary location on disk.
337 ///
338 /// The file won't be encrypted even if it is encrypted on the server.
339 ///
340 /// Returns a `MediaFileHandle` which takes ownership of the file. When the
341 /// handle is dropped, the file will be deleted from the temporary location.
342 ///
343 /// # Arguments
344 ///
345 /// * `request` - The `MediaRequest` of the content.
346 ///
347 /// * `filename` - The filename specified in the event. It is suggested to
348 /// use the `filename()` method on the event's content instead of using
349 /// the `filename` field directly. If not provided, a random name will be
350 /// generated.
351 ///
352 /// * `content_type` - The type of the media, this will be used to set the
353 /// temporary file's extension when one isn't included in the filename.
354 ///
355 /// * `use_cache` - If we should use the media cache for this request.
356 ///
357 /// * `temp_dir` - Path to a directory where temporary directories can be
358 /// created. If not provided, a default, global temporary directory will
359 /// be used; this may not work properly on Android, where the default
360 /// location may require root access on some older Android versions.
361 #[cfg(not(target_family = "wasm"))]
362 pub async fn get_media_file(
363 &self,
364 request: &MediaRequestParameters,
365 filename: Option<String>,
366 content_type: &Mime,
367 use_cache: bool,
368 temp_dir: Option<String>,
369 ) -> Result<MediaFileHandle> {
370 let data = self.get_media_content(request, use_cache).await?;
371
372 let inferred_extension = mime2ext::mime2ext(content_type);
373
374 let filename_as_path = filename.as_ref().map(Path::new);
375
376 let (sanitized_filename, filename_has_extension) = if let Some(path) = filename_as_path {
377 let sanitized_filename = path.file_name().and_then(|f| f.to_str());
378 let filename_has_extension = path.extension().is_some();
379 (sanitized_filename, filename_has_extension)
380 } else {
381 (None, false)
382 };
383
384 let (temp_file, temp_dir) =
385 match (sanitized_filename, filename_has_extension, inferred_extension) {
386 // If the file name has an extension use that
387 (Some(filename_with_extension), true, _) => {
388 // Use an intermediary directory to avoid conflicts
389 let temp_dir = temp_dir.map(TempDir::new_in).unwrap_or_else(TempDir::new)?;
390 let temp_file = TempFileBuilder::new()
391 .prefix(filename_with_extension)
392 .rand_bytes(0)
393 .tempfile_in(&temp_dir)?;
394 (temp_file, Some(temp_dir))
395 }
396 // If the file name doesn't have an extension try inferring one for it
397 (Some(filename), false, Some(inferred_extension)) => {
398 // Use an intermediary directory to avoid conflicts
399 let temp_dir = temp_dir.map(TempDir::new_in).unwrap_or_else(TempDir::new)?;
400 let temp_file = TempFileBuilder::new()
401 .prefix(filename)
402 .suffix(&(".".to_owned() + inferred_extension))
403 .rand_bytes(0)
404 .tempfile_in(&temp_dir)?;
405 (temp_file, Some(temp_dir))
406 }
407 // If the only thing we have is an inferred extension then use that together with a
408 // randomly generated file name
409 (None, _, Some(inferred_extension)) => (
410 TempFileBuilder::new()
411 .suffix(&&(".".to_owned() + inferred_extension))
412 .tempfile()?,
413 None,
414 ),
415 // Otherwise just use a completely random file name
416 _ => (TempFileBuilder::new().tempfile()?, None),
417 };
418
419 let mut file = TokioFile::from_std(temp_file.reopen()?);
420 file.write_all(&data).await?;
421 // Make sure the file metadata is flushed to disk.
422 file.sync_all().await?;
423
424 Ok(MediaFileHandle { file: temp_file, _directory: temp_dir })
425 }
426
427 /// Get a media file's content.
428 ///
429 /// If the content is encrypted and encryption is enabled, the content will
430 /// be decrypted.
431 ///
432 /// # Arguments
433 ///
434 /// * `request` - The `MediaRequest` of the content.
435 ///
436 /// * `use_cache` - If we should use the media cache for this request.
437 pub async fn get_media_content(
438 &self,
439 request: &MediaRequestParameters,
440 use_cache: bool,
441 ) -> Result<Vec<u8>> {
442 // Ignore request parameters for local medias, notably those pending in the send
443 // queue.
444 if let Some(uri) = Self::as_local_uri(&request.source) {
445 return self.get_local_media_content(uri).await;
446 }
447
448 // Read from the cache.
449 if use_cache
450 && let Some(content) =
451 self.client.media_store().lock().await?.get_media_content(request).await?
452 {
453 return Ok(content);
454 }
455
456 let content = self.media_fetcher.fetch_media_content(&self.client, request).await?;
457
458 if use_cache {
459 self.client
460 .media_store()
461 .lock()
462 .await?
463 .add_media_content(request, content.clone(), IgnoreMediaRetentionPolicy::No)
464 .await?;
465 }
466
467 Ok(content)
468 }
469
470 /// Get a media file's content that is only available in the media cache.
471 ///
472 /// # Arguments
473 ///
474 /// * `uri` - The local MXC URI of the media content.
475 async fn get_local_media_content(&self, uri: &MxcUri) -> Result<Vec<u8>> {
476 // Read from the cache.
477 self.client
478 .media_store()
479 .lock()
480 .await?
481 .get_media_content_for_uri(uri)
482 .await?
483 .ok_or_else(|| MediaError::LocalMediaNotFound.into())
484 }
485
486 /// Remove a media file's content from the store.
487 ///
488 /// # Arguments
489 ///
490 /// * `request` - The `MediaRequest` of the content.
491 pub async fn remove_media_content(&self, request: &MediaRequestParameters) -> Result<()> {
492 Ok(self.client.media_store().lock().await?.remove_media_content(request).await?)
493 }
494
495 /// Delete all the media content corresponding to the given
496 /// uri from the store.
497 ///
498 /// # Arguments
499 ///
500 /// * `uri` - The `MxcUri` of the files.
501 pub async fn remove_media_content_for_uri(&self, uri: &MxcUri) -> Result<()> {
502 Ok(self.client.media_store().lock().await?.remove_media_content_for_uri(uri).await?)
503 }
504
505 /// Get the file of the given media event content.
506 ///
507 /// If the content is encrypted and encryption is enabled, the content will
508 /// be decrypted.
509 ///
510 /// Returns `Ok(None)` if the event content has no file.
511 ///
512 /// This is a convenience method that calls the
513 /// [`get_media_content`](#method.get_media_content) method.
514 ///
515 /// # Arguments
516 ///
517 /// * `event_content` - The media event content.
518 ///
519 /// * `use_cache` - If we should use the media cache for this file.
520 pub async fn get_file(
521 &self,
522 event_content: &impl MediaEventContent,
523 use_cache: bool,
524 ) -> Result<Option<Vec<u8>>> {
525 let Some(source) = event_content.source() else { return Ok(None) };
526 let file = self
527 .get_media_content(
528 &MediaRequestParameters { source, format: MediaFormat::File },
529 use_cache,
530 )
531 .await?;
532 Ok(Some(file))
533 }
534
535 /// Remove the file of the given media event content from the cache.
536 ///
537 /// This is a convenience method that calls the
538 /// [`remove_media_content`](#method.remove_media_content) method.
539 ///
540 /// # Arguments
541 ///
542 /// * `event_content` - The media event content.
543 pub async fn remove_file(&self, event_content: &impl MediaEventContent) -> Result<()> {
544 if let Some(source) = event_content.source() {
545 self.remove_media_content(&MediaRequestParameters {
546 source,
547 format: MediaFormat::File,
548 })
549 .await?;
550 }
551
552 Ok(())
553 }
554
555 /// Get a thumbnail of the given media event content.
556 ///
557 /// If the content is encrypted and encryption is enabled, the content will
558 /// be decrypted.
559 ///
560 /// Returns `Ok(None)` if the event content has no thumbnail.
561 ///
562 /// This is a convenience method that calls the
563 /// [`get_media_content`](#method.get_media_content) method.
564 ///
565 /// # Arguments
566 ///
567 /// * `event_content` - The media event content.
568 ///
569 /// * `settings` - The _desired_ settings of the thumbnail. The actual
570 /// thumbnail may not match the settings specified.
571 ///
572 /// * `use_cache` - If we should use the media cache for this thumbnail.
573 pub async fn get_thumbnail(
574 &self,
575 event_content: &impl MediaEventContent,
576 settings: MediaThumbnailSettings,
577 use_cache: bool,
578 ) -> Result<Option<Vec<u8>>> {
579 let Some(source) = event_content.thumbnail_source() else { return Ok(None) };
580 let thumbnail = self
581 .get_media_content(
582 &MediaRequestParameters { source, format: MediaFormat::Thumbnail(settings) },
583 use_cache,
584 )
585 .await?;
586 Ok(Some(thumbnail))
587 }
588
589 /// Remove the thumbnail of the given media event content from the cache.
590 ///
591 /// This is a convenience method that calls the
592 /// [`remove_media_content`](#method.remove_media_content) method.
593 ///
594 /// # Arguments
595 ///
596 /// * `event_content` - The media event content.
597 ///
598 /// * `size` - The _desired_ settings of the thumbnail. Must match the
599 /// settings requested with [`get_thumbnail`](#method.get_thumbnail).
600 pub async fn remove_thumbnail(
601 &self,
602 event_content: &impl MediaEventContent,
603 settings: MediaThumbnailSettings,
604 ) -> Result<()> {
605 if let Some(source) = event_content.source() {
606 self.remove_media_content(&MediaRequestParameters {
607 source,
608 format: MediaFormat::Thumbnail(settings),
609 })
610 .await?
611 }
612
613 Ok(())
614 }
615
616 /// Set the [`MediaRetentionPolicy`] to use for deciding whether to store or
617 /// keep media content.
618 ///
619 /// It is used:
620 ///
621 /// * When a media needs to be cached, to check that it does not exceed the
622 /// max file size.
623 ///
624 /// * When [`Media::clean()`], to check that all media content in the store
625 /// fits those criteria.
626 ///
627 /// To apply the new policy to the media cache right away,
628 /// [`Media::clean()`] should be called after this.
629 ///
630 /// By default, an empty `MediaRetentionPolicy` is used, which means that no
631 /// criteria are applied.
632 ///
633 /// # Arguments
634 ///
635 /// * `policy` - The `MediaRetentionPolicy` to use.
636 pub async fn set_media_retention_policy(&self, policy: MediaRetentionPolicy) -> Result<()> {
637 self.client.media_store().lock().await?.set_media_retention_policy(policy).await?;
638 Ok(())
639 }
640
641 /// Get the current `MediaRetentionPolicy`.
642 pub async fn media_retention_policy(&self) -> Result<MediaRetentionPolicy> {
643 Ok(self.client.media_store().lock().await?.media_retention_policy())
644 }
645
646 /// Clean up the media cache with the current [`MediaRetentionPolicy`].
647 ///
648 /// If there is already an ongoing cleanup, this is a noop.
649 pub async fn clean(&self) -> Result<()> {
650 self.client.media_store().lock().await?.clean().await?;
651 Ok(())
652 }
653
654 /// Upload the file bytes in `data` and return the source information.
655 pub(crate) async fn upload_plain_media_and_thumbnail(
656 &self,
657 content_type: &Mime,
658 data: Vec<u8>,
659 thumbnail: Option<Thumbnail>,
660 send_progress: SharedObservable<TransmissionProgress>,
661 ) -> Result<(MediaSource, Option<(MediaSource, Box<ThumbnailInfo>)>)> {
662 let upload_thumbnail = self.upload_thumbnail(thumbnail, send_progress.clone());
663
664 let upload_attachment = async move {
665 self.upload(content_type, data, None).with_send_progress_observable(send_progress).await
666 };
667
668 let (thumbnail, response) = try_join(upload_thumbnail, upload_attachment).await?;
669
670 Ok((MediaSource::Plain(response.content_uri), thumbnail))
671 }
672
673 /// Uploads an unencrypted thumbnail to the media repository, and returns
674 /// its source and extra information.
675 async fn upload_thumbnail(
676 &self,
677 thumbnail: Option<Thumbnail>,
678 send_progress: SharedObservable<TransmissionProgress>,
679 ) -> Result<Option<(MediaSource, Box<ThumbnailInfo>)>> {
680 let Some(thumbnail) = thumbnail else {
681 return Ok(None);
682 };
683
684 let (data, content_type, thumbnail_info) = thumbnail.into_parts();
685
686 let response = self
687 .upload(&content_type, data, None)
688 .with_send_progress_observable(send_progress)
689 .await?;
690 let url = response.content_uri;
691
692 Ok(Some((MediaSource::Plain(url), thumbnail_info)))
693 }
694
695 /// Create an [`OwnedMxcUri`] for a file or thumbnail we want to store
696 /// locally before sending it.
697 ///
698 /// This uses a MXC ID that is only locally valid.
699 pub(crate) fn make_local_uri(txn_id: &TransactionId) -> OwnedMxcUri {
700 OwnedMxcUri::from(format!("mxc://{LOCAL_MXC_SERVER_NAME}/{txn_id}"))
701 }
702
703 /// Create a [`MediaRequest`] for a file we want to store locally before
704 /// sending it.
705 ///
706 /// This uses a MXC ID that is only locally valid.
707 pub(crate) fn make_local_file_media_request(txn_id: &TransactionId) -> MediaRequestParameters {
708 MediaRequestParameters {
709 source: MediaSource::Plain(Self::make_local_uri(txn_id)),
710 format: MediaFormat::File,
711 }
712 }
713
714 /// Returns the local MXC URI contained by the given source, if any.
715 ///
716 /// A local MXC URI is a URI that was generated with `make_local_uri`.
717 fn as_local_uri(source: &MediaSource) -> Option<&MxcUri> {
718 let uri = match source {
719 MediaSource::Plain(uri) => uri,
720 MediaSource::Encrypted(file) => &file.url,
721 };
722
723 uri.server_name()
724 .is_ok_and(|server_name| server_name == LOCAL_MXC_SERVER_NAME)
725 .then_some(uri)
726 }
727}
728
729/// A [`MediaFetcher`] that uses the default media/authenticated media endpoints
730/// to fetch new media.
731#[derive(Debug, Clone)]
732pub(crate) struct DefaultMediaFetcher;
733
734impl MediaFetcher for DefaultMediaFetcher {
735 fn fetch_media_content<'a>(
736 &'a self,
737 client: &'a Client,
738 request: &'a MediaRequestParameters,
739 ) -> BoxFuture<'a, Result<Vec<u8>, Error>> {
740 Box::pin(async move {
741 let request_config = client
742 .request_config()
743 // Downloading a file should have no timeout as we don't know the network
744 // connectivity available for the user or the file size
745 .timeout(Some(Duration::MAX));
746
747 // Use the authenticated endpoints when the server supports it.
748 let supported_versions = client.supported_versions().await?;
749
750 let use_auth = authenticated_media::get_content::v1::Request::PATH_BUILDER
751 .is_supported(&supported_versions);
752
753 match &request.source {
754 MediaSource::Encrypted(file) => {
755 let content = if use_auth {
756 let request =
757 authenticated_media::get_content::v1::Request::from_uri(&file.url)?;
758 client.send(request).with_request_config(request_config).await?.file
759 } else {
760 #[allow(deprecated)]
761 let request = media::get_content::v3::Request::from_url(&file.url)?;
762 client.send(request).with_request_config(request_config).await?.file
763 };
764
765 #[cfg(feature = "e2e-encryption")]
766 let content = {
767 let content_len = content.len();
768 let mut cursor = std::io::Cursor::new(content);
769 let mut reader = matrix_sdk_base::crypto::AttachmentDecryptor::new(
770 &mut cursor,
771 file.as_ref().clone().into(),
772 )?;
773
774 // Encrypted size should be the same as the decrypted size,
775 // rounded up to a cipher block.
776 let mut decrypted = Vec::with_capacity(content_len);
777
778 reader.read_to_end(&mut decrypted)?;
779
780 decrypted
781 };
782
783 Ok(content)
784 }
785
786 MediaSource::Plain(uri) => {
787 if let MediaFormat::Thumbnail(settings) = &request.format {
788 if use_auth {
789 let mut request =
790 authenticated_media::get_content_thumbnail::v1::Request::from_uri(
791 uri,
792 settings.width,
793 settings.height,
794 )?;
795 request.method = Some(settings.method.clone());
796 request.animated = Some(settings.animated);
797
798 Ok(client.send(request).with_request_config(request_config).await?.file)
799 } else {
800 #[allow(deprecated)]
801 let request = {
802 let mut request =
803 media::get_content_thumbnail::v3::Request::from_url(
804 uri,
805 settings.width,
806 settings.height,
807 )?;
808 request.method = Some(settings.method.clone());
809 request.animated = Some(settings.animated);
810 request
811 };
812
813 Ok(client.send(request).with_request_config(request_config).await?.file)
814 }
815 } else if use_auth {
816 let request = authenticated_media::get_content::v1::Request::from_uri(uri)?;
817 Ok(client.send(request).with_request_config(request_config).await?.file)
818 } else {
819 #[allow(deprecated)]
820 let request = media::get_content::v3::Request::from_url(uri)?;
821 Ok(client.send(request).with_request_config(request_config).await?.file)
822 }
823 }
824 }
825 })
826 }
827}
828
829#[cfg(test)]
830mod tests {
831 use assert_matches2::assert_matches;
832 use ruma::{
833 MxcUri,
834 events::room::{EncryptedFile, MediaSource},
835 mxc_uri, owned_mxc_uri,
836 };
837 use serde_json::json;
838
839 use super::Media;
840
841 /// Create an `EncryptedFile` with the given MXC URI.
842 fn encrypted_file(mxc_uri: &MxcUri) -> Box<EncryptedFile> {
843 Box::new(
844 serde_json::from_value(json!({
845 "url": mxc_uri,
846 "key": {
847 "kty": "oct",
848 "key_ops": ["encrypt", "decrypt"],
849 "alg": "A256CTR",
850 "k": "b50ACIv6LMn9AfMCFD1POJI_UAFWIclxAN1kWrEO2X8",
851 "ext": true,
852 },
853 "iv": "AK1wyzigZtQAAAABAAAAKK",
854 "hashes": {
855 "sha256": "/NogKqW5bz/m8xHgFiH5haFGjCNVmUIPLzfvOhHdrxY",
856 },
857 "v": "v2",
858 }))
859 .unwrap(),
860 )
861 }
862
863 #[test]
864 fn test_as_local_uri() {
865 let txn_id = "abcdef";
866
867 // Request generated with `make_local_file_media_request`.
868 let request = Media::make_local_file_media_request(txn_id.into());
869 assert_matches!(Media::as_local_uri(&request.source), Some(uri));
870 assert_eq!(uri.media_id(), Ok(txn_id));
871
872 // Local plain source.
873 let source = MediaSource::Plain(Media::make_local_uri(txn_id.into()));
874 assert_matches!(Media::as_local_uri(&source), Some(uri));
875 assert_eq!(uri.media_id(), Ok(txn_id));
876
877 // Local encrypted source.
878 let source = MediaSource::Encrypted(encrypted_file(&Media::make_local_uri(txn_id.into())));
879 assert_matches!(Media::as_local_uri(&source), Some(uri));
880 assert_eq!(uri.media_id(), Ok(txn_id));
881
882 // Test non-local plain source.
883 let source = MediaSource::Plain(owned_mxc_uri!("mxc://server.local/poiuyt"));
884 assert_matches!(Media::as_local_uri(&source), None);
885
886 // Test non-local encrypted source.
887 let source = MediaSource::Encrypted(encrypted_file(mxc_uri!("mxc://server.local/mlkjhg")));
888 assert_matches!(Media::as_local_uri(&source), None);
889
890 // Test invalid MXC URI.
891 let source = MediaSource::Plain("https://server.local/nbvcxw".into());
892 assert_matches!(Media::as_local_uri(&source), None);
893 }
894}