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