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