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
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 remove_media_content_for_uri(
220        &self,
221        uri: &MxcUri,
222    ) -> Result<(), IndexeddbMediaStoreError> {
223        let _timer = timer!("method");
224
225        let transaction = self.transaction(
226            &[MediaMetadata::OBJECT_STORE, MediaContent::OBJECT_STORE],
227            TransactionMode::Readwrite,
228        )?;
229        transaction.delete_media_by_uri(uri).await?;
230        transaction.commit().await.map_err(Into::into)
231    }
232
233    #[instrument(skip_all)]
234    async fn set_media_retention_policy(
235        &self,
236        policy: MediaRetentionPolicy,
237    ) -> Result<(), IndexeddbMediaStoreError> {
238        let _timer = timer!("method");
239        self.media_service.set_media_retention_policy(self, policy).await
240    }
241
242    #[instrument(skip_all)]
243    fn media_retention_policy(&self) -> MediaRetentionPolicy {
244        let _timer = timer!("method");
245        self.media_service.media_retention_policy()
246    }
247
248    #[instrument(skip_all)]
249    async fn set_ignore_media_retention_policy(
250        &self,
251        request: &MediaRequestParameters,
252        ignore_policy: IgnoreMediaRetentionPolicy,
253    ) -> Result<(), IndexeddbMediaStoreError> {
254        let _timer = timer!("method");
255        self.media_service.set_ignore_media_retention_policy(self, request, ignore_policy).await
256    }
257
258    #[instrument(skip_all)]
259    async fn clean(&self) -> Result<(), IndexeddbMediaStoreError> {
260        let _timer = timer!("method");
261        self.media_service.clean(self).await
262    }
263
264    async fn optimize(&self) -> Result<(), Self::Error> {
265        Ok(())
266    }
267
268    async fn get_size(&self) -> Result<Option<usize>, Self::Error> {
269        Ok(None)
270    }
271
272    async fn close(&self) -> Result<(), Self::Error> {
273        Ok(())
274    }
275
276    async fn reopen(&self) -> Result<(), Self::Error> {
277        Ok(())
278    }
279}
280
281#[cfg(target_family = "wasm")]
282#[async_trait::async_trait(?Send)]
283impl MediaStoreInner for IndexeddbMediaStore {
284    type Error = IndexeddbMediaStoreError;
285
286    #[instrument(skip_all)]
287    async fn media_retention_policy_inner(
288        &self,
289    ) -> Result<Option<MediaRetentionPolicy>, IndexeddbMediaStoreError> {
290        let _timer = timer!("method");
291        self.transaction(&[MediaRetentionPolicy::OBJECT_STORE], TransactionMode::Readonly)?
292            .get_media_retention_policy()
293            .await
294            .map_err(Into::into)
295    }
296
297    #[instrument(skip_all)]
298    async fn set_media_retention_policy_inner(
299        &self,
300        policy: MediaRetentionPolicy,
301    ) -> Result<(), IndexeddbMediaStoreError> {
302        let _timer = timer!("method");
303
304        let transaction =
305            self.transaction(&[MediaRetentionPolicy::OBJECT_STORE], TransactionMode::Readwrite)?;
306        transaction.put_item(&policy).await?;
307        transaction.commit().await.map_err(Into::into)
308    }
309
310    #[instrument(skip_all)]
311    async fn add_media_content_inner(
312        &self,
313        request: &MediaRequestParameters,
314        content: Vec<u8>,
315        current_time: SystemTime,
316        policy: MediaRetentionPolicy,
317        ignore_policy: IgnoreMediaRetentionPolicy,
318    ) -> Result<(), IndexeddbMediaStoreError> {
319        let _timer = timer!("method");
320
321        let transaction = self.transaction(
322            &[MediaMetadata::OBJECT_STORE, MediaContent::OBJECT_STORE],
323            TransactionMode::Readwrite,
324        )?;
325
326        let media = Media {
327            request_parameters: request.clone(),
328            last_access: current_time.into(),
329            ignore_policy,
330            content,
331        };
332
333        transaction.put_media_if_policy_compliant(media, policy).await?;
334        transaction.commit().await.map_err(Into::into)
335    }
336
337    #[instrument(skip_all)]
338    async fn set_ignore_media_retention_policy_inner(
339        &self,
340        request: &MediaRequestParameters,
341        ignore_policy: IgnoreMediaRetentionPolicy,
342    ) -> Result<(), IndexeddbMediaStoreError> {
343        let _timer = timer!("method");
344
345        let transaction =
346            self.transaction(&[MediaMetadata::OBJECT_STORE], TransactionMode::Readwrite)?;
347        if let Some(mut metadata) = transaction.get_media_metadata_by_id(request).await?
348            && metadata.ignore_policy != ignore_policy
349        {
350            metadata.ignore_policy = ignore_policy;
351            transaction.put_media_metadata(&metadata).await?;
352            transaction.commit().await?;
353        }
354        Ok(())
355    }
356
357    #[instrument(skip_all)]
358    async fn get_media_content_inner(
359        &self,
360        request: &MediaRequestParameters,
361        current_time: SystemTime,
362    ) -> Result<Option<Vec<u8>>, IndexeddbMediaStoreError> {
363        let _timer = timer!("method");
364
365        let transaction = self.transaction(
366            &[MediaMetadata::OBJECT_STORE, MediaContent::OBJECT_STORE],
367            TransactionMode::Readwrite,
368        )?;
369        let media = transaction.access_media_by_id(request, current_time).await?;
370        transaction.commit().await?;
371        Ok(media.map(|m| m.content))
372    }
373
374    #[instrument(skip_all)]
375    async fn clean_inner(
376        &self,
377        policy: MediaRetentionPolicy,
378        current_time: SystemTime,
379    ) -> Result<(), IndexeddbMediaStoreError> {
380        let _timer = timer!("method");
381
382        if !policy.has_limitations() {
383            return Ok(());
384        }
385
386        let transaction = self.transaction(
387            &[
388                MediaMetadata::OBJECT_STORE,
389                MediaContent::OBJECT_STORE,
390                MediaCleanupTime::OBJECT_STORE,
391            ],
392            TransactionMode::Readwrite,
393        )?;
394
395        let ignore_policy = IgnoreMediaRetentionPolicy::No;
396        let current_time = UnixTime::from(current_time);
397
398        if let Some(max_file_size) = policy.computed_max_file_size() {
399            transaction
400                .delete_media_by_content_size_greater_than(ignore_policy, max_file_size as usize)
401                .await?;
402        }
403
404        if let Some(expiry) = policy.last_access_expiry {
405            transaction
406                .delete_media_by_last_access_earlier_than(ignore_policy, current_time - expiry)
407                .await?;
408        }
409
410        if let Some(max_cache_size) = policy.max_cache_size {
411            let cache_size = transaction
412                .get_cache_size(ignore_policy)
413                .await?
414                .ok_or(Self::Error::CacheSizeTooBig)?;
415            if cache_size > (max_cache_size as usize) {
416                let (_, upper_key) = transaction
417                    .fold_media_metadata_keys_by_retention_while(
418                        CursorDirection::Prev,
419                        ignore_policy,
420                        0usize,
421                        |total, key| match total.checked_add(key.content_size()) {
422                            None => None,
423                            Some(total) if total > max_cache_size as usize => None,
424                            Some(total) => Some(total),
425                        },
426                    )
427                    .await?;
428                if let Some(upper_key) = upper_key {
429                    transaction
430                        .delete_media_by_retention_metadata_to(
431                            upper_key.ignore_policy(),
432                            upper_key.last_access(),
433                            upper_key.content_size(),
434                        )
435                        .await?;
436                }
437            }
438        }
439
440        transaction.put_media_cleanup_time(current_time).await?;
441        transaction.commit().await.map_err(Into::into)
442    }
443
444    #[instrument(skip_all)]
445    async fn last_media_cleanup_time_inner(
446        &self,
447    ) -> Result<Option<SystemTime>, IndexeddbMediaStoreError> {
448        let _timer = timer!("method");
449        let time = self
450            .transaction(&[MediaCleanupTime::OBJECT_STORE], TransactionMode::Readonly)?
451            .get_media_cleanup_time()
452            .await?;
453        Ok(time.map(Into::into))
454    }
455}
456
457#[cfg(all(test, target_family = "wasm"))]
458mod tests {
459    use matrix_sdk_base::{
460        media::store::MediaStoreError, media_store_inner_integration_tests,
461        media_store_integration_tests, media_store_integration_tests_time,
462    };
463    use uuid::Uuid;
464
465    use crate::media_store::IndexeddbMediaStore;
466
467    mod unencrypted {
468        use super::*;
469
470        wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
471
472        async fn get_media_store() -> Result<IndexeddbMediaStore, MediaStoreError> {
473            let name = format!("test-media-store-{}", Uuid::new_v4().as_hyphenated());
474            Ok(IndexeddbMediaStore::builder().database_name(name).build().await?)
475        }
476
477        #[cfg(target_family = "wasm")]
478        media_store_integration_tests!();
479
480        #[cfg(target_family = "wasm")]
481        media_store_integration_tests_time!();
482
483        #[cfg(target_family = "wasm")]
484        media_store_inner_integration_tests!(with_media_size_tests);
485    }
486
487    mod encrypted {
488        use std::sync::Arc;
489
490        use matrix_sdk_store_encryption::StoreCipher;
491
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()
499                .database_name(name)
500                .store_cipher(Arc::new(StoreCipher::new().expect("store cipher")))
501                .build()
502                .await?)
503        }
504
505        #[cfg(target_family = "wasm")]
506        media_store_integration_tests!();
507
508        #[cfg(target_family = "wasm")]
509        media_store_integration_tests_time!();
510
511        #[cfg(target_family = "wasm")]
512        media_store_inner_integration_tests!();
513    }
514}