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}