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