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 uuid::Uuid;

use crate::error::Error;

/// Allowed image MIME types for upload validation.
pub const ALLOWED_IMAGE_TYPES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];

/// Maximum presigned URL expiry in seconds (7 days).
pub const MAX_PRESIGN_EXPIRY_SECS: u64 = 604_800;

/// Profile image dimensions (full size).
pub const PROFILE_IMAGE_SIZE: u32 = 200;

/// Profile thumbnail dimensions.
pub const THUMBNAIL_SIZE: u32 = 50;

/// Options for object upload.
#[derive(Debug, Clone, Default)]
pub struct PutOptions {
    /// Content-Disposition header value (e.g. "attachment").
    pub content_disposition: Option<String>,
    /// Cache-Control header value (e.g. "max-age=31536000").
    pub cache_control: Option<String>,
}

/// Raw result from a storage backend `get_object` call.
#[derive(Debug, Clone)]
pub struct RawDownloadResult {
    pub data: Bytes,
    pub content_type: String,
    pub content_length: Option<i64>,
}

/// Result of a successful image upload containing both URLs.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct UploadResult {
    pub url: String,
    pub thumbnail_url: String,
    pub key: String,
    pub thumbnail_key: String,
}

/// Result of a generic file upload.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct FileUploadResult {
    pub url: String,
    pub key: String,
    pub content_type: String,
}

/// Result of downloading an object.
///
/// Serde serialization skips the `data` field since raw bytes are not
/// meaningful in JSON; use this struct in-process and serialize metadata only.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DownloadResult {
    #[cfg_attr(feature = "serde", serde(skip))]
    pub data: Bytes,
    pub content_type: String,
    pub content_length: Option<i64>,
}

/// Configuration for image upload operations.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ImageUploadConfig {
    pub width: u32,
    pub height: u32,
    pub thumbnail_width: u32,
    pub thumbnail_height: u32,
    pub folder: String,
    pub maintain_aspect_ratio: bool,
}

impl Default for ImageUploadConfig {
    fn default() -> Self {
        Self {
            width: PROFILE_IMAGE_SIZE,
            height: PROFILE_IMAGE_SIZE,
            thumbnail_width: THUMBNAIL_SIZE,
            thumbnail_height: THUMBNAIL_SIZE,
            folder: "profiles".to_string(),
            maintain_aspect_ratio: false,
        }
    }
}

/// Result of a presigned upload URL generation.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PresignedUploadResult {
    pub presigned_url: String,
    pub key: String,
    pub public_url: String,
    pub expires_at: i64,
}

/// Validates the content type against allowed image types.
pub fn validate_content_type(content_type: &str) -> Result<(), Error> {
    if !ALLOWED_IMAGE_TYPES.contains(&content_type) {
        return Err(Error::InvalidFileType(
            content_type.to_string(),
            ALLOWED_IMAGE_TYPES.join(", "),
        ));
    }
    Ok(())
}

/// Validates the file size against the provided maximum.
pub fn validate_file_size(size: usize, max: usize) -> Result<(), Error> {
    if size > max {
        return Err(Error::FileTooLarge(size, max));
    }
    Ok(())
}

/// Validates presigned URL expiry is within the 7-day limit.
pub fn validate_expiry(expires_in_secs: u64) -> Result<(), Error> {
    if expires_in_secs == 0 || expires_in_secs > MAX_PRESIGN_EXPIRY_SECS {
        return Err(Error::InvalidExpiry(
            expires_in_secs,
            MAX_PRESIGN_EXPIRY_SECS,
        ));
    }
    Ok(())
}

/// Generates a unique storage key.
/// Format: `{folder}/{YYYY/MM/DD}/{uuid}.{extension}`
pub fn generate_storage_key(folder: &str, extension: &str) -> String {
    let date = Utc::now().format("%Y/%m/%d");
    let uuid = Uuid::new_v4();
    format!("{folder}/{date}/{uuid}.{extension}")
}

/// Generates the thumbnail key from the main image key.
pub fn generate_thumbnail_key(key: &str) -> String {
    if let Some(pos) = key.rfind('.') {
        format!("{}_thumb{}", &key[..pos], &key[pos..])
    } else {
        format!("{key}_thumb")
    }
}