Skip to main content

architect_sdk/storage/
mod.rs

1//! Storage provider abstraction: upload, presign, delete.
2//!
3//! Enable backends via Cargo features:
4//!   - `storage-s3`   — AWS S3 and S3-compatible endpoints (RustFS, MinIO)
5//!   - `storage-azure` — Azure Blob Storage
6//!   - `storage-gcs`  — Google Cloud Storage
7//!   - `storage-all`  — all three
8//!
9//! Set `STORAGE_PROVIDER` env var to `s3`, `rustfs`, `azure`, or `gcs` at runtime.
10
11use crate::error::AppError;
12use async_trait::async_trait;
13use chrono::{DateTime, Utc};
14use std::sync::Arc;
15
16#[cfg(feature = "storage-azure")]
17pub mod azure;
18#[cfg(feature = "storage-gcs")]
19pub mod gcs;
20
21// ── Public result types ───────────────────────────────────────────────────────
22
23pub struct PresignResult {
24    pub url: String,
25    pub expires_at: DateTime<Utc>,
26    pub expires_in: u64,
27}
28
29// ── Trait ─────────────────────────────────────────────────────────────────────
30
31#[async_trait]
32pub trait StorageProvider: Send + Sync {
33    /// Upload `data` to `path` in the configured bucket. Returns the stored path.
34    async fn upload(&self, path: &str, data: Vec<u8>, content_type: &str) -> Result<(), AppError>;
35    /// Generate a presigned GET URL for `path` valid for `expires_secs` seconds.
36    async fn presign_url(&self, path: &str, expires_secs: u64) -> Result<PresignResult, AppError>;
37    /// Delete the object at `path`.
38    async fn delete(&self, path: &str) -> Result<(), AppError>;
39}
40
41// ── S3 / RustFS provider ──────────────────────────────────────────────────────
42
43#[cfg(feature = "storage-s3")]
44pub struct S3Provider {
45    client: aws_sdk_s3::Client,
46    bucket: String,
47}
48
49#[cfg(feature = "storage-s3")]
50#[allow(clippy::wildcard_imports)]
51use std::time::Duration;
52
53#[cfg(feature = "storage-s3")]
54impl S3Provider {
55    /// Construct from environment variables.
56    /// Required: STORAGE_BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY.
57    /// Optional: STORAGE_ENDPOINT (RustFS / custom), AWS_REGION (default us-east-1).
58    pub async fn from_env() -> Option<Self> {
59        let bucket = std::env::var("STORAGE_BUCKET").ok()?;
60        let endpoint = std::env::var("STORAGE_ENDPOINT").ok();
61        let region = std::env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".into());
62
63        let aws_cfg = aws_config::defaults(aws_config::BehaviorVersion::latest())
64            .region(aws_sdk_s3::config::Region::new(region))
65            .load()
66            .await;
67
68        let mut builder = aws_sdk_s3::config::Builder::from(&aws_cfg);
69        if let Some(ep) = endpoint {
70            // Force path-style for S3-compatible endpoints (RustFS, MinIO, etc.)
71            builder = builder.endpoint_url(ep).force_path_style(true);
72        }
73        let client = aws_sdk_s3::Client::from_conf(builder.build());
74        Some(S3Provider { client, bucket })
75    }
76}
77
78#[cfg(feature = "storage-s3")]
79#[async_trait]
80impl StorageProvider for S3Provider {
81    async fn upload(&self, path: &str, data: Vec<u8>, content_type: &str) -> Result<(), AppError> {
82        self.client
83            .put_object()
84            .bucket(&self.bucket)
85            .key(path)
86            .body(aws_sdk_s3::primitives::ByteStream::from(data))
87            .content_type(content_type)
88            .send()
89            .await
90            .map_err(|e| AppError::Storage(e.to_string()))?;
91        Ok(())
92    }
93
94    async fn presign_url(&self, path: &str, expires_secs: u64) -> Result<PresignResult, AppError> {
95        let cfg =
96            aws_sdk_s3::presigning::PresigningConfig::expires_in(Duration::from_secs(expires_secs))
97                .map_err(|e| AppError::Storage(e.to_string()))?;
98
99        let presigned = self
100            .client
101            .get_object()
102            .bucket(&self.bucket)
103            .key(path)
104            .presigned(cfg)
105            .await
106            .map_err(|e| AppError::Storage(e.to_string()))?;
107
108        Ok(PresignResult {
109            url: presigned.uri().to_string(),
110            expires_at: Utc::now() + chrono::Duration::seconds(expires_secs as i64),
111            expires_in: expires_secs,
112        })
113    }
114
115    async fn delete(&self, path: &str) -> Result<(), AppError> {
116        self.client
117            .delete_object()
118            .bucket(&self.bucket)
119            .key(path)
120            .send()
121            .await
122            .map_err(|e| AppError::Storage(e.to_string()))?;
123        Ok(())
124    }
125}
126
127// ── Initialisation ────────────────────────────────────────────────────────────
128
129/// Build a storage provider from env vars. Returns None when STORAGE_PROVIDER is not set.
130///
131/// Supported values for `STORAGE_PROVIDER`:
132///   - `s3` / `rustfs` — AWS S3 or S3-compatible (requires feature `storage-s3`)
133///   - `azure`         — Azure Blob Storage      (requires feature `storage-azure`)
134///   - `gcs`           — Google Cloud Storage    (requires feature `storage-gcs`)
135pub async fn init_storage_provider() -> Option<Arc<dyn StorageProvider>> {
136    let provider_type = std::env::var("STORAGE_PROVIDER").ok()?.to_lowercase();
137    match provider_type.as_str() {
138        #[cfg(feature = "storage-s3")]
139        "s3" | "rustfs" => {
140            let p = S3Provider::from_env().await?;
141            Some(Arc::new(p) as Arc<dyn StorageProvider>)
142        }
143        #[cfg(feature = "storage-azure")]
144        "azure" => {
145            let p = azure::AzureProvider::from_env()?;
146            Some(Arc::new(p) as Arc<dyn StorageProvider>)
147        }
148        #[cfg(feature = "storage-gcs")]
149        "gcs" => {
150            let p = gcs::GcsProvider::from_env().await?;
151            Some(Arc::new(p) as Arc<dyn StorageProvider>)
152        }
153        _ => {
154            tracing::warn!(provider = %provider_type, "unknown STORAGE_PROVIDER or feature not enabled; storage disabled");
155            None
156        }
157    }
158}
159
160// ── Prefix resolution ─────────────────────────────────────────────────────────
161
162/// Resolve a prefix template at upload time.
163/// Supported tokens: {yyyy}, {mm}, {dd}, {hh}, {tenant_id}, {entity}.
164pub fn resolve_prefix(template: &str, tenant_id: &str, entity: &str) -> String {
165    let now = Utc::now();
166    template
167        .replace("{yyyy}", &now.format("%Y").to_string())
168        .replace("{mm}", &now.format("%m").to_string())
169        .replace("{dd}", &now.format("%d").to_string())
170        .replace("{hh}", &now.format("%H").to_string())
171        .replace("{tenant_id}", tenant_id)
172        .replace("{entity}", entity)
173}
174
175// ── Compression ───────────────────────────────────────────────────────────────
176
177/// Apply byte-level compression before upload.
178/// Supported: "gzip", "zstd", "none" (or any unrecognised value → pass-through).
179pub fn compress(data: Vec<u8>, compression: &str) -> Result<Vec<u8>, AppError> {
180    match compression.to_lowercase().as_str() {
181        "gzip" => {
182            use flate2::write::GzEncoder;
183            use flate2::Compression;
184            use std::io::Write;
185            let mut enc = GzEncoder::new(Vec::new(), Compression::default());
186            enc.write_all(&data)
187                .map_err(|e| AppError::Storage(format!("gzip write: {}", e)))?;
188            enc.finish()
189                .map_err(|e| AppError::Storage(format!("gzip finish: {}", e)))
190        }
191        "zstd" => zstd::bulk::compress(&data, 0)
192            .map_err(|e| AppError::Storage(format!("zstd compress: {}", e))),
193        _ => Ok(data),
194    }
195}
196
197// ── Asset validation ──────────────────────────────────────────────────────────
198
199/// Validate an uploaded file against the asset validation rules configured in api_entities.
200pub fn validate_asset_field(
201    col: &str,
202    filename: &str,
203    content_type: &str,
204    size_bytes: usize,
205    rule: &crate::config::ValidationRule,
206) -> Result<(), AppError> {
207    if let Some(ref allowed) = rule.allowed_mime_types {
208        let ct = content_type
209            .split(';')
210            .next()
211            .unwrap_or(content_type)
212            .trim();
213        if !allowed.iter().any(|m| m.eq_ignore_ascii_case(ct)) {
214            return Err(AppError::Validation(format!(
215                "{}: mime type '{}' is not allowed; accepted: {}",
216                col,
217                ct,
218                allowed.join(", ")
219            )));
220        }
221    }
222    if let Some(ref allowed) = rule.allowed_extensions {
223        let ext = std::path::Path::new(filename)
224            .extension()
225            .and_then(|e| e.to_str())
226            .map(|e| format!(".{}", e.to_lowercase()))
227            .unwrap_or_default();
228        if !allowed.iter().any(|a| a.eq_ignore_ascii_case(&ext)) {
229            return Err(AppError::Validation(format!(
230                "{}: extension '{}' is not allowed; accepted: {}",
231                col,
232                ext,
233                allowed.join(", ")
234            )));
235        }
236    }
237    if let Some(max_mb) = rule.max_size_mb {
238        let limit = (max_mb * 1024.0 * 1024.0) as usize;
239        if size_bytes > limit {
240            return Err(AppError::Validation(format!(
241                "{}: file size {} bytes exceeds maximum of {:.1} MB",
242                col, size_bytes, max_mb
243            )));
244        }
245    }
246    if let Some(min_kb) = rule.min_size_kb {
247        let floor = (min_kb * 1024.0) as usize;
248        if size_bytes < floor {
249            return Err(AppError::Validation(format!(
250                "{}: file size {} bytes is below minimum of {:.1} KB",
251                col, size_bytes, min_kb
252            )));
253        }
254    }
255    if let Some(max_len) = rule.max_filename_length {
256        if filename.len() > max_len as usize {
257            return Err(AppError::Validation(format!(
258                "{}: filename length {} exceeds maximum of {}",
259                col,
260                filename.len(),
261                max_len
262            )));
263        }
264    }
265    Ok(())
266}