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