1use crate::{normalize_key, BlobStore, LocalStore, MemoryStore, PutOpts, StorageError};
4use bytes::Bytes;
5use rand::RngCore;
6use sova_core::{App, Plugin, Request, Upload};
7use std::path::PathBuf;
8use std::sync::Arc;
9use std::time::Duration;
10
11#[derive(Debug, Clone)]
13pub struct StoredFile {
14 pub key: String,
15 pub url: Option<String>,
17}
18
19#[derive(Clone)]
21pub struct AppStorage {
22 pub inner: Arc<dyn BlobStore>,
23 public_base: Option<String>,
24}
25
26impl AppStorage {
27 pub fn new(store: Arc<dyn BlobStore>) -> Self {
28 Self {
29 inner: store,
30 public_base: None,
31 }
32 }
33
34 pub fn with_public_url(mut self, base: impl Into<String>) -> Self {
35 let base = base.into().trim_end_matches('/').to_string();
36 self.public_base = if base.is_empty() { None } else { Some(base) };
37 self
38 }
39
40 pub fn url(&self, key: &str) -> Option<String> {
42 let key = normalize_key(key).ok()?;
43 let base = self.public_base.as_ref()?;
44 Some(format!("{base}/{key}"))
45 }
46
47 pub async fn put(&self, key: &str, data: Bytes, opts: PutOpts) -> Result<(), StorageError> {
48 self.inner.put(key, data, opts).await
49 }
50
51 pub async fn get(&self, key: &str) -> Result<Option<Bytes>, StorageError> {
52 self.inner.get(key).await
53 }
54
55 pub async fn delete(&self, key: &str) -> Result<(), StorageError> {
56 self.inner.delete(key).await
57 }
58
59 pub async fn exists(&self, key: &str) -> Result<bool, StorageError> {
60 self.inner.exists(key).await
61 }
62
63 pub async fn list(&self, prefix: &str) -> Result<Vec<String>, StorageError> {
64 self.inner.list(prefix).await
65 }
66
67 pub async fn temporary_url(
69 &self,
70 key: &str,
71 expires: Duration,
72 ) -> Result<String, StorageError> {
73 self.inner.temporary_url(key, expires).await
74 }
75
76 pub async fn temporary_upload_url(
78 &self,
79 key: &str,
80 expires: Duration,
81 ) -> Result<String, StorageError> {
82 self.inner.temporary_upload_url(key, expires).await
83 }
84
85 pub async fn put_upload(&self, key: &str, upload: &Upload) -> Result<(), StorageError> {
87 let opts = PutOpts {
88 content_type: upload.content_type.clone(),
89 ..Default::default()
90 };
91 self.put(key, upload.data.clone(), opts).await
92 }
93
94 pub async fn store(&self, upload: &Upload, dir: &str) -> Result<StoredFile, StorageError> {
98 let dir = dir.trim().trim_matches('/');
99 let name = random_object_name(upload);
100 let key = if dir.is_empty() {
101 name
102 } else {
103 format!("{dir}/{name}")
104 };
105 self.store_as(upload, &key).await
106 }
107
108 pub async fn store_as(&self, upload: &Upload, key: &str) -> Result<StoredFile, StorageError> {
110 let key = normalize_key(key)?;
111 self.put_upload(&key, upload).await?;
112 Ok(StoredFile {
113 url: self.url(&key),
114 key,
115 })
116 }
117}
118
119fn random_object_name(upload: &Upload) -> String {
120 let mut buf = [0u8; 16];
121 rand::thread_rng().fill_bytes(&mut buf);
122 let mut name = String::with_capacity(32 + 1 + 8);
123 for b in buf {
124 name.push_str(&format!("{b:02x}"));
125 }
126 let ext = upload.extension().unwrap_or_else(|| "bin".to_string());
127 name.push('.');
128 name.push_str(&ext);
129 name
130}
131
132pub trait StorageExt {
134 fn storage(&self) -> AppStorage;
135}
136
137impl StorageExt for Request {
138 fn storage(&self) -> AppStorage {
139 self.try_state::<AppStorage>()
140 .map(|a| (*a).clone())
141 .expect("Storage plugin is not installed (missing req.storage())")
142 }
143}
144
145pub struct Storage {
147 store: Arc<dyn BlobStore>,
148 public_base: Option<String>,
149 public_url_explicit: bool,
150}
151
152impl Storage {
153 pub fn new(store: Arc<dyn BlobStore>) -> Self {
154 Self {
155 store,
156 public_base: None,
157 public_url_explicit: false,
158 }
159 }
160
161 #[cfg(feature = "local")]
162 pub fn local(root: impl Into<PathBuf>) -> Self {
163 Self::new(Arc::new(LocalStore::new(root)))
164 }
165
166 #[cfg(feature = "memory")]
167 pub fn memory() -> Self {
168 Self::new(Arc::new(MemoryStore::new()))
169 }
170
171 pub fn public_url(mut self, base: impl Into<String>) -> Self {
172 let base = base.into().trim_end_matches('/').to_string();
173 self.public_base = if base.is_empty() { None } else { Some(base) };
174 self.public_url_explicit = true;
175 self
176 }
177
178 pub fn from_config(app: &App) -> Result<Self, StorageError> {
182 let section = app.config_doc().and_then(|d| d.section("storage"));
183 let driver = std::env::var("SOVA_STORAGE")
184 .ok()
185 .filter(|s| !s.is_empty())
186 .or_else(|| {
187 section
188 .as_ref()
189 .and_then(|s| s.get("driver").and_then(|v| v.as_str()).map(str::to_string))
190 })
191 .unwrap_or_else(|| "local".into());
192
193 let _guard = TomlEnvGuard::apply(section.as_ref());
196
197 let mut storage = match driver.as_str() {
198 "local" => {
199 #[cfg(feature = "local")]
200 {
201 let path = std::env::var("SOVA_STORAGE_PATH")
202 .ok()
203 .filter(|s| !s.is_empty())
204 .or_else(|| {
205 section.as_ref().and_then(|s| {
206 s.get("path").and_then(|v| v.as_str()).map(str::to_string)
207 })
208 })
209 .unwrap_or_else(|| "./storage".into());
210 Ok(Self::local(path))
211 }
212 #[cfg(not(feature = "local"))]
213 {
214 Err(StorageError::Msg(
215 "storage driver=local requires feature `local`".into(),
216 ))
217 }
218 }
219 "memory" => {
220 #[cfg(feature = "memory")]
221 {
222 Ok(Self::memory())
223 }
224 #[cfg(not(feature = "memory"))]
225 {
226 Err(StorageError::Msg(
227 "storage driver=memory requires feature `memory`".into(),
228 ))
229 }
230 }
231 "s3" => {
232 #[cfg(feature = "s3")]
233 {
234 Ok(Self::new(Arc::new(crate::opendal_store::s3_from_env()?)))
235 }
236 #[cfg(not(feature = "s3"))]
237 {
238 Err(StorageError::Msg(
239 "storage driver=s3 requires feature `s3` (facade: storage-s3)".into(),
240 ))
241 }
242 }
243 "gcs" => {
244 #[cfg(feature = "gcs")]
245 {
246 Ok(Self::new(Arc::new(crate::opendal_store::gcs_from_env()?)))
247 }
248 #[cfg(not(feature = "gcs"))]
249 {
250 Err(StorageError::Msg(
251 "storage driver=gcs requires feature `gcs` (facade: storage-gcs)".into(),
252 ))
253 }
254 }
255 "azure" => {
256 #[cfg(feature = "azure")]
257 {
258 Ok(Self::new(Arc::new(crate::opendal_store::azure_from_env()?)))
259 }
260 #[cfg(not(feature = "azure"))]
261 {
262 Err(StorageError::Msg(
263 "storage driver=azure requires feature `azure` (facade: storage-azure)"
264 .into(),
265 ))
266 }
267 }
268 other => Err(StorageError::Msg(format!(
269 "unknown storage driver={other} (use local|memory|s3|gcs|azure)"
270 ))),
271 }?;
272
273 let public = std::env::var("SOVA_STORAGE_PUBLIC_URL")
274 .ok()
275 .filter(|s| !s.is_empty())
276 .or_else(|| {
277 section.as_ref().and_then(|s| {
278 s.get("public_url")
279 .and_then(|v| v.as_str())
280 .map(str::to_string)
281 })
282 });
283 if let Some(base) = public {
284 storage = storage.public_url(base);
285 }
286 drop(_guard);
287 Ok(storage)
288 }
289
290 pub fn from_env() -> Result<Self, StorageError> {
292 let kind = std::env::var("SOVA_STORAGE").unwrap_or_else(|_| "local".into());
293 let mut storage = match kind.as_str() {
294 "local" => {
295 #[cfg(feature = "local")]
296 {
297 let path = std::env::var("SOVA_STORAGE_PATH")
298 .unwrap_or_else(|_| "./storage".into());
299 Ok(Self::local(path))
300 }
301 #[cfg(not(feature = "local"))]
302 {
303 Err(StorageError::Msg(
304 "SOVA_STORAGE=local requires feature `local`".into(),
305 ))
306 }
307 }
308 "memory" => {
309 #[cfg(feature = "memory")]
310 {
311 Ok(Self::memory())
312 }
313 #[cfg(not(feature = "memory"))]
314 {
315 Err(StorageError::Msg(
316 "SOVA_STORAGE=memory requires feature `memory`".into(),
317 ))
318 }
319 }
320 "s3" => {
321 #[cfg(feature = "s3")]
322 {
323 Ok(Self::new(Arc::new(crate::opendal_store::s3_from_env()?)))
324 }
325 #[cfg(not(feature = "s3"))]
326 {
327 Err(StorageError::Msg(
328 "SOVA_STORAGE=s3 requires feature `s3` (facade: storage-s3)".into(),
329 ))
330 }
331 }
332 "gcs" => {
333 #[cfg(feature = "gcs")]
334 {
335 Ok(Self::new(Arc::new(crate::opendal_store::gcs_from_env()?)))
336 }
337 #[cfg(not(feature = "gcs"))]
338 {
339 Err(StorageError::Msg(
340 "SOVA_STORAGE=gcs requires feature `gcs` (facade: storage-gcs)".into(),
341 ))
342 }
343 }
344 "azure" => {
345 #[cfg(feature = "azure")]
346 {
347 Ok(Self::new(Arc::new(crate::opendal_store::azure_from_env()?)))
348 }
349 #[cfg(not(feature = "azure"))]
350 {
351 Err(StorageError::Msg(
352 "SOVA_STORAGE=azure requires feature `azure` (facade: storage-azure)"
353 .into(),
354 ))
355 }
356 }
357 other => Err(StorageError::Msg(format!(
358 "unknown SOVA_STORAGE={other} (use local|memory|s3|gcs|azure)"
359 ))),
360 }?;
361
362 if let Ok(base) = std::env::var("SOVA_STORAGE_PUBLIC_URL") {
363 storage = storage.public_url(base);
364 }
365 Ok(storage)
366 }
367
368 #[cfg(feature = "s3")]
369 pub fn s3_from_env() -> Result<Self, StorageError> {
370 Ok(Self::new(Arc::new(crate::opendal_store::s3_from_env()?)))
371 }
372
373 #[cfg(feature = "gcs")]
374 pub fn gcs_from_env() -> Result<Self, StorageError> {
375 Ok(Self::new(Arc::new(crate::opendal_store::gcs_from_env()?)))
376 }
377
378 #[cfg(feature = "azure")]
379 pub fn azure_from_env() -> Result<Self, StorageError> {
380 Ok(Self::new(Arc::new(crate::opendal_store::azure_from_env()?)))
381 }
382}
383
384struct TomlEnvGuard {
386 restored: Vec<(String, Option<String>)>,
387}
388
389impl TomlEnvGuard {
390 fn apply(section: Option<&toml::map::Map<String, toml::Value>>) -> Self {
391 let mut restored = Vec::new();
392 let Some(section) = section else {
393 return Self { restored };
394 };
395 let pairs = [
396 ("bucket", "SOVA_STORAGE_BUCKET"),
397 ("region", "SOVA_STORAGE_REGION"),
398 ("endpoint", "SOVA_STORAGE_ENDPOINT"),
399 ("root", "SOVA_STORAGE_ROOT"),
400 ("force_path_style", "SOVA_STORAGE_FORCE_PATH_STYLE"),
401 ("path", "SOVA_STORAGE_PATH"),
402 ("public_url", "SOVA_STORAGE_PUBLIC_URL"),
403 ];
404 for (toml_key, env_key) in pairs {
405 if std::env::var(env_key).is_ok() {
406 continue;
407 }
408 let val = match section.get(toml_key) {
409 Some(toml::Value::String(s)) => s.clone(),
410 Some(toml::Value::Boolean(b)) => b.to_string(),
411 Some(toml::Value::Integer(i)) => i.to_string(),
412 _ => continue,
413 };
414 let prev = std::env::var(env_key).ok();
415 std::env::set_var(env_key, &val);
416 restored.push((env_key.to_string(), prev));
417 }
418 Self { restored }
419 }
420}
421
422impl Drop for TomlEnvGuard {
423 fn drop(&mut self) {
424 for (key, prev) in self.restored.drain(..) {
425 match prev {
426 Some(v) => std::env::set_var(&key, v),
427 None => std::env::remove_var(&key),
428 }
429 }
430 }
431}
432
433impl Plugin for Storage {
434 fn id(&self) -> &'static str {
435 "storage"
436 }
437
438 fn meta(&self) -> sova_core::PluginMeta {
439 sova_core::PluginMeta::new("Storage")
440 .description("Object storage (local / memory / S3 / GCS / Azure)")
441 .version(env!("CARGO_PKG_VERSION"))
442 }
443
444 fn install(mut self, app: &mut App) {
445 if !self.public_url_explicit {
446 if let Some(doc) = app.config_doc() {
447 if let Some(section) = doc.section("storage") {
448 if let Some(base) = section.get("public_url").and_then(|v| v.as_str()) {
449 let base = base.trim_end_matches('/').to_string();
450 self.public_base = if base.is_empty() { None } else { Some(base) };
451 }
452 }
453 }
454 }
455 let handle = AppStorage {
456 inner: self.store,
457 public_base: self.public_base,
458 };
459 let check = handle.clone();
460 app.state(handle);
461 app.register_check("storage", move |_state| {
462 let check = check.clone();
463 async move {
464 check
466 .exists("__sova_storage_health__")
467 .await
468 .map(|_| ())
469 .map_err(|e| sova_core::Error::Internal(e.to_string()))
470 }
471 });
472 }
473}