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 bytes::Bytes;
use chrono::Utc;

use crate::config::StorageConfig;
use crate::error::Error;
use crate::img;
use crate::traits::StorageBackend;
use crate::types::*;

/// High-level storage service that wraps a [`StorageBackend`] and adds
/// image processing, validation, atomic uploads, and download limits.
pub struct StorageService<B: StorageBackend> {
    backend: B,
    config: StorageConfig,
}

impl<B: StorageBackend> StorageService<B> {
    pub fn new(backend: B, config: StorageConfig) -> Self {
        Self { backend, config }
    }

    /// Returns a reference to the underlying backend.
    pub fn backend(&self) -> &B {
        &self.backend
    }

    /// Returns a reference to the service configuration.
    pub fn config(&self) -> &StorageConfig {
        &self.config
    }

    fn validate_output_dimensions(&self, width: u32, height: u32) -> Result<(), Error> {
        let max = self.config.max_image_dimension;
        if width == 0 || height == 0 || width > max || height > max {
            return Err(Error::InvalidDimensions(width, height, max));
        }
        Ok(())
    }

    fn make_put_options(&self, content_disposition: Option<&str>) -> PutOptions {
        PutOptions {
            cache_control: Some(self.config.default_cache_control.clone()),
            content_disposition: content_disposition.map(String::from),
        }
    }

    /// Uploads an image with a caller-supplied key.
    ///
    /// If the thumbnail upload fails, the already-uploaded main image is
    /// cleaned up to prevent orphaned objects.
    #[allow(clippy::too_many_arguments)]
    pub async fn upload_image_with_key(
        &self,
        data: &[u8],
        content_type: &str,
        key: &str,
        width: u32,
        height: u32,
        thumbnail_width: u32,
        thumbnail_height: u32,
    ) -> Result<UploadResult, Error> {
        validate_content_type(content_type)?;
        validate_file_size(data.len(), self.config.max_file_size)?;
        self.validate_output_dimensions(width, height)?;
        self.validate_output_dimensions(thumbnail_width, thumbnail_height)?;

        let original = img::load_image(
            data,
            self.config.max_image_alloc,
            self.config.max_image_dimension,
        )?;
        let format = img::format_from_content_type(content_type);
        let thumbnail_key = generate_thumbnail_key(key);

        let resized = img::resize_image(&original, width, height, false);
        let thumbnail = img::resize_image(&original, thumbnail_width, thumbnail_height, false);

        let main_bytes = img::encode_image(&resized, format)?;
        let thumb_bytes = img::encode_image(&thumbnail, format)?;

        let options = self.make_put_options(None);

        log::debug!("Uploading main image: {key}");
        self.backend
            .put_object(key, Bytes::from(main_bytes), content_type, &options)
            .await?;

        log::debug!("Uploading thumbnail: {thumbnail_key}");
        if let Err(e) = self
            .backend
            .put_object(
                &thumbnail_key,
                Bytes::from(thumb_bytes),
                content_type,
                &options,
            )
            .await
        {
            log::warn!("Thumbnail upload failed, cleaning up main image: {key}");
            let _ = self.backend.delete_object(key).await;
            return Err(e);
        }

        log::info!("Successfully uploaded image: {key}");

        Ok(UploadResult {
            url: self.backend.public_url(key),
            thumbnail_url: self.backend.public_url(&thumbnail_key),
            key: key.to_string(),
            thumbnail_key,
        })
    }

    /// Uploads an image using an [`ImageUploadConfig`].
    ///
    /// A unique key is generated automatically. If the thumbnail upload fails,
    /// the already-uploaded main image is cleaned up.
    pub async fn upload_image_with_config(
        &self,
        data: &[u8],
        content_type: &str,
        config: &ImageUploadConfig,
    ) -> Result<UploadResult, Error> {
        validate_content_type(content_type)?;
        validate_file_size(data.len(), self.config.max_file_size)?;
        self.validate_output_dimensions(config.width, config.height)?;
        self.validate_output_dimensions(config.thumbnail_width, config.thumbnail_height)?;

        let original = img::load_image(
            data,
            self.config.max_image_alloc,
            self.config.max_image_dimension,
        )?;
        let format = img::format_from_content_type(content_type);
        let extension = img::extension_from_content_type(content_type);

        let resized = img::resize_image(
            &original,
            config.width,
            config.height,
            config.maintain_aspect_ratio,
        );
        let thumbnail = img::resize_image(
            &original,
            config.thumbnail_width,
            config.thumbnail_height,
            config.maintain_aspect_ratio,
        );

        let main_bytes = img::encode_image(&resized, format)?;
        let thumb_bytes = img::encode_image(&thumbnail, format)?;

        let key = generate_storage_key(&config.folder, extension);
        let thumbnail_key = generate_thumbnail_key(&key);

        let options = self.make_put_options(None);

        log::debug!("Uploading main image: {key}");
        self.backend
            .put_object(&key, Bytes::from(main_bytes), content_type, &options)
            .await?;

        log::debug!("Uploading thumbnail: {thumbnail_key}");
        if let Err(e) = self
            .backend
            .put_object(
                &thumbnail_key,
                Bytes::from(thumb_bytes),
                content_type,
                &options,
            )
            .await
        {
            log::warn!("Thumbnail upload failed, cleaning up main image: {key}");
            let _ = self.backend.delete_object(&key).await;
            return Err(e);
        }

        log::info!("Successfully uploaded image: {key}");

        Ok(UploadResult {
            url: self.backend.public_url(&key),
            thumbnail_url: self.backend.public_url(&thumbnail_key),
            key,
            thumbnail_key,
        })
    }

