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