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