crypsol_storage 0.2.0

Multi-cloud storage library for Rust with image processing, validation, and thumbnail generation. Supports AWS S3, GCS, Azure Blob, Cloudflare R2, MinIO, and local filesystem.
Documentation
use std::sync::Arc;

use aws_sdk_s3::Client as S3Client;
use aws_sdk_s3::config::{Credentials, Region, SharedCredentialsProvider};
use aws_sdk_s3::error::SdkError;
use aws_sdk_s3::operation::head_object::HeadObjectError;
use aws_sdk_s3::primitives::ByteStream;
use bytes::Bytes;

use crate::error::Error;
use crate::traits::StorageBackend;
use crate::types::{PutOptions, RawDownloadResult};

/// S3-compatible storage backend.
///
/// Works with AWS S3, Cloudflare R2, MinIO, and any other S3-compatible
/// service — just provide a custom `endpoint` via the builder.
pub struct S3Backend {
    client: Arc<S3Client>,
    bucket: String,
    public_base_url: String,
}

/// Builder for [`S3Backend`].
pub struct S3BackendBuilder {
    region: String,
    bucket: Option<String>,
    access_key: Option<String>,
    secret_key: Option<String>,
    endpoint: Option<String>,
    public_base_url: Option<String>,
}

impl S3Backend {
    pub fn builder() -> S3BackendBuilder {
        S3BackendBuilder {
            region: "us-east-1".to_string(),
            bucket: None,
            access_key: None,
            secret_key: None,
            endpoint: None,
            public_base_url: None,
        }
    }
}

impl S3BackendBuilder {
    pub fn region(mut self, region: &str) -> Self {
        self.region = region.to_string();
        self
    }

    pub fn bucket(mut self, bucket: &str) -> Self {
        self.bucket = Some(bucket.to_string());
        self
    }

    pub fn credentials(mut self, access_key: &str, secret_key: &str) -> Self {
        self.access_key = Some(access_key.to_string());
        self.secret_key = Some(secret_key.to_string());
        self
    }

    /// Set a custom endpoint URL (required for R2, MinIO, etc.).
    pub fn endpoint(mut self, endpoint: &str) -> Self {
        self.endpoint = Some(endpoint.to_string());
        self
    }

    /// Override the base URL used to construct public URLs.
    ///
    /// Defaults to `https://{bucket}.s3.{region}.amazonaws.com` for AWS, or
    /// `{endpoint}/{bucket}` when a custom endpoint is set.
    pub fn public_base_url(mut self, url: &str) -> Self {
        self.public_base_url = Some(url.trim_end_matches('/').to_string());
        self
    }

    pub fn build(self) -> Result<S3Backend, Error> {
        let bucket = self
            .bucket
            .ok_or_else(|| Error::ConfigError("bucket is required".into()))?;
        let access_key = self
            .access_key
            .ok_or_else(|| Error::ConfigError("access_key is required".into()))?;
        let secret_key = self
            .secret_key
            .ok_or_else(|| Error::ConfigError("secret_key is required".into()))?;

        let region = Region::new(self.region.clone());
        let credentials = Credentials::new(&access_key, &secret_key, None, None, "crypsol_storage");
        let creds_provider = SharedCredentialsProvider::new(credentials);

        let mut cfg = aws_sdk_s3::Config::builder()
            .region(region)
            .credentials_provider(creds_provider)
            .behavior_version_latest()
            .force_path_style(self.endpoint.is_some());

        if let Some(ref endpoint) = self.endpoint {
            cfg = cfg.endpoint_url(endpoint);
        }

        let client = S3Client::from_conf(cfg.build());

        let public_base_url = self.public_base_url.unwrap_or_else(|| {
            if let Some(ref ep) = self.endpoint {
                format!("{}/{bucket}", ep.trim_end_matches('/'))
            } else {
                format!("https://{bucket}.s3.{}.amazonaws.com", self.region)
            }
        });

        Ok(S3Backend {
            client: Arc::new(client),
            bucket,
            public_base_url,
        })
    }
}

