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