1mod 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 pub metadata: Vec<(String, String)>,
46}
47
48pub 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 fn list<'a>(&'a self, prefix: &'a str) -> BoxFuture<'a, Result<Vec<String>, StorageError>>;
65
66 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 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
93pub 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
105pub 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}