Skip to main content

what_core/
uploads.rs

1//! Upload storage backends — local filesystem and Cloudflare R2
2
3use crate::Result;
4
5/// Upload storage backend
6#[derive(Clone)]
7pub enum UploadBackend {
8    Local {
9        directory: std::path::PathBuf,
10    },
11    R2 {
12        client: reqwest::Client,
13        account_id: String,
14        bucket: String,
15        api_token: String,
16        public_url: String,
17    },
18}
19
20impl UploadBackend {
21    /// Validate that a filename is safe (no path traversal, no special characters)
22    fn validate_filename(filename: &str) -> Result<()> {
23        if filename.is_empty()
24            || filename.contains("..")
25            || filename.contains('/')
26            || filename.contains('\\')
27            || filename.contains('\0')
28        {
29            return Err(crate::Error::Upload(format!(
30                "Invalid filename: {:?}",
31                filename
32            )));
33        }
34        Ok(())
35    }
36
37    /// Store a file and return its public URL
38    pub async fn put(&self, filename: &str, data: &[u8], content_type: &str) -> Result<String> {
39        Self::validate_filename(filename)?;
40        match self {
41            Self::Local { directory } => {
42                let path = directory.join(filename);
43                tokio::fs::write(&path, data).await?;
44                Ok(format!("/uploads/{}", filename))
45            }
46            Self::R2 {
47                client,
48                account_id,
49                bucket,
50                api_token,
51                public_url,
52            } => {
53                let url = format!(
54                    "https://api.cloudflare.com/client/v4/accounts/{}/r2/buckets/{}/objects/{}",
55                    account_id, bucket, filename
56                );
57
58                let resp = client
59                    .put(&url)
60                    .bearer_auth(api_token)
61                    .header("Content-Type", content_type)
62                    .body(data.to_vec())
63                    .send()
64                    .await
65                    .map_err(|e| crate::Error::Upload(format!("R2 upload failed: {}", e)))?;
66
67                if !resp.status().is_success() {
68                    let status = resp.status();
69                    let body = resp.text().await.unwrap_or_default();
70                    return Err(crate::Error::Upload(format!(
71                        "R2 upload error ({}): {}",
72                        status, body
73                    )));
74                }
75
76                Ok(format!("{}/{}", public_url.trim_end_matches('/'), filename))
77            }
78        }
79    }
80
81    /// Delete a file by filename
82    pub async fn delete(&self, filename: &str) -> Result<()> {
83        Self::validate_filename(filename)?;
84        match self {
85            Self::Local { directory } => {
86                let path = directory.join(filename);
87                if path.exists() {
88                    tokio::fs::remove_file(&path).await?;
89                }
90                Ok(())
91            }
92            Self::R2 {
93                client,
94                account_id,
95                bucket,
96                api_token,
97                ..
98            } => {
99                let url = format!(
100                    "https://api.cloudflare.com/client/v4/accounts/{}/r2/buckets/{}/objects/{}",
101                    account_id, bucket, filename
102                );
103
104                let resp = client
105                    .delete(&url)
106                    .bearer_auth(api_token)
107                    .send()
108                    .await
109                    .map_err(|e| crate::Error::Upload(format!("R2 delete failed: {}", e)))?;
110
111                if !resp.status().is_success() && resp.status().as_u16() != 404 {
112                    let status = resp.status();
113                    let body = resp.text().await.unwrap_or_default();
114                    return Err(crate::Error::Upload(format!(
115                        "R2 delete error ({}): {}",
116                        status, body
117                    )));
118                }
119
120                Ok(())
121            }
122        }
123    }
124}