Skip to main content

webdav_server/model/
asset.rs

1use crate::{model::error::Result, storage::file::Metadata};
2use serde::Serialize;
3use sqlx::{SqlitePool, query};
4use uuid::Uuid;
5
6/// A record in the `assets` table representing one user's ownership of a blob.
7///
8/// The server stores files by their BLAKE3 hash (CAS semantics). Multiple users
9/// can upload identical files — each gets their own `Asset` row pointing to the
10/// same hash. When a user deletes their asset, only their row is removed. The
11/// physical blob is only deleted when no `Asset` rows reference its hash anymore.
12/// This mirrors Unix hard-link semantics.
13#[derive(sqlx::FromRow)]
14#[allow(unused)]
15pub struct Asset {
16    uuid: Uuid,
17    hash: Vec<u8>,
18    last_modified: i64,
19    user: i64,
20    size: i64,
21    tag: AssetTag,
22}
23
24/// Classifies an asset into a logical section of the Android client.
25///
26/// Stored as an `i32` in `SQLite` to minimize storage footprint. The client
27/// uses this tag to route an asset to the correct UI view (gallery or drive)
28/// without needing to inspect the encrypted file contents.
29#[derive(sqlx::Type, Copy, Clone)]
30#[repr(i32)]
31pub enum AssetTag {
32    /// Metadata file for the Gallery section (encrypted index, thumbnails, etc.).
33    GalleryMeta = 0,
34    /// An encrypted media file belonging to the Gallery section.
35    GalleryItem = 1,
36    /// Metadata file for the Drive section.
37    DriveMeta = 2,
38    /// An encrypted file belonging to the Drive section.
39    DriveItem = 3,
40}
41
42impl Asset {
43    /// Returns whether asset exists with given hash
44    ///
45    /// # Errors
46    /// - Fails with [`sqlx::Error`] if fails to work with database
47    pub async fn exists(pool: &SqlitePool, hash: Vec<u8>) -> Result<bool> {
48        let result =
49            query!("SELECT 1 AS matched FROM assets WHERE hash = ?", hash)
50                .fetch_optional(pool)
51                .await?;
52
53        Ok(result.is_some())
54    }
55
56    /// Registers an asset entry to the assets entity
57    ///
58    /// # Errors
59    /// - Returns [`sqlx::Error::Database`] for uniqueness violation,
60    ///   because of `uuid` being PRIMARY KEY, which happens very (very) rarely.
61    /// - Otherwise, this can fail on querrying databases
62    pub async fn create(
63        pool: &SqlitePool,
64        user: i64,
65        tag: AssetTag,
66        metadata: &Metadata,
67    ) -> Result<()> {
68        sqlx::query!(
69            r#"
70            INSERT INTO assets (id, user_id, hash, size_bytes, last_modified,tag)
71            VALUES(?, ?, ?, ?, ?, ?)
72            "#,
73            Uuid::new_v4().as_bytes().to_vec(),
74            user,
75            metadata.hash.as_bytes().to_vec(),
76            metadata.size,
77            metadata.last_modified,
78            tag
79        ).execute(pool)
80        .await?;
81
82        Ok(())
83    }
84
85    /// Deletes user's ownership over an asset
86    /// - Returns `Ok(true)`, if no other user owns same asset. Good signal to wipe that asset physically off the server.
87    /// - Return `Ok(false)`, if user doesn't own the file, or other users have own the same file.
88    ///
89    /// # Errors
90    /// Returns [`sqlx::Error`] if a database transaction fails.
91    pub async fn delete(
92        pool: &SqlitePool,
93        user: i64,
94        hash: &[u8],
95    ) -> Result<()> {
96        sqlx::query!(
97            r#"
98            DELETE FROM assets WHERE user_id = ? AND hash = ?
99        "#,
100            user,
101            hash
102        )
103        .execute(pool)
104        .await?;
105
106        Ok(())
107    }
108
109    /// Checks if any [`Asset`] with provided `hash` exists, and is owned by the specified `user` as well
110    ///
111    /// It's different from [`Asset::exists`], because it checks for ownership.
112    /// Required for situations where user tries to access an asset, this returns the proof of it.
113    ///
114    /// # Errors
115    /// Returns [`sqlx::Error`] if the database query fails.
116    pub async fn owned_by(
117        pool: &SqlitePool,
118        user: i64,
119        hash: &[u8],
120    ) -> Result<bool> {
121        let result = sqlx::query!(
122            r#"
123            SELECT 1 AS matched FROM assets WHERE user_id = ? AND hash = ?
124        "#,
125            user,
126            hash,
127        )
128        .fetch_optional(pool)
129        .await?;
130
131        Ok(result.is_some())
132    }
133}
134
135#[derive(Serialize)]
136pub struct AssetMetadataRow {
137    pub hash: String,
138    pub size_bytes: i64,
139    pub last_modified: i64,
140    pub tag: i16,
141}
142
143impl Asset {
144    pub async fn list(
145        pool: &SqlitePool,
146        user_id: i64,
147        tag_filter: Option<i16>,
148    ) -> Result<Vec<AssetMetadataRow>> {
149        let tag_str = tag_filter.map(|t| t.to_string());
150        let rows = sqlx::query!(
151            r#"
152            SELECT hash, size_bytes, last_modified, tag
153            FROM assets
154            WHERE user_id = ?
155                AND (? IS NULL OR tag = ?)
156            ORDER BY last_modified DESC
157            "#,
158            user_id,
159            tag_str,
160            tag_str,
161        )
162        .fetch_all(pool)
163        .await?;
164
165        let items = rows
166            .into_iter()
167            .map(|row| AssetMetadataRow {
168                hash: hex::encode(row.hash),
169                size_bytes: row.size_bytes,
170                last_modified: row.last_modified,
171                tag: row.tag.parse::<i64>().unwrap_or(0) as i16,
172            })
173            .collect();
174
175        Ok(items)
176    }
177}
178
179impl TryFrom<&str> for AssetTag {
180    type Error = ();
181    fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
182        use AssetTag::{DriveItem, DriveMeta, GalleryItem, GalleryMeta};
183
184        Ok(match value {
185            "0" => GalleryMeta,
186            "1" => GalleryItem,
187            "2" => DriveMeta,
188            "3" => DriveItem,
189            _ => Err(())?,
190        })
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use anyhow::Result;
197    use sqlx::SqlitePool;
198    use uuid::Uuid;
199
200    use crate::model::asset::Asset;
201
202    async fn setup_db() -> Result<SqlitePool> {
203        let pool = SqlitePool::connect("sqlite::memory:").await?;
204        sqlx::migrate!().run(&pool).await?;
205        Ok(pool)
206    }
207
208    async fn insert_asset(
209        pool: &SqlitePool,
210        user_id: i64,
211        hash: &[u8],
212    ) -> Result<()> {
213        let dummy_id = format!("id_hash_{user_id}");
214
215        sqlx::query!(r#"
216            INSERT OR IGNORE INTO users (id, identity_hash, auth_verifier) VALUES (?, ?, ?)
217        "#,
218        user_id,
219        dummy_id,
220        "dummy_verifier"
221        ).execute(pool)
222        .await?;
223
224        sqlx::query!(r#"
225            INSERT INTO assets (id, user_id, hash, size_bytes, last_modified, tag) VALUES (?, ?, ?, ?, ?, ?)
226            "#,
227            Uuid::new_v4().as_bytes().to_vec(),
228            user_id,
229            hash,
230            100,
231            0,
232            0
233        ).execute(pool)
234        .await?;
235        Ok(())
236    }
237
238    async fn count_owners(pool: &SqlitePool, hash: &[u8]) -> Result<i64> {
239        let count = sqlx::query_scalar!(
240            "SELECT COUNT(*) FROM assets WHERE hash = ?",
241            hash as &[u8]
242        )
243        .fetch_one(pool)
244        .await?;
245
246        Ok(count)
247    }
248
249    #[tokio::test]
250    async fn delete_asset_with_single_owner() -> Result<()> {
251        let pool = setup_db().await?;
252        let hash = b"hello_fellas_i_m_deleting_a_file";
253
254        insert_asset(&pool, 10, hash).await?;
255
256        Asset::delete(&pool, 10, hash).await?;
257
258        let count = count_owners(&pool, hash).await?;
259
260        // Test: No asset should be present as there were no other owners
261        assert_eq!(count, 0);
262
263        Ok(())
264    }
265
266    #[tokio::test]
267    async fn attempt_to_delete_unowned_asset() -> Result<()> {
268        let pool = setup_db().await?;
269        let hash = b"a_dude_uploads_a_file_with_cache";
270
271        insert_asset(&pool, 10, hash).await?;
272
273        Asset::delete(&pool, 12, hash).await?;
274
275        let count = count_owners(&pool, hash).await?;
276
277        // Test: owner_count shouldn't be affected by this
278        assert_eq!(count, 1);
279
280        Ok(())
281    }
282
283    #[tokio::test]
284    async fn list_assets_filters_by_tag_and_orders_newest_first() -> Result<()>
285    {
286        let pool = setup_db().await?;
287        let user_id = 99;
288
289        sqlx::query!(
290            "INSERT INTO users (id, identity_hash, auth_verifier) VALUES (?, ?, ?)",
291            user_id, "dummy_hash_99", "dummy_verifier"
292        ).execute(&pool).await?;
293
294        let insert = async move |pool: &SqlitePool,
295                                 hash: &[u8],
296                                 last_modified: i64,
297                                 tag: &str| {
298            sqlx::query!(
299                r#"
300                INSERT INTO assets (id, user_id, hash, size_bytes, last_modified, tag)
301                VALUES (?, ?, ?, ?, ?, ?)
302            "#,
303                Uuid::new_v4().as_bytes().to_vec(),
304                user_id,
305                hash,
306                1024,
307                last_modified,
308                tag
309            ).execute(&pool.clone()).await
310        };
311
312        insert(&pool, b"hash_a", 100, "0").await?;
313        insert(&pool, b"hash_b", 200, "1").await?;
314        insert(&pool, b"hash_c", 300, "0").await?;
315
316        let all_assets = Asset::list(&pool, user_id, None).await?;
317        assert_eq!(all_assets.len(), 3);
318        assert_eq!(
319            all_assets[0].last_modified, 300,
320            "Expected newest asset to be listed, first"
321        );
322        assert_eq!(
323            all_assets[2].last_modified, 100,
324            "Expected oldest item to be listed last"
325        );
326
327        let meta_assets = Asset::list(&pool, user_id, Some(0)).await?;
328        assert_eq!(meta_assets.len(), 2, "Should filter out tag 1");
329        assert_eq!(meta_assets[0].tag, 0);
330        assert_eq!(meta_assets[1].tag, 0);
331        assert_eq!(
332            meta_assets[0].last_modified, 300,
333            "Newest meta should be listed first"
334        );
335
336        Ok(())
337    }
338}