architect_sdk/storage/
mod.rs1use 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
21pub struct PresignResult {
24 pub url: String,
25 pub expires_at: DateTime<Utc>,
26 pub expires_in: u64,
27}
28
29#[async_trait]
32pub trait StorageProvider: Send + Sync {
33 async fn upload(&self, path: &str, data: Vec<u8>, content_type: &str) -> Result<(), AppError>;
35 async fn presign_url(&self, path: &str, expires_secs: u64) -> Result<PresignResult, AppError>;
37 async fn delete(&self, path: &str) -> Result<(), AppError>;
39}
40
41#[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 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 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
127pub 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
160pub 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
175pub 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
197pub 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}