Skip to main content

adminx_cloud/
lib.rs

1// adminx-cloud/src/lib.rs
2//
3// Cloud storage backends for adminx file attachments. Every provider — AWS S3,
4// Google Cloud Storage, Azure Blob — is reached through the same
5// [`object_store`] trait, so this crate is one small [`BlobStore`] wrapper plus
6// a config builder per provider. Enable only the providers you use:
7//
8// ```toml
9// adminx-cloud = { version = "3", features = ["aws"] }        # just S3
10// adminx-cloud = { version = "3", features = ["all"] }        # S3 + GCS + Azure
11// ```
12//
13// ## Use
14//
15// ```ignore
16// // credentials from the standard env vars (AWS_ACCESS_KEY_ID, ...)
17// let blobs = adminx_cloud::s3_from_env("my-bucket")?;
18// adminx_attachments::init(blobs);   // hand it to the attachment layer
19// ```
20//
21// The attachment metadata still lives in your database (via adminx's `Storage`);
22// only the bytes move to the cloud. Everything above the `BlobStore` — the
23// upload widget, attach/serve/detach routes, replace-on-reupload, purge-on-
24// delete — is unchanged, because the backend is the only thing that swaps.
25
26use adminx_attachments::BlobStore;
27use adminx_core::storage::StorageError;
28use async_trait::async_trait;
29// The convenience `put`/`get`/`delete` live on `ObjectStoreExt` in 0.14; the
30// base `ObjectStore` trait only exposes the `_opts` forms.
31use object_store::{path::Path as ObjPath, ObjectStore, ObjectStoreExt, PutPayload};
32use std::sync::Arc;
33
34/// A [`BlobStore`] backed by any [`object_store::ObjectStore`] — S3, GCS, Azure,
35/// or the in-memory / local stores object_store ships. Construct it with one of
36/// the provider builders below, or [`from_object_store`] for full control.
37pub struct CloudBlobStore {
38    inner: Arc<dyn ObjectStore>,
39    /// Optional key prefix, so several apps (or environments) can share one
40    /// bucket without colliding. Joined ahead of every attachment key.
41    prefix: Option<String>,
42}
43
44impl CloudBlobStore {
45    /// Prefix every key with `prefix` (e.g. `"prod/attachments"`), so this
46    /// store's blobs live under their own path in a shared bucket.
47    pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
48        let p = prefix.into();
49        self.prefix = if p.is_empty() { None } else { Some(p) };
50        self
51    }
52
53    /// Resolve an attachment key to an object-store path, applying the prefix and
54    /// validating the result.
55    fn path(&self, key: &str) -> Result<ObjPath, StorageError> {
56        let full = match &self.prefix {
57            Some(p) => format!("{}/{}", p.trim_end_matches('/'), key),
58            None => key.to_string(),
59        };
60        ObjPath::parse(&full)
61            .map_err(|e| StorageError::Backend(format!("invalid object key {full:?}: {e}")))
62    }
63}
64
65/// Build a store from an already-constructed [`object_store::ObjectStore`]. The
66/// escape hatch when the `*_from_env` builders aren't enough — pass anything
67/// object_store can build (custom endpoints, static credentials, retries, ...).
68///
69/// ```ignore
70/// use object_store::aws::AmazonS3Builder;
71/// let s3 = AmazonS3Builder::new()
72///     .with_bucket_name("bucket")
73///     .with_endpoint("https://minio.internal:9000")
74///     .with_access_key_id("key")
75///     .with_secret_access_key("secret")
76///     .with_allow_http(true)
77///     .build()?;
78/// let blobs = adminx_cloud::from_object_store(std::sync::Arc::new(s3));
79/// ```
80pub fn from_object_store(store: Arc<dyn ObjectStore>) -> Box<CloudBlobStore> {
81    Box::new(CloudBlobStore {
82        inner: store,
83        prefix: None,
84    })
85}
86
87#[async_trait]
88impl BlobStore for CloudBlobStore {
89    async fn put(&self, key: &str, bytes: &[u8]) -> Result<(), StorageError> {
90        let path = self.path(key)?;
91        self.inner
92            .put(&path, PutPayload::from(bytes.to_vec()))
93            .await
94            .map(|_| ())
95            .map_err(map_err)
96    }
97
98    async fn get(&self, key: &str) -> Result<Vec<u8>, StorageError> {
99        let path = self.path(key)?;
100        let result = self.inner.get(&path).await.map_err(map_err)?;
101        let bytes = result.bytes().await.map_err(map_err)?;
102        Ok(bytes.to_vec())
103    }
104
105    async fn delete(&self, key: &str) -> Result<(), StorageError> {
106        let path = self.path(key)?;
107        match self.inner.delete(&path).await {
108            Ok(()) => Ok(()),
109            // Idempotent: deleting an absent key is success.
110            Err(object_store::Error::NotFound { .. }) => Ok(()),
111            Err(e) => Err(map_err(e)),
112        }
113    }
114}
115
116/// Map object_store errors onto adminx's storage error, preserving the
117/// not-found distinction the serve path relies on.
118fn map_err(e: object_store::Error) -> StorageError {
119    match e {
120        object_store::Error::NotFound { .. } => StorageError::NotFound,
121        other => StorageError::Backend(format!("object store: {other}")),
122    }
123}
124
125// ===== Provider builders =====
126//
127// Each reads credentials from that provider's standard environment variables
128// (the same ones the provider's own CLI/SDK use), then targets one bucket or
129// container. For anything more than that — custom endpoints, static keys — build
130// the object_store yourself and use [`from_object_store`].
131
132/// AWS S3 (or S3-compatible: MinIO, Cloudflare R2, ...), credentials from the
133/// standard `AWS_*` environment (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`,
134/// `AWS_REGION`, `AWS_ENDPOINT`, ...).
135#[cfg(feature = "aws")]
136pub fn s3_from_env(bucket: impl Into<String>) -> Result<Box<CloudBlobStore>, StorageError> {
137    let bucket = bucket.into();
138    let store = object_store::aws::AmazonS3Builder::from_env()
139        .with_bucket_name(&bucket)
140        .build()
141        .map_err(|e| StorageError::Backend(format!("S3 init ({bucket}): {e}")))?;
142    tracing::info!("adminx-cloud: attachments -> S3 bucket `{bucket}`");
143    Ok(from_object_store(Arc::new(store)))
144}
145
146/// Google Cloud Storage, credentials from the standard environment
147/// (`GOOGLE_SERVICE_ACCOUNT` / `GOOGLE_SERVICE_ACCOUNT_KEY` / Application Default
148/// Credentials).
149#[cfg(feature = "gcp")]
150pub fn gcs_from_env(bucket: impl Into<String>) -> Result<Box<CloudBlobStore>, StorageError> {
151    let bucket = bucket.into();
152    let store = object_store::gcp::GoogleCloudStorageBuilder::from_env()
153        .with_bucket_name(&bucket)
154        .build()
155        .map_err(|e| StorageError::Backend(format!("GCS init ({bucket}): {e}")))?;
156    tracing::info!("adminx-cloud: attachments -> GCS bucket `{bucket}`");
157    Ok(from_object_store(Arc::new(store)))
158}
159
160/// Azure Blob Storage, credentials from the standard environment
161/// (`AZURE_STORAGE_ACCOUNT_NAME`, `AZURE_STORAGE_ACCOUNT_KEY`, ...). `container`
162/// is the blob container name.
163#[cfg(feature = "azure")]
164pub fn azure_from_env(container: impl Into<String>) -> Result<Box<CloudBlobStore>, StorageError> {
165    let container = container.into();
166    let store = object_store::azure::MicrosoftAzureBuilder::from_env()
167        .with_container_name(&container)
168        .build()
169        .map_err(|e| StorageError::Backend(format!("Azure init ({container}): {e}")))?;
170    tracing::info!("adminx-cloud: attachments -> Azure container `{container}`");
171    Ok(from_object_store(Arc::new(store)))
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    /// The wrapper's logic is provider-independent: it targets an
179    /// `object_store::ObjectStore`, and S3/GCS/Azure are just different
180    /// implementations of that trait. So exercising it over object_store's
181    /// in-memory store proves the same put/get/delete + prefix + not-found paths
182    /// every cloud backend rides on — without credentials.
183    #[tokio::test]
184    async fn round_trips_and_honours_prefix_over_the_in_memory_store() {
185        let mem = Arc::new(object_store::memory::InMemory::new());
186        let store = from_object_store(mem.clone()).with_prefix("env1/attachments");
187
188        store.put("posts/1/cover/abc", b"hello").await.unwrap();
189        assert_eq!(store.get("posts/1/cover/abc").await.unwrap(), b"hello");
190
191        // The prefix is really applied: the raw object lives under it.
192        let raw = mem
193            .get(&ObjPath::parse("env1/attachments/posts/1/cover/abc").unwrap())
194            .await
195            .unwrap()
196            .bytes()
197            .await
198            .unwrap();
199        assert_eq!(&raw[..], b"hello");
200
201        // Missing key surfaces as NotFound, which the serve path maps to 404.
202        assert!(matches!(
203            store.get("posts/1/cover/missing").await,
204            Err(StorageError::NotFound)
205        ));
206
207        // Delete removes it and is idempotent.
208        store.delete("posts/1/cover/abc").await.unwrap();
209        assert!(matches!(
210            store.get("posts/1/cover/abc").await,
211            Err(StorageError::NotFound)
212        ));
213        store.delete("posts/1/cover/abc").await.unwrap(); // second delete: still Ok
214    }
215}