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}