Skip to main content

fraiseql_storage/backend/
mod.rs

1//! Object storage backends for file upload and download.
2//!
3//! Provides enum-based dispatch to local filesystem, AWS S3, Google Cloud Storage,
4//! Azure Blob Storage, and S3-compatible European providers (Hetzner, Scaleway, OVH,
5//! Exoscale, Backblaze B2, Cloudflare R2).
6
7use std::time::Duration;
8
9use chrono::{DateTime, Utc};
10#[cfg(feature = "aws-s3")]
11use fraiseql_error::FraiseQLError;
12use fraiseql_error::{FileError, Result};
13use serde::{Deserialize, Serialize};
14
15pub mod local;
16pub mod types;
17
18/// Presigned URL for time-limited direct access to an object.
19///
20/// Can be used for direct uploads (PUT) or downloads (GET) without going through
21/// the FraiseQL server, reducing server load and enabling client-side uploads.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct PresignedUrl {
24    /// The complete presigned URL (including query parameters)
25    pub url:        String,
26    /// When the URL expires (UTC)
27    pub expires_at: DateTime<Utc>,
28    /// HTTP method this URL is valid for (GET or PUT)
29    pub method:     String,
30}
31
32impl PresignedUrl {
33    /// Creates a new presigned URL.
34    ///
35    /// # Arguments
36    ///
37    /// * `url` - The complete presigned URL
38    /// * `expires_at` - When the URL expires
39    /// * `method` - HTTP method (GET or PUT)
40    #[must_use]
41    pub fn new(url: String, expires_at: DateTime<Utc>, method: &str) -> Self {
42        Self {
43            url,
44            expires_at,
45            method: method.to_uppercase(),
46        }
47    }
48}
49
50/// Capability trait for backends that support presigned URLs.
51///
52/// Not all backends support presigned URLs. For example, `LocalBackend` cannot
53/// generate presigned URLs for direct client access (it's a filesystem, not a service).
54///
55/// This trait is implemented separately from `StorageBackend` to make it optional.
56/// Check if a backend implements this trait before using presigned URL features.
57#[cfg(feature = "aws-s3")]
58#[allow(async_fn_in_trait)] // Reason: native async syntax avoids boxing overhead; Send bound enforced by implementors
59pub trait PresignCapable {
60    /// Generates a presigned URL for uploading an object (PUT).
61    ///
62    /// The returned URL can be used directly by clients to upload files without
63    /// credentials, useful for browser-based uploads.
64    ///
65    /// # Arguments
66    ///
67    /// * `key` - The object key (storage path)
68    /// * `content_type` - The MIME type for the upload
69    /// * `expires_in` - How long the URL remains valid
70    ///
71    /// # Errors
72    ///
73    /// Returns `FraiseQLError::File` if URL generation fails.
74    async fn presign_put(
75        &self,
76        key: &str,
77        content_type: &str,
78        expires_in: Duration,
79    ) -> Result<PresignedUrl>;
80
81    /// Generates a presigned URL for downloading an object (GET).
82    ///
83    /// The returned URL can be used directly by clients to download files,
84    /// useful for serving content from S3 directly.
85    ///
86    /// # Arguments
87    ///
88    /// * `key` - The object key (storage path)
89    /// * `expires_in` - How long the URL remains valid
90    ///
91    /// # Errors
92    ///
93    /// Returns `FraiseQLError::File` if URL generation fails.
94    async fn presign_get(&self, key: &str, expires_in: Duration) -> Result<PresignedUrl>;
95}
96
97#[cfg(feature = "aws-s3")]
98pub mod s3;
99
100#[cfg(feature = "gcs")]
101pub mod gcs;
102
103#[cfg(feature = "azure-blob")]
104pub mod azure;
105
106#[cfg(test)]
107mod tests;
108
109pub use local::LocalBackend;
110
111#[cfg(feature = "azure-blob")]
112pub use self::azure::AzureBackend;
113#[cfg(feature = "gcs")]
114pub use self::gcs::GcsBackend;
115#[cfg(feature = "aws-s3")]
116pub use self::s3::S3Backend;
117
118/// Enum-based storage backend dispatch to local filesystem, S3, GCS, or Azure.
119///
120/// Provides unified async methods for file upload, download, deletion, existence checks,
121/// and presigned URL generation across all supported providers.
122///
123/// # Errors
124///
125/// All methods return `FraiseQLError::File` on failure.
126#[non_exhaustive]
127pub enum StorageBackend {
128    /// Local filesystem storage.
129    Local(LocalBackend),
130    /// AWS S3 storage.
131    #[cfg(feature = "aws-s3")]
132    S3(S3Backend),
133    /// Hetzner Object Storage (S3-compatible).
134    #[cfg(feature = "aws-s3")]
135    Hetzner(S3Backend),
136    /// Scaleway Object Storage (S3-compatible).
137    #[cfg(feature = "aws-s3")]
138    Scaleway(S3Backend),
139    /// OVH Object Storage (S3-compatible).
140    #[cfg(feature = "aws-s3")]
141    Ovh(S3Backend),
142    /// Exoscale Object Storage (S3-compatible).
143    #[cfg(feature = "aws-s3")]
144    Exoscale(S3Backend),
145    /// Backblaze B2 (S3-compatible).
146    #[cfg(feature = "aws-s3")]
147    Backblaze(S3Backend),
148    /// Cloudflare R2 (S3-compatible).
149    #[cfg(feature = "aws-s3")]
150    R2(S3Backend),
151    /// Google Cloud Storage.
152    #[cfg(feature = "gcs")]
153    Gcs(GcsBackend),
154    /// Azure Blob Storage.
155    #[cfg(feature = "azure-blob")]
156    Azure(AzureBackend),
157}
158
159impl StorageBackend {
160    /// Uploads data and returns the storage key.
161    ///
162    /// # Errors
163    ///
164    /// Returns `FraiseQLError::File` if the upload fails.
165    pub async fn upload(&self, key: &str, data: &[u8], content_type: &str) -> Result<String> {
166        match self {
167            Self::Local(b) => b.upload(key, data, content_type).await,
168            #[cfg(feature = "aws-s3")]
169            Self::S3(b)
170            | Self::Hetzner(b)
171            | Self::Scaleway(b)
172            | Self::Ovh(b)
173            | Self::Exoscale(b)
174            | Self::Backblaze(b)
175            | Self::R2(b) => b.upload(key, data, content_type).await,
176            #[cfg(feature = "gcs")]
177            Self::Gcs(b) => b.upload(key, data, content_type).await,
178            #[cfg(feature = "azure-blob")]
179            Self::Azure(b) => b.upload(key, data, content_type).await,
180        }
181    }
182
183    /// Downloads the contents of the given key.
184    ///
185    /// # Errors
186    ///
187    /// Returns `FraiseQLError::File` with code `not_found` if the key does not exist,
188    /// or other error codes on backend failures.
189    pub async fn download(&self, key: &str) -> Result<Vec<u8>> {
190        match self {
191            Self::Local(b) => b.download(key).await,
192            #[cfg(feature = "aws-s3")]
193            Self::S3(b)
194            | Self::Hetzner(b)
195            | Self::Scaleway(b)
196            | Self::Ovh(b)
197            | Self::Exoscale(b)
198            | Self::Backblaze(b)
199            | Self::R2(b) => b.download(key).await,
200            #[cfg(feature = "gcs")]
201            Self::Gcs(b) => b.download(key).await,
202            #[cfg(feature = "azure-blob")]
203            Self::Azure(b) => b.download(key).await,
204        }
205    }
206
207    /// Deletes the object at the given key.
208    ///
209    /// # Errors
210    ///
211    /// Returns `FraiseQLError::File` on backend failures.
212    pub async fn delete(&self, key: &str) -> Result<()> {
213        match self {
214            Self::Local(b) => b.delete(key).await,
215            #[cfg(feature = "aws-s3")]
216            Self::S3(b)
217            | Self::Hetzner(b)
218            | Self::Scaleway(b)
219            | Self::Ovh(b)
220            | Self::Exoscale(b)
221            | Self::Backblaze(b)
222            | Self::R2(b) => b.delete(key).await,
223            #[cfg(feature = "gcs")]
224            Self::Gcs(b) => b.delete(key).await,
225            #[cfg(feature = "azure-blob")]
226            Self::Azure(b) => b.delete(key).await,
227        }
228    }
229
230    /// Checks whether an object exists at the given key.
231    ///
232    /// # Errors
233    ///
234    /// Returns `FraiseQLError::File` on backend communication errors.
235    pub async fn exists(&self, key: &str) -> Result<bool> {
236        match self {
237            Self::Local(b) => b.exists(key).await,
238            #[cfg(feature = "aws-s3")]
239            Self::S3(b)
240            | Self::Hetzner(b)
241            | Self::Scaleway(b)
242            | Self::Ovh(b)
243            | Self::Exoscale(b)
244            | Self::Backblaze(b)
245            | Self::R2(b) => b.exists(key).await,
246            #[cfg(feature = "gcs")]
247            Self::Gcs(b) => b.exists(key).await,
248            #[cfg(feature = "azure-blob")]
249            Self::Azure(b) => b.exists(key).await,
250        }
251    }
252
253    /// Generates a presigned (time-limited) URL for direct access to an object.
254    ///
255    /// # Errors
256    ///
257    /// Returns `FraiseQLError::File` if presigned URLs are not supported by
258    /// the backend or if generation fails.
259    pub async fn presigned_url(&self, key: &str, expiry: Duration) -> Result<String> {
260        match self {
261            Self::Local(b) => b.presigned_url(key, expiry).await,
262            #[cfg(feature = "aws-s3")]
263            Self::S3(b)
264            | Self::Hetzner(b)
265            | Self::Scaleway(b)
266            | Self::Ovh(b)
267            | Self::Exoscale(b)
268            | Self::Backblaze(b)
269            | Self::R2(b) => b.presigned_url(key, expiry).await,
270            #[cfg(feature = "gcs")]
271            Self::Gcs(b) => b.presigned_url(key, expiry).await,
272            #[cfg(feature = "azure-blob")]
273            Self::Azure(b) => b.presigned_url(key, expiry).await,
274        }
275    }
276
277    /// Generates a presigned URL for uploading an object (PUT).
278    ///
279    /// # Errors
280    ///
281    /// Returns `FraiseQLError::File` if the backend does not support presigned
282    /// URLs or if URL generation fails.
283    #[cfg(feature = "aws-s3")]
284    pub async fn presign_put(
285        &self,
286        key: &str,
287        content_type: &str,
288        expires_in: Duration,
289    ) -> Result<PresignedUrl> {
290        match self {
291            Self::S3(b)
292            | Self::Hetzner(b)
293            | Self::Scaleway(b)
294            | Self::Ovh(b)
295            | Self::Exoscale(b)
296            | Self::Backblaze(b)
297            | Self::R2(b) => b.presign_put(key, content_type, expires_in).await,
298            _ => Err(FraiseQLError::File(FileError::Unsupported {
299                message: "presigned PUT not supported by this backend".to_string(),
300            })),
301        }
302    }
303
304    /// Generates a presigned URL for downloading an object (GET).
305    ///
306    /// # Errors
307    ///
308    /// Returns `FraiseQLError::File` if the backend does not support presigned
309    /// URLs or if URL generation fails.
310    #[cfg(feature = "aws-s3")]
311    pub async fn presign_get(&self, key: &str, expires_in: Duration) -> Result<PresignedUrl> {
312        match self {
313            Self::S3(b)
314            | Self::Hetzner(b)
315            | Self::Scaleway(b)
316            | Self::Ovh(b)
317            | Self::Exoscale(b)
318            | Self::Backblaze(b)
319            | Self::R2(b) => b.presign_get(key, expires_in).await,
320            _ => Err(FraiseQLError::File(FileError::Unsupported {
321                message: "presigned GET not supported by this backend".to_string(),
322            })),
323        }
324    }
325
326    /// Lists objects in the bucket by prefix with pagination.
327    ///
328    /// # Errors
329    ///
330    /// Returns `FraiseQLError::File` on I/O or backend failures.
331    pub async fn list(
332        &self,
333        prefix: &str,
334        cursor: Option<&str>,
335        limit: usize,
336    ) -> Result<types::ListResult> {
337        match self {
338            Self::Local(b) => b.list(prefix, cursor, limit).await,
339            #[cfg(feature = "aws-s3")]
340            Self::S3(b)
341            | Self::Hetzner(b)
342            | Self::Scaleway(b)
343            | Self::Ovh(b)
344            | Self::Exoscale(b)
345            | Self::Backblaze(b)
346            | Self::R2(b) => b.list(prefix, cursor, limit).await,
347            #[cfg(feature = "gcs")]
348            Self::Gcs(b) => b.list(prefix, cursor, limit).await,
349            #[cfg(feature = "azure-blob")]
350            Self::Azure(b) => b.list(prefix, cursor, limit).await,
351        }
352    }
353}
354
355/// Validates that a storage key is safe (no path traversal).
356///
357/// # Errors
358///
359/// Returns `FraiseQLError::File` if the key is empty, contains `..`,
360/// or starts with `/` or `\`.
361pub fn validate_key(key: &str) -> Result<()> {
362    if key.is_empty() {
363        return Err(fraiseql_error::FraiseQLError::File(FileError::InvalidKey {
364            message: "Storage key must not be empty".to_string(),
365        }));
366    }
367    if key.contains("..") || key.starts_with('/') || key.starts_with('\\') {
368        return Err(fraiseql_error::FraiseQLError::File(FileError::InvalidKey {
369            message: "Invalid storage key: must be a relative path without '..'".to_string(),
370        }));
371    }
372    Ok(())
373}
374
375/// Returns a well-known endpoint template for S3-compatible providers.
376///
377/// The `region` placeholder is substituted with the configured region.  If the
378/// config already provides an explicit `endpoint`, it takes precedence.
379#[cfg(any(feature = "aws-s3", test))]
380#[allow(dead_code)] // Reason: only used when aws-s3 feature is enabled
381fn default_s3_endpoint(backend: &str, region: Option<&str>) -> Option<String> {
382    match backend {
383        "r2" => {
384            // R2 endpoint requires account ID via config.endpoint; no useful default.
385            None
386        },
387        "hetzner" => {
388            let r = region.unwrap_or("fsn1");
389            Some(format!("https://{r}.your-objectstorage.com"))
390        },
391        "scaleway" => {
392            let r = region.unwrap_or("fr-par");
393            Some(format!("https://s3.{r}.scw.cloud"))
394        },
395        "ovh" => {
396            let r = region.unwrap_or("gra");
397            Some(format!("https://s3.{r}.perf.cloud.ovh.net"))
398        },
399        "exoscale" => {
400            let r = region.unwrap_or("de-fra-1");
401            Some(format!("https://sos-{r}.exo.io"))
402        },
403        "backblaze" => {
404            // Backblaze B2 S3-compatible endpoint — region is the key-id region prefix.
405            let r = region.unwrap_or("us-west-004");
406            Some(format!("https://s3.{r}.backblazeb2.com"))
407        },
408        _ => None,
409    }
410}
411
412/// Build a `FileError::Backend` for a missing-config or unknown-backend error.
413fn config_err(message: impl Into<String>) -> fraiseql_error::FraiseQLError {
414    fraiseql_error::FraiseQLError::File(FileError::Backend {
415        message: message.into(),
416        source:  None,
417    })
418}
419
420/// Creates a storage backend from a [`StorageConfig`](crate::config::StorageConfig).
421///
422/// S3-compatible providers (`s3`, `hetzner`, `scaleway`, `ovh`, `exoscale`,
423/// `backblaze`, `r2`) each get their own enum variant but use the same underlying
424/// `S3Backend` implementation. Provider-specific defaults for the endpoint URL are
425/// applied when `endpoint` is not set in the config.
426///
427/// # Errors
428///
429/// Returns `FraiseQLError::File` if the backend type is unknown, the required
430/// feature is not enabled, or required configuration fields are missing.
431pub async fn create_backend(config: &crate::config::StorageConfig) -> Result<StorageBackend> {
432    let backend_name = config.backend.as_str();
433
434    match backend_name {
435        "local" => {
436            let path = config
437                .path
438                .as_deref()
439                .ok_or_else(|| config_err("Local storage backend requires 'path' configuration"))?;
440            Ok(StorageBackend::Local(LocalBackend::new(path)))
441        },
442        #[cfg(feature = "aws-s3")]
443        "s3" => {
444            let bucket = config.bucket.as_deref().ok_or_else(|| {
445                config_err("AWS S3 storage backend requires 'bucket' configuration")
446            })?;
447            let endpoint = config.endpoint.as_deref().map(str::to_owned);
448            let backend =
449                S3Backend::new(bucket, config.region.as_deref(), endpoint.as_deref()).await;
450            Ok(StorageBackend::S3(backend))
451        },
452        #[cfg(feature = "aws-s3")]
453        "hetzner" => {
454            let bucket = config.bucket.as_deref().ok_or_else(|| {
455                config_err("Hetzner Object Storage requires 'bucket' configuration")
456            })?;
457            let endpoint = config
458                .endpoint
459                .as_deref()
460                .map(str::to_owned)
461                .or_else(|| default_s3_endpoint("hetzner", config.region.as_deref()));
462            let backend =
463                S3Backend::new(bucket, config.region.as_deref(), endpoint.as_deref()).await;
464            Ok(StorageBackend::Hetzner(backend))
465        },
466        #[cfg(feature = "aws-s3")]
467        "scaleway" => {
468            let bucket = config.bucket.as_deref().ok_or_else(|| {
469                config_err("Scaleway Object Storage requires 'bucket' configuration")
470            })?;
471            let endpoint = config
472                .endpoint
473                .as_deref()
474                .map(str::to_owned)
475                .or_else(|| default_s3_endpoint("scaleway", config.region.as_deref()));
476            let backend =
477                S3Backend::new(bucket, config.region.as_deref(), endpoint.as_deref()).await;
478            Ok(StorageBackend::Scaleway(backend))
479        },
480        #[cfg(feature = "aws-s3")]
481        "ovh" => {
482            let bucket = config
483                .bucket
484                .as_deref()
485                .ok_or_else(|| config_err("OVH Object Storage requires 'bucket' configuration"))?;
486            let endpoint = config
487                .endpoint
488                .as_deref()
489                .map(str::to_owned)
490                .or_else(|| default_s3_endpoint("ovh", config.region.as_deref()));
491            let backend =
492                S3Backend::new(bucket, config.region.as_deref(), endpoint.as_deref()).await;
493            Ok(StorageBackend::Ovh(backend))
494        },
495        #[cfg(feature = "aws-s3")]
496        "exoscale" => {
497            let bucket = config.bucket.as_deref().ok_or_else(|| {
498                config_err("Exoscale Object Storage requires 'bucket' configuration")
499            })?;
500            let endpoint = config
501                .endpoint
502                .as_deref()
503                .map(str::to_owned)
504                .or_else(|| default_s3_endpoint("exoscale", config.region.as_deref()));
505            let backend =
506                S3Backend::new(bucket, config.region.as_deref(), endpoint.as_deref()).await;
507            Ok(StorageBackend::Exoscale(backend))
508        },
509        #[cfg(feature = "aws-s3")]
510        "backblaze" => {
511            let bucket = config.bucket.as_deref().ok_or_else(|| {
512                config_err("Backblaze B2 storage requires 'bucket' configuration")
513            })?;
514            let endpoint = config
515                .endpoint
516                .as_deref()
517                .map(str::to_owned)
518                .or_else(|| default_s3_endpoint("backblaze", config.region.as_deref()));
519            let backend =
520                S3Backend::new(bucket, config.region.as_deref(), endpoint.as_deref()).await;
521            Ok(StorageBackend::Backblaze(backend))
522        },
523        #[cfg(feature = "aws-s3")]
524        "r2" => {
525            let bucket = config
526                .bucket
527                .as_deref()
528                .ok_or_else(|| config_err("Cloudflare R2 requires 'bucket' configuration"))?;
529            let endpoint = config.endpoint.as_deref().ok_or_else(|| {
530                config_err("Cloudflare R2 requires 'endpoint' configuration (account ID in URL)")
531            })?;
532            let backend = S3Backend::new(bucket, config.region.as_deref(), Some(endpoint)).await;
533            Ok(StorageBackend::R2(backend))
534        },
535        #[cfg(feature = "gcs")]
536        "gcs" => {
537            let bucket = config
538                .bucket
539                .as_deref()
540                .ok_or_else(|| config_err("GCS storage backend requires 'bucket' configuration"))?;
541            let backend = GcsBackend::new(bucket)?;
542            Ok(StorageBackend::Gcs(backend))
543        },
544        #[cfg(feature = "azure-blob")]
545        "azure" => {
546            let container = config.bucket.as_deref().ok_or_else(|| {
547                config_err("Azure Blob storage requires 'bucket' (container) configuration")
548            })?;
549            let account = config.account_name.as_deref().ok_or_else(|| {
550                config_err("Azure Blob storage requires 'account_name' configuration")
551            })?;
552            let backend = AzureBackend::new(account, container)?;
553            Ok(StorageBackend::Azure(backend))
554        },
555        #[cfg(not(feature = "aws-s3"))]
556        "s3" | "hetzner" | "scaleway" | "ovh" | "exoscale" | "backblaze" | "r2" => {
557            Err(config_err("S3-compatible storage backends require the 'aws-s3' feature"))
558        },
559        #[cfg(not(feature = "gcs"))]
560        "gcs" => Err(config_err("GCS storage backend requires the 'gcs' feature")),
561        #[cfg(not(feature = "azure-blob"))]
562        "azure" => Err(config_err("Azure Blob storage backend requires the 'azure-blob' feature")),
563        other => Err(config_err(format!("Unknown storage backend: {other}"))),
564    }
565}