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