    /// Uploads a raw file without image processing.
    ///
    /// Sets `Content-Disposition: attachment` to prevent browsers from
    /// rendering uploaded content inline (mitigates stored-XSS).
    pub async fn upload_file(
        &self,
        data: &[u8],
        content_type: &str,
        folder: &str,
        extension: &str,
    ) -> Result<FileUploadResult, Error> {
        validate_file_size(data.len(), self.config.max_file_size)?;

        let key = generate_storage_key(folder, extension);
        let options = self.make_put_options(Some("attachment"));

        log::debug!("Uploading file: {key}");
        self.backend
            .put_object(&key, Bytes::copy_from_slice(data), content_type, &options)
            .await?;

        log::info!("Successfully uploaded file: {key}");

        Ok(FileUploadResult {
            url: self.backend.public_url(&key),
            key,
            content_type: content_type.to_string(),
        })
    }

    /// Downloads an object with a size-limit guard.
    ///
    /// **Important:** The size limit is enforced *after* the backend buffers
    /// the full object in memory. Very large objects may spike memory before
    /// being rejected. For strict memory control, enforce object-size limits
    /// at the storage-provider level (e.g. S3 lifecycle rules).
    pub async fn download_object(&self, key: &str) -> Result<DownloadResult, Error> {
        log::debug!("Downloading object: {key}");

        let raw = self.backend.get_object(key).await?;
        let max_size = self.config.max_download_size;

        if let Some(len) = raw.content_length
            && len > 0
            && (len as u64) > max_size
        {
            return Err(Error::FileTooLarge(len as usize, max_size as usize));
        }

        if (raw.data.len() as u64) > max_size {
            return Err(Error::FileTooLarge(raw.data.len(), max_size as usize));
        }

        log::info!("Downloaded object: {key} ({} bytes)", raw.data.len());

        Ok(DownloadResult {
            data: raw.data,
            content_type: raw.content_type,
            content_length: raw.content_length,
        })
    }

    /// Deletes a single object.
    pub async fn delete_file(&self, key: &str) -> Result<(), Error> {
        log::debug!("Deleting file: {key}");
        self.backend.delete_object(key).await?;
        log::info!("Deleted file: {key}");
        Ok(())
    }

    /// Deletes an image and its associated thumbnail concurrently.
    ///
    /// Both deletions are attempted independently so a failure in one does
    /// not prevent the other from being attempted.
    pub async fn delete_image_with_thumbnail(&self, key: &str) -> Result<(), Error> {
        let thumbnail_key = generate_thumbnail_key(key);

        let (main_result, thumb_result) = tokio::join!(
            self.backend.delete_object(key),
            self.backend.delete_object(&thumbnail_key),
        );

        if main_result.is_err() {
            if let Err(ref te) = thumb_result {
                log::warn!("Thumbnail deletion also failed for {thumbnail_key}: {te}");
            }
            return main_result;
        }

        thumb_result
    }

    /// Checks whether an object exists.
    pub async fn file_exists(&self, key: &str) -> Result<bool, Error> {
        self.backend.exists(key).await
    }

    /// Generates a presigned GET URL.
    pub async fn presigned_get_url(
        &self,
        key: &str,
        expires_in_secs: u64,
    ) -> Result<String, Error> {
        validate_expiry(expires_in_secs)?;
        self.backend.presigned_get_url(key, expires_in_secs).await
    }

    /// Generates a presigned PUT URL and returns a [`PresignedUploadResult`].
    pub async fn presigned_upload_url(
        &self,
        key: &str,
        content_type: &str,
        expires_in_secs: u64,
    ) -> Result<PresignedUploadResult, Error> {
        validate_expiry(expires_in_secs)?;

        let presigned_url = self
            .backend
            .presigned_put_url(key, content_type, expires_in_secs)
            .await?;
        let public_url = self.backend.public_url(key);
        // Safe cast: validate_expiry guarantees <= 604_800 which fits in i64.
        let expires_at = Utc::now().timestamp() + expires_in_secs as i64;

        Ok(PresignedUploadResult {
            presigned_url,
            key: key.to_string(),
            public_url,
            expires_at,
        })
    }

    /// Returns the public URL for a given key.
    pub fn public_url(&self, key: &str) -> String {
        self.backend.public_url(key)
    }

    /// Verifies that the underlying backend is reachable and credentials
    /// are valid.
    pub async fn test_connection(&self) -> Result<(), Error> {
        log::debug!("Testing backend connection");
        self.backend.test_connection().await
    }

    /// Extracts the storage key from a full public URL.
    pub fn extract_key_from_url(&self, url: &str) -> Option<String> {
        let base = self.backend.public_url("");
        url.strip_prefix(base.trim_end_matches('/'))?
            .strip_prefix('/')
            .map(String::from)
    }
}