Skip to main content

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}