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