fraiseql_server/storage/
mod.rs1use std::time::Duration;
8
9use async_trait::async_trait;
10use fraiseql_error::FileError;
11
12pub mod local;
13
14#[cfg(feature = "aws-s3")]
15pub mod s3;
16
17#[cfg(feature = "gcs")]
18pub mod gcs;
19
20#[cfg(feature = "azure-blob")]
21pub mod azure;
22
23#[cfg(test)]
24mod tests;
25
26pub use local::LocalStorageBackend;
27
28#[cfg(feature = "azure-blob")]
29pub use self::azure::AzureBlobStorageBackend;
30#[cfg(feature = "gcs")]
31pub use self::gcs::GcsStorageBackend;
32#[cfg(feature = "aws-s3")]
33pub use self::s3::S3StorageBackend;
34
35pub type StorageResult<T> = Result<T, FileError>;
37
38#[async_trait]
49pub trait StorageBackend: Send + Sync {
50 async fn upload(&self, key: &str, data: &[u8], content_type: &str) -> StorageResult<String>;
56
57 async fn download(&self, key: &str) -> StorageResult<Vec<u8>>;
64
65 async fn delete(&self, key: &str) -> StorageResult<()>;
72
73 async fn exists(&self, key: &str) -> StorageResult<bool>;
79
80 async fn presigned_url(&self, key: &str, expiry: Duration) -> StorageResult<String>;
87}
88
89pub fn validate_key(key: &str) -> StorageResult<()> {
96 if key.is_empty() {
97 return Err(FileError::Storage {
98 message: "Storage key must not be empty".to_string(),
99 source: None,
100 });
101 }
102 if key.contains("..") || key.starts_with('/') || key.starts_with('\\') {
103 return Err(FileError::Storage {
104 message: "Invalid storage key: must be a relative path without '..'".to_string(),
105 source: None,
106 });
107 }
108 Ok(())
109}
110
111const S3_COMPAT_BACKENDS: &[&str] = &[
113 "s3",
114 "r2",
115 "hetzner",
116 "scaleway",
117 "ovh",
118 "exoscale",
119 "backblaze",
120];
121
122#[cfg(any(feature = "aws-s3", test))]
124fn default_s3_endpoint(backend: &str, region: Option<&str>) -> Option<String> {
127 match backend {
128 "r2" => {
129 None
131 },
132 "hetzner" => {
133 let r = region.unwrap_or("fsn1");
134 Some(format!("https://{r}.your-objectstorage.com"))
135 },
136 "scaleway" => {
137 let r = region.unwrap_or("fr-par");
138 Some(format!("https://s3.{r}.scw.cloud"))
139 },
140 "ovh" => {
141 let r = region.unwrap_or("gra");
142 Some(format!("https://s3.{r}.perf.cloud.ovh.net"))
143 },
144 "exoscale" => {
145 let r = region.unwrap_or("de-fra-1");
146 Some(format!("https://sos-{r}.exo.io"))
147 },
148 "backblaze" => {
149 let r = region.unwrap_or("us-west-004");
151 Some(format!("https://s3.{r}.backblazeb2.com"))
152 },
153 _ => None,
154 }
155}
156
157pub async fn create_backend(
169 config: &crate::config::StorageConfig,
170) -> StorageResult<Box<dyn StorageBackend>> {
171 let backend_name = config.backend.as_str();
172
173 match backend_name {
174 "local" => {
175 let path = config.path.as_deref().ok_or_else(|| FileError::Storage {
176 message: "Local storage backend requires 'path' configuration".to_string(),
177 source: None,
178 })?;
179 Ok(Box::new(LocalStorageBackend::new(path)))
180 },
181 #[cfg(feature = "aws-s3")]
182 b if S3_COMPAT_BACKENDS.contains(&b) => {
183 let bucket = config.bucket.as_deref().ok_or_else(|| FileError::Storage {
184 message: format!("{b} storage backend requires 'bucket' configuration"),
185 source: None,
186 })?;
187 let endpoint = config
188 .endpoint
189 .as_deref()
190 .map(str::to_owned)
191 .or_else(|| default_s3_endpoint(b, config.region.as_deref()));
192 let backend =
193 S3StorageBackend::new(bucket, config.region.as_deref(), endpoint.as_deref()).await;
194 Ok(Box::new(backend))
195 },
196 #[cfg(feature = "gcs")]
197 "gcs" => {
198 let bucket = config.bucket.as_deref().ok_or_else(|| FileError::Storage {
199 message: "GCS storage backend requires 'bucket' configuration".to_string(),
200 source: None,
201 })?;
202 let backend = GcsStorageBackend::new(bucket)?;
203 Ok(Box::new(backend))
204 },
205 #[cfg(feature = "azure-blob")]
206 "azure" => {
207 let container = config.bucket.as_deref().ok_or_else(|| FileError::Storage {
208 message: "Azure Blob storage requires 'bucket' (container) configuration"
209 .to_string(),
210 source: None,
211 })?;
212 let account = config.account_name.as_deref().ok_or_else(|| FileError::Storage {
213 message: "Azure Blob storage requires 'account_name' configuration".to_string(),
214 source: None,
215 })?;
216 let backend = AzureBlobStorageBackend::new(account, container)?;
217 Ok(Box::new(backend))
218 },
219 #[cfg(not(feature = "aws-s3"))]
220 b if S3_COMPAT_BACKENDS.contains(&b) => Err(FileError::Storage {
221 message: format!("{b} storage backend requires the 'aws-s3' feature"),
222 source: None,
223 }),
224 #[cfg(not(feature = "gcs"))]
225 "gcs" => Err(FileError::Storage {
226 message: "GCS storage backend requires the 'gcs' feature".to_string(),
227 source: None,
228 }),
229 #[cfg(not(feature = "azure-blob"))]
230 "azure" => Err(FileError::Storage {
231 message: "Azure Blob storage backend requires the 'azure-blob' feature".to_string(),
232 source: None,
233 }),
234 other => Err(FileError::Storage {
235 message: format!("Unknown storage backend: {other}"),
236 source: None,
237 }),
238 }
239}