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