Skip to main content

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