Skip to main content

adminx_attachments/
store.rs

1// adminx-storage/src/store.rs
2//
3// The `Attachments` implementation. It pairs a `BlobStore` (the bytes) with the
4// `adminx_attachments` metadata table (written through adminx-core's `Storage`,
5// so SeaORM and Mongo both work). Neither half names a concrete database or a
6// concrete filesystem.
7
8use crate::blobstore::BlobStore;
9use adminx_core::attach::{Attachment, Attachments, UploadedFile};
10use adminx_core::storage::{
11    storage, FilterClause, FilterOp, QueryOptions, StorageError,
12};
13use async_trait::async_trait;
14use serde_json::{Map, Value};
15
16/// Metadata table name.
17pub const TABLE: &str = "adminx_attachments";
18
19/// Reasonable upper bound for a listing query — a record won't have hundreds of
20/// attachments, and the metadata rows are tiny.
21const LIST_CAP: u64 = 500;
22
23/// Ties a blob store to the metadata table.
24pub struct AttachmentStore {
25    blobs: Box<dyn BlobStore>,
26}
27
28impl AttachmentStore {
29    pub fn new(blobs: Box<dyn BlobStore>) -> Self {
30        Self { blobs }
31    }
32
33    /// Rows for a record, optionally narrowed to one field.
34    async fn rows(
35        &self,
36        owner_type: &str,
37        owner_id: &str,
38        field: Option<&str>,
39    ) -> Result<Vec<Value>, StorageError> {
40        let mut filters = vec![
41            FilterClause {
42                field: "owner_type".into(),
43                op: FilterOp::Eq,
44                value: owner_type.to_string(),
45            },
46            FilterClause {
47                field: "owner_id".into(),
48                op: FilterOp::Eq,
49                value: owner_id.to_string(),
50            },
51        ];
52        if let Some(f) = field {
53            filters.push(FilterClause {
54                field: "field".into(),
55                op: FilterOp::Eq,
56                value: f.to_string(),
57            });
58        }
59        let opts = QueryOptions {
60            page: 1,
61            per_page: LIST_CAP,
62            sort_by: Some("id".to_string()),
63            sort_desc: false,
64            filters,
65        };
66        Ok(storage().list(TABLE, &opts).await?.rows)
67    }
68
69    /// Delete a metadata row and its bytes. Bytes first: an orphaned metadata row
70    /// is a visible, fixable inconsistency, whereas an orphaned blob is silent
71    /// disk that nothing points at.
72    async fn remove_row(&self, row: &Value) -> Result<(), StorageError> {
73        if let Some(key) = row.get("storage_key").and_then(|v| v.as_str()) {
74            self.blobs.delete(key).await?;
75        }
76        if let Some(id) = row_id(row) {
77            storage().delete(TABLE, "id", &id, false).await?;
78        }
79        Ok(())
80    }
81}
82
83#[async_trait]
84impl Attachments for AttachmentStore {
85    async fn put(
86        &self,
87        owner_type: &str,
88        owner_id: &str,
89        field: &str,
90        file: UploadedFile,
91    ) -> Result<Attachment, StorageError> {
92        // A field holds one file: clear whatever is there before writing the new
93        // one, so re-uploading replaces rather than accumulates.
94        for row in self.rows(owner_type, owner_id, Some(field)).await? {
95            self.remove_row(&row).await?;
96        }
97
98        // Key layout mirrors the ownership path and ends in a random id, so two
99        // uploads never collide and the on-disk tree is navigable.
100        let storage_key = format!(
101            "{owner_type}/{owner_id}/{field}/{}",
102            nanoid::nanoid!()
103        );
104        self.blobs.put(&storage_key, &file.bytes).await?;
105
106        let byte_size = file.bytes.len() as u64;
107        let mut record = Map::new();
108        record.insert("owner_type".into(), Value::String(owner_type.into()));
109        record.insert("owner_id".into(), Value::String(owner_id.into()));
110        record.insert("field".into(), Value::String(field.into()));
111        record.insert("filename".into(), Value::String(file.filename.clone()));
112        record.insert("content_type".into(), Value::String(file.content_type.clone()));
113        record.insert("byte_size".into(), Value::Number(byte_size.into()));
114        record.insert("storage_key".into(), Value::String(storage_key.clone()));
115        record.insert(
116            "created_at".into(),
117            Value::String(chrono::Utc::now().to_rfc3339()),
118        );
119
120        // If the metadata write fails, don't leave the bytes stranded.
121        if let Err(e) = storage().create(TABLE, record).await {
122            let _ = self.blobs.delete(&storage_key).await;
123            return Err(e);
124        }
125
126        Ok(Attachment {
127            field: field.into(),
128            filename: file.filename,
129            content_type: file.content_type,
130            byte_size,
131            storage_key,
132        })
133    }
134
135    async fn get(
136        &self,
137        owner_type: &str,
138        owner_id: &str,
139        field: &str,
140    ) -> Result<Option<Attachment>, StorageError> {
141        Ok(self
142            .rows(owner_type, owner_id, Some(field))
143            .await?
144            .first()
145            .map(to_attachment))
146    }
147
148    async fn list(
149        &self,
150        owner_type: &str,
151        owner_id: &str,
152    ) -> Result<Vec<Attachment>, StorageError> {
153        Ok(self
154            .rows(owner_type, owner_id, None)
155            .await?
156            .iter()
157            .map(to_attachment)
158            .collect())
159    }
160
161    async fn read(&self, storage_key: &str) -> Result<Vec<u8>, StorageError> {
162        self.blobs.get(storage_key).await
163    }
164
165    async fn delete(
166        &self,
167        owner_type: &str,
168        owner_id: &str,
169        field: &str,
170    ) -> Result<(), StorageError> {
171        for row in self.rows(owner_type, owner_id, Some(field)).await? {
172            self.remove_row(&row).await?;
173        }
174        Ok(())
175    }
176
177    async fn delete_all(&self, owner_type: &str, owner_id: &str) -> Result<(), StorageError> {
178        for row in self.rows(owner_type, owner_id, None).await? {
179            self.remove_row(&row).await?;
180        }
181        Ok(())
182    }
183}
184
185/// A metadata row -> the neutral `Attachment` the panel renders.
186fn to_attachment(row: &Value) -> Attachment {
187    let s = |k: &str| row.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
188    Attachment {
189        field: s("field"),
190        filename: s("filename"),
191        content_type: s("content_type"),
192        // Stored integer, but a JSON string over some backends — accept either.
193        byte_size: row
194            .get("byte_size")
195            .and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
196            .unwrap_or(0),
197        storage_key: s("storage_key"),
198    }
199}
200
201/// The primary key of a row, as a string, however the backend typed it.
202fn row_id(row: &Value) -> Option<String> {
203    match row.get("id") {
204        Some(Value::Number(n)) => Some(n.to_string()),
205        Some(Value::String(s)) => Some(s.clone()),
206        _ => None,
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use serde_json::json;
214
215    #[test]
216    fn row_maps_to_attachment_with_either_size_type() {
217        let typed = json!({
218            "field": "avatar", "filename": "a.png", "content_type": "image/png",
219            "byte_size": 1234, "storage_key": "posts/1/avatar/x"
220        });
221        let a = to_attachment(&typed);
222        assert_eq!(a.byte_size, 1234);
223        assert_eq!(a.filename, "a.png");
224
225        // Mongo/text backend may hand the number back as a string.
226        let stringy = json!({ "byte_size": "1234", "storage_key": "k",
227            "field": "f", "filename": "n", "content_type": "t" });
228        assert_eq!(to_attachment(&stringy).byte_size, 1234);
229    }
230
231    #[test]
232    fn row_id_survives_int_or_string_pk() {
233        assert_eq!(row_id(&json!({"id": 5})).as_deref(), Some("5"));
234        assert_eq!(row_id(&json!({"id": "abc"})).as_deref(), Some("abc"));
235        assert_eq!(row_id(&json!({})), None);
236    }
237}