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 OutgoingRequest,
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 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 let use_auth =
447 authenticated_media::get_content::v1::Request::is_supported(&supported_versions);
448
449 let content: Vec<u8> = match &request.source {
450 MediaSource::Encrypted(file) => {
451 let content = if use_auth {
452 let request =
453 authenticated_media::get_content::v1::Request::from_uri(&file.url)?;
454 self.client.send(request).with_request_config(request_config).await?.file
455 } else {
456 #[allow(deprecated)]
457 let request = media::get_content::v3::Request::from_url(&file.url)?;
458 self.client.send(request).with_request_config(request_config).await?.file
459 };
460
461 #[cfg(feature = "e2e-encryption")]
462 let content = {
463 let content_len = content.len();
464 let mut cursor = std::io::Cursor::new(content);
465 let mut reader = matrix_sdk_base::crypto::AttachmentDecryptor::new(
466 &mut cursor,
467 file.as_ref().clone().into(),
468 )?;
469
470 // Encrypted size should be the same as the decrypted size,
471 // rounded up to a cipher block.
472 let mut decrypted = Vec::with_capacity(content_len);
473
474 reader.read_to_end(&mut decrypted)?;
475
476 decrypted
477 };
478
479 content
480 }
481
482 MediaSource::Plain(uri) => {
483 if let MediaFormat::Thumbnail(settings) = &request.format {
484 if use_auth {
485 let mut request =
486 authenticated_media::get_content_thumbnail::v1::Request::from_uri(
487 uri,
488 settings.width,
489 settings.height,
490 )?;
491 request.method = Some(settings.method.clone());
492 request.animated = Some(settings.animated);
493
494 self.client.send(request).with_request_config(request_config).await?.file
495 } else {
496 #[allow(deprecated)]
497 let request = {
498 let mut request = media::get_content_thumbnail::v3::Request::from_url(
499 uri,
500 settings.width,
501 settings.height,
502 )?;
503 request.method = Some(settings.method.clone());
504 request.animated = Some(settings.animated);
505 request
506 };
507
508 self.client.send(request).with_request_config(request_config).await?.file
509 }
510 } else if use_auth {
511 let request = authenticated_media::get_content::v1::Request::from_uri(uri)?;
512 self.client.send(request).with_request_config(request_config).await?.file
513 } else {
514 #[allow(deprecated)]
515 let request = media::get_content::v3::Request::from_url(uri)?;
516 self.client.send(request).with_request_config(request_config).await?.file
517 }
518 }
519 };
520
521 if use_cache {
522 self.client
523 .event_cache_store()
524 .lock()
525 .await?
526 .add_media_content(request, content.clone(), IgnoreMediaRetentionPolicy::No)
527 .await?;
528 }
529
530 Ok(content)
531 }
532
533 /// Get a media file's content that is only available in the media cache.
534 ///
535 /// # Arguments
536 ///
537 /// * `uri` - The local MXC URI of the media content.
538 async fn get_local_media_content(&self, uri: &MxcUri) -> Result<Vec<u8>> {
539 // Read from the cache.
540 self.client
541 .event_cache_store()
542 .lock()
543 .await?
544 .get_media_content_for_uri(uri)
545 .await?
546 .ok_or_else(|| MediaError::LocalMediaNotFound.into())
547 }
548
549 /// Remove a media file's content from the store.
550 ///
551 /// # Arguments
552 ///
553 /// * `request` - The `MediaRequest` of the content.
554 pub async fn remove_media_content(&self, request: &MediaRequestParameters) -> Result<()> {
555 Ok(self.client.event_cache_store().lock().await?.remove_media_content(request).await?)
556 }
557
558 /// Delete all the media content corresponding to the given
559 /// uri from the store.
560 ///
561 /// # Arguments
562 ///
563 /// * `uri` - The `MxcUri` of the files.
564 pub async fn remove_media_content_for_uri(&self, uri: &MxcUri) -> Result<()> {
565 Ok(self.client.event_cache_store().lock().await?.remove_media_content_for_uri(uri).await?)
566 }
567
568 /// Get the file of the given media event content.
569 ///
570 /// If the content is encrypted and encryption is enabled, the content will
571 /// be decrypted.
572 ///
573 /// Returns `Ok(None)` if the event content has no file.
574 ///
575 /// This is a convenience method that calls the
576 /// [`get_media_content`](#method.get_media_content) method.
577 ///
578 /// # Arguments
579 ///
580 /// * `event_content` - The media event content.
581 ///
582 /// * `use_cache` - If we should use the media cache for this file.
583 pub async fn get_file(
584 &self,
585 event_content: &impl MediaEventContent,
586 use_cache: bool,
587 ) -> Result<Option<Vec<u8>>> {
588 let Some(source) = event_content.source() else { return Ok(None) };
589 let file = self
590 .get_media_content(
591 &MediaRequestParameters { source, format: MediaFormat::File },
592 use_cache,
593 )
594 .await?;
595 Ok(Some(file))
596 }
597
598 /// Remove the file of the given media event content from the cache.
599 ///
600 /// This is a convenience method that calls the
601 /// [`remove_media_content`](#method.remove_media_content) method.
602 ///
603 /// # Arguments
604 ///
605 /// * `event_content` - The media event content.
606 pub async fn remove_file(&self, event_content: &impl MediaEventContent) -> Result<()> {
607 if let Some(source) = event_content.source() {
608 self.remove_media_content(&MediaRequestParameters {
609 source,
610 format: MediaFormat::File,
611 })
612 .await?;
613 }
614
615 Ok(())
616 }
617
618 /// Get a thumbnail of the given media event content.
619 ///
620 /// If the content is encrypted and encryption is enabled, the content will
621 /// be decrypted.
622 ///
623 /// Returns `Ok(None)` if the event content has no thumbnail.
624 ///
625 /// This is a convenience method that calls the
626 /// [`get_media_content`](#method.get_media_content) method.
627 ///
628 /// # Arguments
629 ///
630 /// * `event_content` - The media event content.
631 ///
632 /// * `settings` - The _desired_ settings of the thumbnail. The actual
633 /// thumbnail may not match the settings specified.
634 ///
635 /// * `use_cache` - If we should use the media cache for this thumbnail.
636 pub async fn get_thumbnail(
637 &self,
638 event_content: &impl MediaEventContent,
639 settings: MediaThumbnailSettings,
640 use_cache: bool,
641 ) -> Result<Option<Vec<u8>>> {
642 let Some(source) = event_content.thumbnail_source() else { return Ok(None) };
643 let thumbnail = self
644 .get_media_content(
645 &MediaRequestParameters { source, format: MediaFormat::Thumbnail(settings) },
646 use_cache,
647 )
648 .await?;
649 Ok(Some(thumbnail))
650 }
651
652 /// Remove the thumbnail of the given media event content from the cache.
653 ///
654 /// This is a convenience method that calls the
655 /// [`remove_media_content`](#method.remove_media_content) method.
656 ///
657 /// # Arguments
658 ///
659 /// * `event_content` - The media event content.
660 ///
661 /// * `size` - The _desired_ settings of the thumbnail. Must match the
662 /// settings requested with [`get_thumbnail`](#method.get_thumbnail).
663 pub async fn remove_thumbnail(
664 &self,
665 event_content: &impl MediaEventContent,
666 settings: MediaThumbnailSettings,
667 ) -> Result<()> {
668 if let Some(source) = event_content.source() {
669 self.remove_media_content(&MediaRequestParameters {
670 source,
671 format: MediaFormat::Thumbnail(settings),
672 })
673 .await?
674 }
675
676 Ok(())
677 }
678
679 /// Set the [`MediaRetentionPolicy`] to use for deciding whether to store or
680 /// keep media content.
681 ///
682 /// It is used:
683 ///
684 /// * When a media needs to be cached, to check that it does not exceed the
685 /// max file size.
686 ///
687 /// * When [`Media::clean_up_media_cache()`], to check that all media
688 /// content in the store fits those criteria.
689 ///
690 /// To apply the new policy to the media cache right away,
691 /// [`Media::clean_up_media_cache()`] should be called after this.
692 ///
693 /// By default, an empty `MediaRetentionPolicy` is used, which means that no
694 /// criteria are applied.
695 ///
696 /// # Arguments
697 ///
698 /// * `policy` - The `MediaRetentionPolicy` to use.
699 pub async fn set_media_retention_policy(&self, policy: MediaRetentionPolicy) -> Result<()> {
700 self.client.event_cache_store().lock().await?.set_media_retention_policy(policy).await?;
701 Ok(())
702 }
703
704 /// Get the current `MediaRetentionPolicy`.
705 pub async fn media_retention_policy(&self) -> Result<MediaRetentionPolicy> {
706 Ok(self.client.event_cache_store().lock().await?.media_retention_policy())
707 }
708
709 /// Clean up the media cache with the current [`MediaRetentionPolicy`].
710 ///
711 /// If there is already an ongoing cleanup, this is a noop.
712 pub async fn clean_up_media_cache(&self) -> Result<()> {
713 self.client.event_cache_store().lock().await?.clean_up_media_cache().await?;
714 Ok(())
715 }
716
717 /// Upload the file bytes in `data` and return the source information.
718 pub(crate) async fn upload_plain_media_and_thumbnail(
719 &self,
720 content_type: &Mime,
721 data: Vec<u8>,
722 thumbnail: Option<Thumbnail>,
723 send_progress: SharedObservable<TransmissionProgress>,
724 ) -> Result<(MediaSource, Option<(MediaSource, Box<ThumbnailInfo>)>)> {
725 let upload_thumbnail = self.upload_thumbnail(thumbnail, send_progress.clone());
726
727 let upload_attachment = async move {
728 self.upload(content_type, data, None).with_send_progress_observable(send_progress).await
729 };
730
731 let (thumbnail, response) = try_join(upload_thumbnail, upload_attachment).await?;
732
733 Ok((MediaSource::Plain(response.content_uri), thumbnail))
734 }
735
736 /// Uploads an unencrypted thumbnail to the media repository, and returns
737 /// its source and extra information.
738 async fn upload_thumbnail(
739 &self,
740 thumbnail: Option<Thumbnail>,
741 send_progress: SharedObservable<TransmissionProgress>,
742 ) -> Result<Option<(MediaSource, Box<ThumbnailInfo>)>> {
743 let Some(thumbnail) = thumbnail else {
744 return Ok(None);
745 };
746
747 let (data, content_type, thumbnail_info) = thumbnail.into_parts();
748
749 let response = self
750 .upload(&content_type, data, None)
751 .with_send_progress_observable(send_progress)
752 .await?;
753 let url = response.content_uri;
754
755 Ok(Some((MediaSource::Plain(url), thumbnail_info)))
756 }
757
758 /// Create an [`OwnedMxcUri`] for a file or thumbnail we want to store
759 /// locally before sending it.
760 ///
761 /// This uses a MXC ID that is only locally valid.
762 pub(crate) fn make_local_uri(txn_id: &TransactionId) -> OwnedMxcUri {
763 OwnedMxcUri::from(format!("mxc://{LOCAL_MXC_SERVER_NAME}/{txn_id}"))
764 }
765
766 /// Create a [`MediaRequest`] for a file we want to store locally before
767 /// sending it.
768 ///
769 /// This uses a MXC ID that is only locally valid.
770 pub(crate) fn make_local_file_media_request(txn_id: &TransactionId) -> MediaRequestParameters {
771 MediaRequestParameters {
772 source: MediaSource::Plain(Self::make_local_uri(txn_id)),
773 format: MediaFormat::File,
774 }
775 }
776
777 /// Create a [`MediaRequest`] for a file we want to store locally before
778 /// sending it.
779 ///
780 /// This uses a MXC ID that is only locally valid.
781 pub(crate) fn make_local_thumbnail_media_request(
782 txn_id: &TransactionId,
783 height: UInt,
784 width: UInt,
785 ) -> MediaRequestParameters {
786 MediaRequestParameters {
787 source: MediaSource::Plain(Self::make_local_uri(txn_id)),
788 format: MediaFormat::Thumbnail(MediaThumbnailSettings::new(width, height)),
789 }
790 }
791
792 /// Returns the local MXC URI contained by the given source, if any.
793 ///
794 /// A local MXC URI is a URI that was generated with `make_local_uri`.
795 fn as_local_uri(source: &MediaSource) -> Option<&MxcUri> {
796 let uri = match source {
797 MediaSource::Plain(uri) => uri,
798 MediaSource::Encrypted(file) => &file.url,
799 };
800
801 uri.server_name()
802 .is_ok_and(|server_name| server_name == LOCAL_MXC_SERVER_NAME)
803 .then_some(uri)
804 }
805}
806
807#[cfg(test)]
808mod tests {
809 use assert_matches2::assert_matches;
810 use ruma::{
811 events::room::{EncryptedFile, MediaSource},
812 mxc_uri, owned_mxc_uri, uint, MxcUri,
813 };
814 use serde_json::json;
815
816 use super::Media;
817
818 /// Create an `EncryptedFile` with the given MXC URI.
819 fn encrypted_file(mxc_uri: &MxcUri) -> Box<EncryptedFile> {
820 Box::new(
821 serde_json::from_value(json!({
822 "url": mxc_uri,
823 "key": {
824 "kty": "oct",
825 "key_ops": ["encrypt", "decrypt"],
826 "alg": "A256CTR",
827 "k": "b50ACIv6LMn9AfMCFD1POJI_UAFWIclxAN1kWrEO2X8",
828 "ext": true,
829 },
830 "iv": "AK1wyzigZtQAAAABAAAAKK",
831 "hashes": {
832 "sha256": "foobar",
833 },
834 "v": "v2",
835 }))
836 .unwrap(),
837 )
838 }
839
840 #[test]
841 fn test_as_local_uri() {
842 let txn_id = "abcdef";
843
844 // Request generated with `make_local_file_media_request`.
845 let request = Media::make_local_file_media_request(txn_id.into());
846 assert_matches!(Media::as_local_uri(&request.source), Some(uri));
847 assert_eq!(uri.media_id(), Ok(txn_id));
848
849 // Request generated with `make_local_thumbnail_media_request`.
850 let request =
851 Media::make_local_thumbnail_media_request(txn_id.into(), uint!(100), uint!(100));
852 assert_matches!(Media::as_local_uri(&request.source), Some(uri));
853 assert_eq!(uri.media_id(), Ok(txn_id));
854
855 // Local plain source.
856 let source = MediaSource::Plain(Media::make_local_uri(txn_id.into()));
857 assert_matches!(Media::as_local_uri(&source), Some(uri));
858 assert_eq!(uri.media_id(), Ok(txn_id));
859
860 // Local encrypted source.
861 let source = MediaSource::Encrypted(encrypted_file(&Media::make_local_uri(txn_id.into())));
862 assert_matches!(Media::as_local_uri(&source), Some(uri));
863 assert_eq!(uri.media_id(), Ok(txn_id));
864
865 // Test non-local plain source.
866 let source = MediaSource::Plain(owned_mxc_uri!("mxc://server.local/poiuyt"));
867 assert_matches!(Media::as_local_uri(&source), None);
868
869 // Test non-local encrypted source.
870 let source = MediaSource::Encrypted(encrypted_file(mxc_uri!("mxc://server.local/mlkjhg")));
871 assert_matches!(Media::as_local_uri(&source), None);
872
873 // Test invalid MXC URI.
874 let source = MediaSource::Plain("https://server.local/nbvcxw".into());
875 assert_matches!(Media::as_local_uri(&source), None);
876 }
877}