impl StorageBackend for S3Backend {
    async fn put_object(
        &self,
        key: &str,
        data: Bytes,
        content_type: &str,
        options: &PutOptions,
    ) -> Result<(), Error> {
        let body = ByteStream::from(data);

        let mut builder = self
            .client
            .put_object()
            .bucket(&self.bucket)
            .key(key)
            .body(body)
            .content_type(content_type);

        if let Some(ref cc) = options.cache_control {
            builder = builder.cache_control(cc);
        }
        if let Some(ref cd) = options.content_disposition {
            builder = builder.content_disposition(cd);
        }

        builder
            .send()
            .await
            .map_err(|e| Error::Backend(format!("S3 upload failed: {e}")))?;

        Ok(())
    }

    async fn get_object(&self, key: &str) -> Result<RawDownloadResult, Error> {
        let response = self
            .client
            .get_object()
            .bucket(&self.bucket)
            .key(key)
            .send()
            .await
            .map_err(|e| Error::Backend(format!("S3 download failed: {e}")))?;

        let content_type = response
            .content_type()
            .unwrap_or("application/octet-stream")
            .to_string();
        let content_length = response.content_length();

        let body = response
            .body
            .collect()
            .await
            .map_err(|e| Error::Backend(format!("S3 read body failed: {e}")))?;

        Ok(RawDownloadResult {
            data: body.into_bytes(),
            content_type,
            content_length,
        })
    }

    async fn delete_object(&self, key: &str) -> Result<(), Error> {
        self.client
            .delete_object()
            .bucket(&self.bucket)
            .key(key)
            .send()
            .await
            .map_err(|e| Error::Backend(format!("S3 delete failed: {e}")))?;
        Ok(())
    }

    async fn exists(&self, key: &str) -> Result<bool, Error> {
        match self
            .client
            .head_object()
            .bucket(&self.bucket)
            .key(key)
            .send()
            .await
        {
            Ok(_) => Ok(true),
            Err(sdk_error) => match &sdk_error {
                SdkError::ServiceError(service_err) => match service_err.err() {
                    HeadObjectError::NotFound(_) => Ok(false),
                    _ => Err(Error::Backend(format!(
                        "S3 exists check failed: {sdk_error}"
                    ))),
                },
                _ => Err(Error::Backend(format!(
                    "S3 exists check failed: {sdk_error}"
                ))),
            },
        }
    }

    async fn presigned_get_url(&self, key: &str, expires_in_secs: u64) -> Result<String, Error> {
        let presigning_config = aws_sdk_s3::presigning::PresigningConfig::builder()
            .expires_in(std::time::Duration::from_secs(expires_in_secs))
            .build()
            .map_err(|e| Error::Backend(format!("S3 presign config failed: {e}")))?;

        let presigned = self
            .client
            .get_object()
            .bucket(&self.bucket)
            .key(key)
            .presigned(presigning_config)
            .await
            .map_err(|e| Error::Backend(format!("S3 presigned GET failed: {e}")))?;

        Ok(presigned.uri().to_string())
    }

    async fn presigned_put_url(
        &self,
        key: &str,
        content_type: &str,
        expires_in_secs: u64,
    ) -> Result<String, Error> {
        let presigning_config = aws_sdk_s3::presigning::PresigningConfig::builder()
            .expires_in(std::time::Duration::from_secs(expires_in_secs))
            .build()
            .map_err(|e| Error::Backend(format!("S3 presign config failed: {e}")))?;

        let presigned = self
            .client
            .put_object()
            .bucket(&self.bucket)
            .key(key)
            .content_type(content_type)
            .presigned(presigning_config)
            .await
            .map_err(|e| Error::Backend(format!("S3 presigned PUT failed: {e}")))?;

        Ok(presigned.uri().to_string())
    }

    fn public_url(&self, key: &str) -> String {
        format!("{}/{key}", self.public_base_url)
    }

    async fn test_connection(&self) -> Result<(), Error> {
        self.client
            .head_bucket()
            .bucket(&self.bucket)
            .send()
            .await
            .map_err(|e| Error::Backend(format!("S3 connection test failed: {e}")))?;
        Ok(())
    }
}