Skip to main content

fraiseql_server/storage/
mod.rs

1//! Object storage backends for file upload and download.
2//!
3//! Provides a [`StorageBackend`] trait with implementations for local filesystem,
4//! AWS S3, Google Cloud Storage, Azure Blob Storage, and S3-compatible European
5//! providers (Hetzner, Scaleway, OVH, Exoscale, Backblaze B2).
6
7use 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
35/// Result type for storage operations.
36pub type StorageResult<T> = Result<T, FileError>;
37
38/// Trait for object storage backends.
39///
40/// Implementations handle file upload, download, deletion, existence checks,
41/// and presigned URL generation across different storage providers.
42///
43/// # Errors
44///
45/// All methods return [`FileError`] on failure. Common variants:
46/// - [`FileError::Storage`] for backend communication errors
47/// - [`FileError::NotFound`] when a requested key does not exist
48#[async_trait]
49pub trait StorageBackend: Send + Sync {
50    /// Uploads data and returns the storage key.
51    ///
52    /// # Errors
53    ///
54    /// Returns [`FileError::Storage`] if the upload fails.
55    async fn upload(&self, key: &str, data: &[u8], content_type: &str) -> StorageResult<String>;
56
57    /// Downloads the contents of the given key.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`FileError::NotFound`] if the key does not exist,
62    /// or [`FileError::Storage`] on backend errors.
63    async fn download(&self, key: &str) -> StorageResult<Vec<u8>>;
64
65    /// Deletes the object at the given key.
66    ///
67    /// # Errors
68    ///
69    /// Returns [`FileError::NotFound`] if the key does not exist,
70    /// or [`FileError::Storage`] on backend errors.
71    async fn delete(&self, key: &str) -> StorageResult<()>;
72
73    /// Checks whether an object exists at the given key.
74    ///
75    /// # Errors
76    ///
77    /// Returns [`FileError::Storage`] on backend communication errors.
78    async fn exists(&self, key: &str) -> StorageResult<bool>;
79
80    /// Generates a presigned (time-limited) URL for direct access to an object.
81    ///
82    /// # Errors
83    ///
84    /// Returns [`FileError::Storage`] if presigned URLs are not supported by
85    /// the backend or if generation fails.
86    async fn presigned_url(&self, key: &str, expiry: Duration) -> StorageResult<String>;
87}
88
89/// Validates that a storage key is safe (no path traversal).
90///
91/// # Errors
92///
93/// Returns [`FileError::Storage`] if the key is empty, contains `..`,
94/// or starts with `/` or `\`.
95pub 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
111/// All S3-compatible backend names recognised by the factory.
112const S3_COMPAT_BACKENDS: &[&str] = &[
113    "s3",
114    "r2",
115    "hetzner",
116    "scaleway",
117    "ovh",
118    "exoscale",
119    "backblaze",
120];
121
122/// Returns a well-known endpoint template for S3-compatible providers.
123#[cfg(any(feature = "aws-s3", test))]
124/// The `region` placeholder is substituted with the configured region.  If the
125/// config already provides an explicit `endpoint`, it takes precedence.
126fn default_s3_endpoint(backend: &str, region: Option<&str>) -> Option<String> {
127    match backend {
128        "r2" => {
129            // R2 endpoint requires account ID via config.endpoint; no useful default.
130            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            // Backblaze B2 S3-compatible endpoint — region is the key-id region prefix.
150            let r = region.unwrap_or("us-west-004");
151            Some(format!("https://s3.{r}.backblazeb2.com"))
152        },
153        _ => None,
154    }
155}
156
157/// Creates a storage backend from a [`StorageConfig`](crate::config::StorageConfig).
158///
159/// S3-compatible providers (`s3`, `r2`, `hetzner`, `scaleway`, `ovh`, `exoscale`,
160/// `backblaze`) all use `S3StorageBackend` under the hood.  Provider-specific
161/// defaults for the endpoint URL are applied when `endpoint` is not set in the
162/// config.
163///
164/// # Errors
165///
166/// Returns [`FileError::Storage`] if the backend type is unknown, the required
167/// feature is not enabled, or required configuration fields are missing.
168pub 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}