Skip to main content

sova_storage/
plugin.rs

1//! [`Storage`] plugin + [`AppStorage`] handle.
2
3use 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/// Result of [`AppStorage::store`] / [`AppStorage::store_as`].
12#[derive(Debug, Clone)]
13pub struct StoredFile {
14    pub key: String,
15    /// Public URL when `public_url` was configured on the plugin.
16    pub url: Option<String>,
17}
18
19/// App-state handle: backend + optional public URL prefix.
20#[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    /// Public URL for `key` when `public_url` was set (e.g. `/assets/uploads/avatars/u1.png`).
41    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    /// Presigned download URL (S3/GCS/Azure). Err on local/memory.
68    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    /// Presigned upload URL (S3/GCS/Azure). Err on local/memory.
77    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    /// Store an upload's bytes under `key` (uses upload content-type when present).
86    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    /// Store under `dir` + random name (extension from upload, fallback `bin`).
95    ///
96    /// Validate with [`sova_core::UploadRules`] before calling.
97    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    /// Store under an exact key; returns key + optional public URL.
109    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
132/// Convenient `req.storage()`.
133pub 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
145/// Plugin that installs [`AppStorage`].
146pub 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    /// Build from `[storage]` on `app` (after `configure`) with env overrides for secrets.
179    ///
180    /// Keys: `driver`, `path`, `public_url`, plus env `SOVA_STORAGE*` / cloud credentials.
181    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        // Temporarily set env from toml for opendal helpers that only read env,
194        // without clobbering already-set process env.
195        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    /// Build from `SOVA_STORAGE` (`local` | `memory` | `s3` | `gcs` | `azure`).
291    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
384/// Set missing env vars from toml `[storage]` for cloud helpers; restore on drop.
385struct 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                // Lightweight ping: exists on a sentinel key (ok if missing).
465                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}