what-core 1.7.0

Core framework for What - an HTML-first web framework powered by Rust
Documentation
//! Upload storage backends — local filesystem and Cloudflare R2

use crate::Result;

/// Upload storage backend
#[derive(Clone)]
pub enum UploadBackend {
    Local {
        directory: std::path::PathBuf,
    },
    R2 {
        client: reqwest::Client,
        account_id: String,
        bucket: String,
        api_token: String,
        public_url: String,
    },
}

impl UploadBackend {
    /// Validate that a filename is safe (no path traversal, no special characters)
    fn validate_filename(filename: &str) -> Result<()> {
        if filename.is_empty()
            || filename.contains("..")
            || filename.contains('/')
            || filename.contains('\\')
            || filename.contains('\0')
        {
            return Err(crate::Error::Upload(format!(
                "Invalid filename: {:?}",
                filename
            )));
        }
        Ok(())
    }

    /// Store a file and return its public URL
    pub async fn put(&self, filename: &str, data: &[u8], content_type: &str) -> Result<String> {
        Self::validate_filename(filename)?;
        match self {
            Self::Local { directory } => {
                let path = directory.join(filename);
                tokio::fs::write(&path, data).await?;
                Ok(format!("/uploads/{}", filename))
            }
            Self::R2 {
                client,
                account_id,
                bucket,
                api_token,
                public_url,
            } => {
                let url = format!(
                    "https://api.cloudflare.com/client/v4/accounts/{}/r2/buckets/{}/objects/{}",
                    account_id, bucket, filename
                );

                let resp = client
                    .put(&url)
                    .bearer_auth(api_token)
                    .header("Content-Type", content_type)
                    .body(data.to_vec())
                    .send()
                    .await
                    .map_err(|e| crate::Error::Upload(format!("R2 upload failed: {}", e)))?;

                if !resp.status().is_success() {
                    let status = resp.status();
                    let body = resp.text().await.unwrap_or_default();
                    return Err(crate::Error::Upload(format!(
                        "R2 upload error ({}): {}",
                        status, body
                    )));
                }

                Ok(format!("{}/{}", public_url.trim_end_matches('/'), filename))
            }
        }
    }

    /// Delete a file by filename
    pub async fn delete(&self, filename: &str) -> Result<()> {
        Self::validate_filename(filename)?;
        match self {
            Self::Local { directory } => {
                let path = directory.join(filename);
                if path.exists() {
                    tokio::fs::remove_file(&path).await?;
                }
                Ok(())
            }
            Self::R2 {
                client,
                account_id,
                bucket,
                api_token,
                ..
            } => {
                let url = format!(
                    "https://api.cloudflare.com/client/v4/accounts/{}/r2/buckets/{}/objects/{}",
                    account_id, bucket, filename
                );

                let resp = client
                    .delete(&url)
                    .bearer_auth(api_token)
                    .send()
                    .await
                    .map_err(|e| crate::Error::Upload(format!("R2 delete failed: {}", e)))?;

                if !resp.status().is_success() && resp.status().as_u16() != 404 {
                    let status = resp.status();
                    let body = resp.text().await.unwrap_or_default();
                    return Err(crate::Error::Upload(format!(
                        "R2 delete error ({}): {}",
                        status, body
                    )));
                }

                Ok(())
            }
        }
    }
}