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