use std::time::Duration;
use chrono::{DateTime, Utc};
#[cfg(feature = "aws-s3")]
use fraiseql_error::FraiseQLError;
use fraiseql_error::{FileError, Result};
use serde::{Deserialize, Serialize};
pub mod local;
pub mod types;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PresignedUrl {
pub url: String,
pub expires_at: DateTime<Utc>,
pub method: String,
}
impl PresignedUrl {
#[must_use]
pub fn new(url: String, expires_at: DateTime<Utc>, method: &str) -> Self {
Self {
url,
expires_at,
method: method.to_uppercase(),
}
}
}
#[cfg(feature = "aws-s3")]
#[allow(async_fn_in_trait)] pub trait PresignCapable {
async fn presign_put(
&self,
key: &str,
content_type: &str,
expires_in: Duration,
) -> Result<PresignedUrl>;
async fn presign_get(&self, key: &str, expires_in: Duration) -> Result<PresignedUrl>;
}
#[cfg(feature = "aws-s3")]
pub mod s3;
#[cfg(feature = "gcs")]
pub mod gcs;
#[cfg(feature = "azure-blob")]
pub mod azure;
#[cfg(test)]
mod tests;
pub use local::LocalBackend;
#[cfg(feature = "azure-blob")]
pub use self::azure::AzureBackend;
#[cfg(feature = "gcs")]
pub use self::gcs::GcsBackend;
#[cfg(feature = "aws-s3")]
pub use self::s3::S3Backend;
#[non_exhaustive]
pub enum StorageBackend {
Local(LocalBackend),
#[cfg(feature = "aws-s3")]
S3(S3Backend),
#[cfg(feature = "aws-s3")]
Hetzner(S3Backend),
#[cfg(feature = "aws-s3")]
Scaleway(S3Backend),
#[cfg(feature = "aws-s3")]
Ovh(S3Backend),
#[cfg(feature = "aws-s3")]
Exoscale(S3Backend),
#[cfg(feature = "aws-s3")]
Backblaze(S3Backend),
#[cfg(feature = "aws-s3")]
R2(S3Backend),
#[cfg(feature = "gcs")]
Gcs(GcsBackend),
#[cfg(feature = "azure-blob")]
Azure(AzureBackend),
}
impl StorageBackend {
pub async fn upload(&self, key: &str, data: &[u8], content_type: &str) -> Result<String> {
match self {
Self::Local(b) => b.upload(key, data, content_type).await,
#[cfg(feature = "aws-s3")]
Self::S3(b)
| Self::Hetzner(b)
| Self::Scaleway(b)
| Self::Ovh(b)
| Self::Exoscale(b)
| Self::Backblaze(b)
| Self::R2(b) => b.upload(key, data, content_type).await,
#[cfg(feature = "gcs")]
Self::Gcs(b) => b.upload(key, data, content_type).await,
#[cfg(feature = "azure-blob")]
Self::Azure(b) => b.upload(key, data, content_type).await,
}
}
pub async fn download(&self, key: &str) -> Result<Vec<u8>> {
match self {
Self::Local(b) => b.download(key).await,
#[cfg(feature = "aws-s3")]
Self::S3(b)
| Self::Hetzner(b)
| Self::Scaleway(b)
| Self::Ovh(b)
| Self::Exoscale(b)
| Self::Backblaze(b)
| Self::R2(b) => b.download(key).await,
#[cfg(feature = "gcs")]
Self::Gcs(b) => b.download(key).await,
#[cfg(feature = "azure-blob")]
Self::Azure(b) => b.download(key).await,
}
}
pub async fn delete(&self, key: &str) -> Result<()> {
match self {
Self::Local(b) => b.delete(key).await,
#[cfg(feature = "aws-s3")]
Self::S3(b)
| Self::Hetzner(b)
| Self::Scaleway(b)
| Self::Ovh(b)
| Self::Exoscale(b)
| Self::Backblaze(b)
| Self::R2(b) => b.delete(key).await,
#[cfg(feature = "gcs")]
Self::Gcs(b) => b.delete(key).await,
#[cfg(feature = "azure-blob")]
Self::Azure(b) => b.delete(key).await,
}
}
pub async fn exists(&self, key: &str) -> Result<bool> {
match self {
Self::Local(b) => b.exists(key).await,
#[cfg(feature = "aws-s3")]
Self::S3(b)
| Self::Hetzner(b)
| Self::Scaleway(b)
| Self::Ovh(b)
| Self::Exoscale(b)
| Self::Backblaze(b)
| Self::R2(b) => b.exists(key).await,
#[cfg(feature = "gcs")]
Self::Gcs(b) => b.exists(key).await,
#[cfg(feature = "azure-blob")]
Self::Azure(b) => b.exists(key).await,
}
}
pub async fn presigned_url(&self, key: &str, expiry: Duration) -> Result<String> {
match self {
Self::Local(b) => b.presigned_url(key, expiry).await,
#[cfg(feature = "aws-s3")]
Self::S3(b)
| Self::Hetzner(b)
| Self::Scaleway(b)
| Self::Ovh(b)
| Self::Exoscale(b)
| Self::Backblaze(b)
| Self::R2(b) => b.presigned_url(key, expiry).await,
#[cfg(feature = "gcs")]
Self::Gcs(b) => b.presigned_url(key, expiry).await,
#[cfg(feature = "azure-blob")]
Self::Azure(b) => b.presigned_url(key, expiry).await,
}
}
#[cfg(feature = "aws-s3")]
pub async fn presign_put(
&self,
key: &str,
content_type: &str,
expires_in: Duration,
) -> Result<PresignedUrl> {
match self {
Self::S3(b)
| Self::Hetzner(b)
| Self::Scaleway(b)
| Self::Ovh(b)
| Self::Exoscale(b)
| Self::Backblaze(b)
| Self::R2(b) => b.presign_put(key, content_type, expires_in).await,
_ => Err(FraiseQLError::File(FileError::Unsupported {
message: "presigned PUT not supported by this backend".to_string(),
})),
}
}
#[cfg(feature = "aws-s3")]
pub async fn presign_get(&self, key: &str, expires_in: Duration) -> Result<PresignedUrl> {
match self {
Self::S3(b)
| Self::Hetzner(b)
| Self::Scaleway(b)
| Self::Ovh(b)
| Self::Exoscale(b)
| Self::Backblaze(b)
| Self::R2(b) => b.presign_get(key, expires_in).await,
_ => Err(FraiseQLError::File(FileError::Unsupported {
message: "presigned GET not supported by this backend".to_string(),
})),
}
}
pub async fn list(
&self,
prefix: &str,
cursor: Option<&str>,
limit: usize,
) -> Result<types::ListResult> {
match self {
Self::Local(b) => b.list(prefix, cursor, limit).await,
#[cfg(feature = "aws-s3")]
Self::S3(b)
| Self::Hetzner(b)
| Self::Scaleway(b)
| Self::Ovh(b)
| Self::Exoscale(b)
| Self::Backblaze(b)
| Self::R2(b) => b.list(prefix, cursor, limit).await,
#[cfg(feature = "gcs")]
Self::Gcs(b) => b.list(prefix, cursor, limit).await,
#[cfg(feature = "azure-blob")]
Self::Azure(b) => b.list(prefix, cursor, limit).await,
}
}
}
pub fn validate_key(key: &str) -> Result<()> {
if key.is_empty() {
return Err(fraiseql_error::FraiseQLError::File(FileError::InvalidKey {
message: "Storage key must not be empty".to_string(),
}));
}
if key.contains("..") || key.starts_with('/') || key.starts_with('\\') {
return Err(fraiseql_error::FraiseQLError::File(FileError::InvalidKey {
message: "Invalid storage key: must be a relative path without '..'".to_string(),
}));
}
Ok(())
}
#[cfg(any(feature = "aws-s3", test))]
#[allow(dead_code)] fn default_s3_endpoint(backend: &str, region: Option<&str>) -> Option<String> {
match backend {
"r2" => {
None
},
"hetzner" => {
let r = region.unwrap_or("fsn1");
Some(format!("https://{r}.your-objectstorage.com"))
},
"scaleway" => {
let r = region.unwrap_or("fr-par");
Some(format!("https://s3.{r}.scw.cloud"))
},
"ovh" => {
let r = region.unwrap_or("gra");
Some(format!("https://s3.{r}.perf.cloud.ovh.net"))
},
"exoscale" => {
let r = region.unwrap_or("de-fra-1");
Some(format!("https://sos-{r}.exo.io"))
},
"backblaze" => {
let r = region.unwrap_or("us-west-004");
Some(format!("https://s3.{r}.backblazeb2.com"))
},
_ => None,
}
}
fn config_err(message: impl Into<String>) -> fraiseql_error::FraiseQLError {
fraiseql_error::FraiseQLError::File(FileError::Backend {
message: message.into(),
source: None,
})
}
pub async fn create_backend(config: &crate::config::StorageConfig) -> Result<StorageBackend> {
let backend_name = config.backend.as_str();
match backend_name {
"local" => {
let path = config
.path
.as_deref()
.ok_or_else(|| config_err("Local storage backend requires 'path' configuration"))?;
Ok(StorageBackend::Local(LocalBackend::new(path)))
},
#[cfg(feature = "aws-s3")]
"s3" => {
let bucket = config.bucket.as_deref().ok_or_else(|| {
config_err("AWS S3 storage backend requires 'bucket' configuration")
})?;
let endpoint = config.endpoint.as_deref().map(str::to_owned);
let backend =
S3Backend::new(bucket, config.region.as_deref(), endpoint.as_deref()).await;
Ok(StorageBackend::S3(backend))
},
#[cfg(feature = "aws-s3")]
"hetzner" => {
let bucket = config.bucket.as_deref().ok_or_else(|| {
config_err("Hetzner Object Storage requires 'bucket' configuration")
})?;
let endpoint = config
.endpoint
.as_deref()
.map(str::to_owned)
.or_else(|| default_s3_endpoint("hetzner", config.region.as_deref()));
let backend =
S3Backend::new(bucket, config.region.as_deref(), endpoint.as_deref()).await;
Ok(StorageBackend::Hetzner(backend))
},
#[cfg(feature = "aws-s3")]
"scaleway" => {
let bucket = config.bucket.as_deref().ok_or_else(|| {
config_err("Scaleway Object Storage requires 'bucket' configuration")
})?;
let endpoint = config
.endpoint
.as_deref()
.map(str::to_owned)
.or_else(|| default_s3_endpoint("scaleway", config.region.as_deref()));
let backend =
S3Backend::new(bucket, config.region.as_deref(), endpoint.as_deref()).await;
Ok(StorageBackend::Scaleway(backend))
},
#[cfg(feature = "aws-s3")]
"ovh" => {
let bucket = config
.bucket
.as_deref()
.ok_or_else(|| config_err("OVH Object Storage requires 'bucket' configuration"))?;
let endpoint = config
.endpoint
.as_deref()
.map(str::to_owned)
.or_else(|| default_s3_endpoint("ovh", config.region.as_deref()));
let backend =
S3Backend::new(bucket, config.region.as_deref(), endpoint.as_deref()).await;
Ok(StorageBackend::Ovh(backend))
},
#[cfg(feature = "aws-s3")]
"exoscale" => {
let bucket = config.bucket.as_deref().ok_or_else(|| {
config_err("Exoscale Object Storage requires 'bucket' configuration")
})?;
let endpoint = config
.endpoint
.as_deref()
.map(str::to_owned)
.or_else(|| default_s3_endpoint("exoscale", config.region.as_deref()));
let backend =
S3Backend::new(bucket, config.region.as_deref(), endpoint.as_deref()).await;
Ok(StorageBackend::Exoscale(backend))
},
#[cfg(feature = "aws-s3")]
"backblaze" => {
let bucket = config.bucket.as_deref().ok_or_else(|| {
config_err("Backblaze B2 storage requires 'bucket' configuration")
})?;
let endpoint = config
.endpoint
.as_deref()
.map(str::to_owned)
.or_else(|| default_s3_endpoint("backblaze", config.region.as_deref()));
let backend =
S3Backend::new(bucket, config.region.as_deref(), endpoint.as_deref()).await;
Ok(StorageBackend::Backblaze(backend))
},
#[cfg(feature = "aws-s3")]
"r2" => {
let bucket = config
.bucket
.as_deref()
.ok_or_else(|| config_err("Cloudflare R2 requires 'bucket' configuration"))?;
let endpoint = config.endpoint.as_deref().ok_or_else(|| {
config_err("Cloudflare R2 requires 'endpoint' configuration (account ID in URL)")
})?;
let backend = S3Backend::new(bucket, config.region.as_deref(), Some(endpoint)).await;
Ok(StorageBackend::R2(backend))
},
#[cfg(feature = "gcs")]
"gcs" => {
let bucket = config
.bucket
.as_deref()
.ok_or_else(|| config_err("GCS storage backend requires 'bucket' configuration"))?;
let backend = GcsBackend::new(bucket)?;
Ok(StorageBackend::Gcs(backend))
},
#[cfg(feature = "azure-blob")]
"azure" => {
let container = config.bucket.as_deref().ok_or_else(|| {
config_err("Azure Blob storage requires 'bucket' (container) configuration")
})?;
let account = config.account_name.as_deref().ok_or_else(|| {
config_err("Azure Blob storage requires 'account_name' configuration")
})?;
let backend = AzureBackend::new(account, container)?;
Ok(StorageBackend::Azure(backend))
},
#[cfg(not(feature = "aws-s3"))]
"s3" | "hetzner" | "scaleway" | "ovh" | "exoscale" | "backblaze" | "r2" => {
Err(config_err("S3-compatible storage backends require the 'aws-s3' feature"))
},
#[cfg(not(feature = "gcs"))]
"gcs" => Err(config_err("GCS storage backend requires the 'gcs' feature")),
#[cfg(not(feature = "azure-blob"))]
"azure" => Err(config_err("Azure Blob storage backend requires the 'azure-blob' feature")),
other => Err(config_err(format!("Unknown storage backend: {other}"))),
}
}