Skip to main content

sova_storage/
lib.rs

1//! Object storage for Sova — local disk, memory, S3/R2/MinIO, GCS, Azure.
2//!
3//! Upload pipeline:
4//! ```ignore
5//! file.validate(&UploadRules::new().max_bytes(2_000_000).extensions(["png", "jpg"]))?;
6//! let stored = req.storage().store(&file, "avatars").await?;
7//! // stored.key / stored.url
8//! ```
9
10mod error;
11mod local;
12mod memory;
13mod plugin;
14
15#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
16mod opendal_store;
17
18#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
19pub use opendal_store::OpendalStore;
20#[cfg(feature = "s3")]
21pub use opendal_store::{s3_from_env, S3EnvConfig};
22#[cfg(feature = "gcs")]
23pub use opendal_store::gcs_from_env;
24#[cfg(feature = "azure")]
25pub use opendal_store::azure_from_env;
26
27pub use error::StorageError;
28pub use local::LocalStore;
29pub use memory::MemoryStore;
30pub use plugin::{AppStorage, Storage, StorageExt, StoredFile};
31
32use bytes::Bytes;
33use std::future::Future;
34use std::pin::Pin;
35use std::time::Duration;
36
37pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
38
39#[derive(Debug, Clone, Default)]
40pub struct PutOpts {
41    pub content_type: Option<String>,
42    pub content_disposition: Option<String>,
43    pub cache_control: Option<String>,
44    /// User metadata key/value pairs (cloud backends).
45    pub metadata: Vec<(String, String)>,
46}
47
48/// Byte-oriented object store. Custom backends: `impl BlobStore` + [`Storage::new`].
49pub trait BlobStore: Send + Sync + 'static {
50    fn put<'a>(
51        &'a self,
52        key: &'a str,
53        data: Bytes,
54        opts: PutOpts,
55    ) -> BoxFuture<'a, Result<(), StorageError>>;
56
57    fn get<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<Option<Bytes>, StorageError>>;
58
59    fn delete<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<(), StorageError>>;
60
61    fn exists<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<bool, StorageError>>;
62
63    /// List object keys under `prefix` (empty = store root). Returns file keys only.
64    fn list<'a>(&'a self, prefix: &'a str) -> BoxFuture<'a, Result<Vec<String>, StorageError>>;
65
66    /// Laravel-style temporary download URL (presigned GET). Default: unsupported.
67    fn temporary_url<'a>(
68        &'a self,
69        _key: &'a str,
70        _expires: Duration,
71    ) -> BoxFuture<'a, Result<String, StorageError>> {
72        Box::pin(async {
73            Err(StorageError::Msg(
74                "temporary_url requires s3/gcs/azure backend".into(),
75            ))
76        })
77    }
78
79    /// Laravel-style temporary upload URL (presigned PUT). Default: unsupported.
80    fn temporary_upload_url<'a>(
81        &'a self,
82        _key: &'a str,
83        _expires: Duration,
84    ) -> BoxFuture<'a, Result<String, StorageError>> {
85        Box::pin(async {
86            Err(StorageError::Msg(
87                "temporary_upload_url requires s3/gcs/azure backend".into(),
88            ))
89        })
90    }
91}
92
93/// Normalize object keys: trim leading `/`, reject `..`.
94pub fn normalize_key(key: &str) -> Result<String, StorageError> {
95    let key = key.trim().trim_start_matches('/');
96    if key.is_empty() {
97        return Err(StorageError::Msg("empty storage key".into()));
98    }
99    if key.split('/').any(|p| p == ".." || p == ".") {
100        return Err(StorageError::Msg("unsafe storage key".into()));
101    }
102    Ok(key.to_string())
103}
104
105/// Normalize a list prefix: empty allowed (store root); reject `..`.
106pub fn normalize_prefix(prefix: &str) -> Result<String, StorageError> {
107    let prefix = prefix.trim().trim_start_matches('/');
108    if prefix.is_empty() {
109        return Ok(String::new());
110    }
111    if prefix.split('/').any(|p| p == ".." || p == ".") {
112        return Err(StorageError::Msg("unsafe storage prefix".into()));
113    }
114    Ok(prefix.to_string())
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use std::sync::Arc;
121
122    #[tokio::test]
123    async fn memory_roundtrip() {
124        let store = MemoryStore::new();
125        store
126            .put("a/b.txt", Bytes::from_static(b"hi"), PutOpts::default())
127            .await
128            .unwrap();
129        assert!(store.exists("a/b.txt").await.unwrap());
130        assert_eq!(
131            store.get("a/b.txt").await.unwrap().unwrap().as_ref(),
132            b"hi"
133        );
134        store.delete("a/b.txt").await.unwrap();
135        assert!(!store.exists("a/b.txt").await.unwrap());
136        assert!(store.get("a/b.txt").await.unwrap().is_none());
137    }
138
139    #[tokio::test]
140    async fn memory_list_and_temporary_url_unsupported() {
141        let store = MemoryStore::new();
142        store
143            .put("a/1.txt", Bytes::from_static(b"1"), PutOpts::default())
144            .await
145            .unwrap();
146        store
147            .put("a/2.txt", Bytes::from_static(b"2"), PutOpts::default())
148            .await
149            .unwrap();
150        store
151            .put("b/3.txt", Bytes::from_static(b"3"), PutOpts::default())
152            .await
153            .unwrap();
154        let mut keys = store.list("a").await.unwrap();
155        keys.sort();
156        assert_eq!(keys, vec!["a/1.txt", "a/2.txt"]);
157
158        let err = store
159            .temporary_url("a/1.txt", Duration::from_secs(60))
160            .await
161            .unwrap_err();
162        assert!(err.to_string().contains("temporary_url"));
163    }
164
165    #[tokio::test]
166    async fn local_roundtrip() {
167        let dir = tempfile::tempdir().unwrap();
168        let store = LocalStore::new(dir.path());
169        store
170            .put(
171                "x/y.bin",
172                Bytes::from_static(b"data"),
173                PutOpts {
174                    content_type: Some("application/octet-stream".into()),
175                    ..Default::default()
176                },
177            )
178            .await
179            .unwrap();
180        assert!(dir.path().join("x/y.bin").is_file());
181        assert_eq!(
182            store.get("x/y.bin").await.unwrap().unwrap().as_ref(),
183            b"data"
184        );
185        let listed = store.list("x").await.unwrap();
186        assert_eq!(listed, vec!["x/y.bin"]);
187        store.delete("x/y.bin").await.unwrap();
188        assert!(!store.exists("x/y.bin").await.unwrap());
189    }
190
191    #[tokio::test]
192    async fn rejects_unsafe_key() {
193        let store = MemoryStore::new();
194        let err = store
195            .put("../x", Bytes::from_static(b"no"), PutOpts::default())
196            .await
197            .unwrap_err();
198        assert!(err.to_string().contains("unsafe"));
199    }
200
201    #[tokio::test]
202    async fn app_storage_url() {
203        let app = AppStorage::new(Arc::new(MemoryStore::new())).with_public_url("/assets/uploads");
204        assert_eq!(
205            app.url("avatars/u1.png").as_deref(),
206            Some("/assets/uploads/avatars/u1.png")
207        );
208    }
209
210    #[tokio::test]
211    async fn store_and_store_as() {
212        use sova_core::Upload;
213
214        let app = AppStorage::new(Arc::new(MemoryStore::new())).with_public_url("/assets");
215        let upload = Upload {
216            field: "file".into(),
217            filename: Some("photo.PNG".into()),
218            content_type: Some("image/png".into()),
219            data: Bytes::from_static(b"png-bytes"),
220        };
221
222        let stored = app.store(&upload, "avatars").await.unwrap();
223        assert!(stored.key.starts_with("avatars/"));
224        assert!(stored.key.ends_with(".png"));
225        assert_eq!(
226            stored.url.as_deref(),
227            Some(format!("/assets/{}", stored.key).as_str())
228        );
229        assert_eq!(
230            app.get(&stored.key).await.unwrap().unwrap().as_ref(),
231            b"png-bytes"
232        );
233
234        let fixed = app.store_as(&upload, "avatars/u42.png").await.unwrap();
235        assert_eq!(fixed.key, "avatars/u42.png");
236        assert_eq!(
237            fixed.url.as_deref(),
238            Some("/assets/avatars/u42.png")
239        );
240    }
241}