matrix_sdk_indexeddb/media_store/
mod.rs

1// Copyright 2025 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License
14
15// Allow dead code here, as this module is still in the process
16// of being developed, so some functions will be used later on.
17// Once development is complete, we can remove this line and
18// clean up any unused code.
19#![allow(dead_code)]
20
21mod builder;
22mod error;
23mod migrations;
24mod serializer;
25mod transaction;
26mod types;
27use std::{rc::Rc, time::Duration};
28
29pub use builder::IndexeddbMediaStoreBuilder;
30pub use error::IndexeddbMediaStoreError;
31use indexed_db_futures::{
32    Build, cursor::CursorDirection, database::Database, transaction::TransactionMode,
33};
34#[cfg(target_family = "wasm")]
35use matrix_sdk_base::cross_process_lock::{
36    CrossProcessLockGeneration, FIRST_CROSS_PROCESS_LOCK_GENERATION,
37};
38use matrix_sdk_base::{
39    media::{
40        MediaRequestParameters,
41        store::{
42            IgnoreMediaRetentionPolicy, MediaRetentionPolicy, MediaService, MediaStore,
43            MediaStoreInner,
44        },
45    },
46    timer,
47};
48use ruma::{MilliSecondsSinceUnixEpoch, MxcUri, time::SystemTime};
49use tracing::instrument;
50
51use crate::{
52    media_store::{
53        transaction::IndexeddbMediaStoreTransaction,
54        types::{Lease, Media, MediaCleanupTime, MediaContent, MediaMetadata, UnixTime},
55    },
56    serializer::indexed_type::{IndexedTypeSerializer, traits::Indexed},
57    transaction::TransactionError,
58};
59
60/// A type for providing an IndexedDB implementation of [`MediaStore`][1].
61/// This is meant to be used as a backend to [`MediaStore`][1] in browser
62/// contexts.
63///
64/// [1]: matrix_sdk_base::media::store::MediaStore
65#[derive(Debug, Clone)]
66pub struct IndexeddbMediaStore {
67    // A handle to the IndexedDB database
68    inner: Rc<Database>,
69    // A serializer with functionality tailored to `IndexeddbMediaStore`
70    serializer: IndexedTypeSerializer,
71    // A service for conveniently delegating media-related queries to an `MediaStoreInner`
72    // implementation
73    media_service: MediaService,
74}
75
76impl IndexeddbMediaStore {
77    /// Provides a type with which to conveniently build an
78    /// [`IndexeddbMediaStore`]
79    pub fn builder() -> IndexeddbMediaStoreBuilder {
80        IndexeddbMediaStoreBuilder::default()
81    }
82
83    /// Initializes a new transaction on the underlying IndexedDB database and
84    /// returns a handle which can be used to combine database operations
85    /// into an atomic unit.
86    pub fn transaction<'a>(
87        &'a self,
88        stores: &[&str],
89        mode: TransactionMode,
90    ) -> Result<IndexeddbMediaStoreTransaction<'a>, IndexeddbMediaStoreError> {
91        Ok(IndexeddbMediaStoreTransaction::new(
92            self.inner
93                .transaction(stores)
94                .with_mode(mode)
95                .build()
96                .map_err(TransactionError::from)?,
97            &self.serializer,
98        ))
99    }
100}
101
102#[cfg(target_family = "wasm")]
103#[async_trait::async_trait(?Send)]
104impl MediaStore for IndexeddbMediaStore {
105    type Error = IndexeddbMediaStoreError;
106
107    #[instrument(skip(self))]
108    async fn try_take_leased_lock(
109        &self,
110        lease_duration_ms: u32,
111        key: &str,
112        holder: &str,
113    ) -> Result<Option<CrossProcessLockGeneration>, IndexeddbMediaStoreError> {
114        let transaction = self.transaction(&[Lease::OBJECT_STORE], TransactionMode::Readwrite)?;
115
116        let now = Duration::from_millis(MilliSecondsSinceUnixEpoch::now().get().into());
117        let expiration = now + Duration::from_millis(lease_duration_ms.into());
118
119        let lease = match transaction.get_lease_by_id(key).await? {
120            Some(mut lease) => {
121                if lease.holder == holder {
122                    // We had the lease before, extend it.
123                    lease.expiration = expiration;
124
125                    Some(lease)
126                } else {
127                    // We didn't have it.
128                    if lease.expiration < now {
129                        // Steal it!
130                        lease.holder = holder.to_owned();
131                        lease.expiration = expiration;
132                        lease.generation += 1;
133
134                        Some(lease)
135                    } else {
136                        // We tried our best.
137                        None
138                    }
139                }
140            }
141            None => {
142                let lease = Lease {
143                    key: key.to_owned(),
144                    holder: holder.to_owned(),
145                    expiration,
146                    generation: FIRST_CROSS_PROCESS_LOCK_GENERATION,
147                };
148
149                Some(lease)
150            }
151        };
152
153        Ok(if let Some(lease) = lease {
154            transaction.put_lease(&lease).await?;
155            transaction.commit().await?;
156
157            Some(lease.generation)
158        } else {
159            None
160        })
161    }
162
163    #[instrument(skip_all)]
164    async fn add_media_content(
165        &self,
166        request: &MediaRequestParameters,
167        content: Vec<u8>,
168        ignore_policy: IgnoreMediaRetentionPolicy,
169    ) -> Result<(), IndexeddbMediaStoreError> {
170        let _timer = timer!("method");
171        self.media_service.add_media_content(self, request, content, ignore_policy).await
172    }
173
174    #[instrument(skip_all)]
175    async fn replace_media_key(
176        &self,
177        from: &MediaRequestParameters,
178        to: &MediaRequestParameters,
179    ) -> Result<(), IndexeddbMediaStoreError> {
180        let _timer = timer!("method");
181
182        let transaction =
183            self.transaction(&[MediaMetadata::OBJECT_STORE], TransactionMode::Readwrite)?;
184        if let Some(mut metadata) = transaction.get_media_metadata_by_id(from).await? {
185            // delete before adding, in case `from` and `to` generate the same key
186            transaction.delete_media_metadata_by_id(from).await?;
187            metadata.request_parameters = to.clone();
188            transaction.add_media_metadata(&metadata).await?;
189            transaction.commit().await?;
190        }
191        Ok(())
192    }
193
194    #[instrument(skip_all)]
195    async fn get_media_content(
196        &self,
197        request: &MediaRequestParameters,
198    ) -> Result<Option<Vec<u8>>, IndexeddbMediaStoreError> {
199        let _timer = timer!("method");
200        self.media_service.get_media_content(self, request).await
201    }
202
203    #[instrument(skip_all)]
204    async fn remove_media_content(
205        &self,
206        request: &MediaRequestParameters,
207    ) -> Result<(), IndexeddbMediaStoreError> {
208        let _timer = timer!("method");
209
210        let transaction = self.transaction(
211            &[MediaMetadata::OBJECT_STORE, MediaContent::OBJECT_STORE],
212            TransactionMode::Readwrite,
213        )?;
214        transaction.delete_media_by_id(request).await?;
215        transaction.commit().await.map_err(Into::into)
216    }
217
218    #[instrument(skip(self))]
219    async fn get_media_content_for_uri(
220        &self,
221        uri: &MxcUri,
222    ) -> Result<Option<Vec<u8>>, IndexeddbMediaStoreError> {
223        let _timer = timer!("method");
224        self.media_service.get_media_content_for_uri(self, uri).await
225    }
226
227    #[instrument(skip(self))]
228    async fn remove_media_content_for_uri(
229        &self,
230        uri: &MxcUri,
231    ) -> Result<(), IndexeddbMediaStoreError> {
232        let _timer = timer!("method");
233
234        let transaction = self.transaction(
235            &[MediaMetadata::OBJECT_STORE, MediaContent::OBJECT_STORE],
236            TransactionMode::Readwrite,
237        )?;
238        transaction.delete_media_by_uri(uri).await?;
239        transaction.commit().await.map_err(Into::into)
240    }
241
242    #[instrument(skip_all)]
243    async fn set_media_retention_policy(
244        &self,
245        policy: MediaRetentionPolicy,
246    ) -> Result<(), IndexeddbMediaStoreError> {
247        let _timer = timer!("method");
248        self.media_service.set_media_retention_policy(self, policy).await
249    }
250
251    #[instrument(skip_all)]
252    fn media_retention_policy(&self) -> MediaRetentionPolicy {
253        let _timer = timer!("method");
254        self.media_service.media_retention_policy()
255    }
256
257    #[instrument(skip_all)]
258    async fn set_ignore_media_retention_policy(
259        &self,
260        request: &MediaRequestParameters,
261        ignore_policy: IgnoreMediaRetentionPolicy,
262    ) -> Result<(), IndexeddbMediaStoreError> {
263        let _timer = timer!("method");
264        self.media_service.set_ignore_media_retention_policy(self, request, ignore_policy).await
265    }
266
267    #[instrument(skip_all)]
268    async fn clean(&self) -> Result<(), IndexeddbMediaStoreError> {
269        let _timer = timer!("method");
270        self.media_service.clean(self).await
271    }
272
273    async fn optimize(&self) -> Result<(), Self::Error> {
274        Ok(())
275    }
276
277    async fn get_size(&self) -> Result<Option<usize>, Self::Error> {
278        Ok(None)
279    }
280}
281
282#[cfg(target_family = "wasm")]
283#[async_trait::async_trait(?Send)]
284impl MediaStoreInner for IndexeddbMediaStore {
285    type Error = IndexeddbMediaStoreError;
286
287    #[instrument(skip_all)]
288    async fn media_retention_policy_inner(
289        &self,
290    ) -> Result<Option<MediaRetentionPolicy>, IndexeddbMediaStoreError> {
291        let _timer = timer!("method");
292        self.transaction(&[MediaRetentionPolicy::OBJECT_STORE], TransactionMode::Readonly)?
293            .get_media_retention_policy()
294            .await
295            .map_err(Into::into)
296    }
297
298    #[instrument(skip_all)]
299    async fn set_media_retention_policy_inner(
300        &self,
301        policy: MediaRetentionPolicy,
302    ) -> Result<(), IndexeddbMediaStoreError> {
303        let _timer = timer!("method");
304
305        let transaction =
306            self.transaction(&[MediaRetentionPolicy::OBJECT_STORE], TransactionMode::Readwrite)?;
307        transaction.put_item(&policy).await?;
308        transaction.commit().await.map_err(Into::into)
309    }
310
311    #[instrument(skip_all)]
312    async fn add_media_content_inner(
313        &self,
314        request: &MediaRequestParameters,
315        content: Vec<u8>,
316        current_time: SystemTime,
317        policy: MediaRetentionPolicy,
318        ignore_policy: IgnoreMediaRetentionPolicy,
319    ) -> Result<(), IndexeddbMediaStoreError> {
320        let _timer = timer!("method");
321
322        let transaction = self.transaction(
323            &[MediaMetadata::OBJECT_STORE, MediaContent::OBJECT_STORE],
324            TransactionMode::Readwrite,
325        )?;
326
327        let media = Media {
328            request_parameters: request.clone(),
329            last_access: current_time.into(),
330            ignore_policy,
331            content,
332        };
333
334        transaction.put_media_if_policy_compliant(media, policy).await?;
335        transaction.commit().await.map_err(Into::into)
336    }
337
338    #[instrument(skip_all)]
339    async fn set_ignore_media_retention_policy_inner(
340        &self,
341        request: &MediaRequestParameters,
342        ignore_policy: IgnoreMediaRetentionPolicy,
343    ) -> Result<(), IndexeddbMediaStoreError> {
344        let _timer = timer!("method");
345
346        let transaction =
347            self.transaction(&[MediaMetadata::OBJECT_STORE], TransactionMode::Readwrite)?;
348        if let Some(mut metadata) = transaction.get_media_metadata_by_id(request).await?
349            && metadata.ignore_policy != ignore_policy
350        {
351            metadata.ignore_policy = ignore_policy;
352            transaction.put_media_metadata(&metadata).await?;
353            transaction.commit().await?;
354        }
355        Ok(())
356    }
357
358    #[instrument(skip_all)]
359    async fn get_media_content_inner(
360        &self,
361        request: &MediaRequestParameters,
362        current_time: SystemTime,
363    ) -> Result<Option<Vec<u8>>, IndexeddbMediaStoreError> {
364        let _timer = timer!("method");
365
366        let transaction = self.transaction(
367            &[MediaMetadata::OBJECT_STORE, MediaContent::OBJECT_STORE],
368            TransactionMode::Readwrite,
369        )?;
370        let media = transaction.access_media_by_id(request, current_time).await?;
371        transaction.commit().await?;
372        Ok(media.map(|m| m.content))
373    }
374
375    #[instrument(skip_all)]
376    async fn get_media_content_for_uri_inner(
377        &self,
378        uri: &MxcUri,
379        current_time: SystemTime,
380    ) -> Result<Option<Vec<u8>>, IndexeddbMediaStoreError> {
381        let _timer = timer!("method");
382
383        let transaction = self.transaction(
384            &[MediaMetadata::OBJECT_STORE, MediaContent::OBJECT_STORE],
385            TransactionMode::Readwrite,
386        )?;
387        let media = transaction.access_media_by_uri(uri, current_time).await?.pop();
388        transaction.commit().await?;
389        Ok(media.map(|m| m.content))
390    }
391
392    #[instrument(skip_all)]
393    async fn clean_inner(
394        &self,
395        policy: MediaRetentionPolicy,
396        current_time: SystemTime,
397    ) -> Result<(), IndexeddbMediaStoreError> {
398        let _timer = timer!("method");
399
400        if !policy.has_limitations() {
401            return Ok(());
402        }
403
404        let transaction = self.transaction(
405            &[
406                MediaMetadata::OBJECT_STORE,
407                MediaContent::OBJECT_STORE,
408                MediaCleanupTime::OBJECT_STORE,
409            ],
410            TransactionMode::Readwrite,
411        )?;
412
413        let ignore_policy = IgnoreMediaRetentionPolicy::No;
414        let current_time = UnixTime::from(current_time);
415
416        if let Some(max_file_size) = policy.computed_max_file_size() {
417            transaction
418                .delete_media_by_content_size_greater_than(ignore_policy, max_file_size as usize)
419                .await?;
420        }
421
422        if let Some(expiry) = policy.last_access_expiry {
423            transaction
424                .delete_media_by_last_access_earlier_than(ignore_policy, current_time - expiry)
425                .await?;
426        }
427
428        if let Some(max_cache_size) = policy.max_cache_size {
429            let cache_size = transaction
430                .get_cache_size(ignore_policy)
431                .await?
432                .ok_or(Self::Error::CacheSizeTooBig)?;
433            if cache_size > (max_cache_size as usize) {
434                let (_, upper_key) = transaction
435                    .fold_media_metadata_keys_by_retention_while(
436                        CursorDirection::Prev,
437                        ignore_policy,
438                        0usize,
439                        |total, key| match total.checked_add(key.content_size()) {
440                            None => None,
441                            Some(total) if total > max_cache_size as usize => None,
442                            Some(total) => Some(total),
443                        },
444                    )
445                    .await?;
446                if let Some(upper_key) = upper_key {
447                    transaction
448                        .delete_media_by_retention_metadata_to(
449                            upper_key.ignore_policy(),
450                            upper_key.last_access(),
451                            upper_key.content_size(),
452                        )
453                        .await?;
454                }
455            }
456        }
457
458        transaction.put_media_cleanup_time(current_time).await?;
459        transaction.commit().await.map_err(Into::into)
460    }
461
462    #[instrument(skip_all)]
463    async fn last_media_cleanup_time_inner(
464        &self,
465    ) -> Result<Option<SystemTime>, IndexeddbMediaStoreError> {
466        let _timer = timer!("method");
467        let time = self
468            .transaction(&[MediaCleanupTime::OBJECT_STORE], TransactionMode::Readonly)?
469            .get_media_cleanup_time()
470            .await?;
471        Ok(time.map(Into::into))
472    }
473}
474
475#[cfg(all(test, target_family = "wasm"))]
476mod tests {
477    use matrix_sdk_base::{
478        media::store::MediaStoreError, media_store_inner_integration_tests,
479        media_store_integration_tests, media_store_integration_tests_time,
480    };
481    use uuid::Uuid;
482
483    use crate::media_store::IndexeddbMediaStore;
484
485    mod unencrypted {
486        use super::*;
487
488        wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
489
490        async fn get_media_store() -> Result<IndexeddbMediaStore, MediaStoreError> {
491            let name = format!("test-media-store-{}", Uuid::new_v4().as_hyphenated());
492            Ok(IndexeddbMediaStore::builder().database_name(name).build().await?)
493        }
494
495        #[cfg(target_family = "wasm")]
496        media_store_integration_tests!();
497
498        #[cfg(target_family = "wasm")]
499        media_store_integration_tests_time!();
500
501        #[cfg(target_family = "wasm")]
502        media_store_inner_integration_tests!(with_media_size_tests);
503    }
504
505    mod encrypted {
506        use std::sync::Arc;
507
508        use matrix_sdk_store_encryption::StoreCipher;
509
510        use super::*;
511
512        wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
513
514        async fn get_media_store() -> Result<IndexeddbMediaStore, MediaStoreError> {
515            let name = format!("test-media-store-{}", Uuid::new_v4().as_hyphenated());
516            Ok(IndexeddbMediaStore::builder()
517                .database_name(name)
518                .store_cipher(Arc::new(StoreCipher::new().expect("store cipher")))
519                .build()
520                .await?)
521        }
522
523        #[cfg(target_family = "wasm")]
524        media_store_integration_tests!();
525
526        #[cfg(target_family = "wasm")]
527        media_store_integration_tests_time!();
528
529        #[cfg(target_family = "wasm")]
530        media_store_inner_integration_tests!();
531    }
532}