Skip to main content

typub_storage/
s3.rs

1//! S3-compatible storage client.
2//!
3//! Per [[RFC-0004:C-UPLOAD-TRACKING]].
4
5use anyhow::{Context, Result};
6use s3::creds::Credentials;
7use s3::{Bucket, Region};
8use sha2::{Digest, Sha256};
9use std::path::Path;
10use typub_config::StorageConfig;
11
12use crate::mime_type_from_path;
13
14/// S3-compatible storage client
15/// Per [[RFC-0004:C-UPLOAD-TRACKING]]
16pub struct S3Storage {
17    bucket: Box<Bucket>,
18    url_prefix: String,
19    config_id: String,
20}
21
22/// Result of an asset upload
23#[derive(Debug, Clone)]
24pub struct UploadResult {
25    /// Remote object key
26    pub remote_key: String,
27    /// Public URL of the uploaded asset
28    pub remote_url: String,
29    /// Content hash (SHA-256, lowercase hex, 64 chars)
30    pub content_hash: String,
31    /// Normalized extension (lowercase alphanumeric only)
32    pub extension: String,
33}
34
35impl S3Storage {
36    /// Create a new S3Storage client from config
37    pub fn new(config: &StorageConfig) -> Result<Self> {
38        config.validate()?;
39
40        let bucket_name = config
41            .bucket
42            .as_ref()
43            .context("bucket is required")?
44            .clone();
45
46        let region = match (&config.endpoint, &config.region) {
47            (Some(endpoint), Some(region)) => Region::Custom {
48                region: region.clone(),
49                endpoint: endpoint.clone(),
50            },
51            (Some(endpoint), None) => Region::Custom {
52                region: "auto".to_string(), // R2 uses "auto"
53                endpoint: endpoint.clone(),
54            },
55            (None, Some(region)) => region.parse().unwrap_or_else(|_| Region::Custom {
56                region: region.clone(),
57                endpoint: format!("https://s3.{}.amazonaws.com", region),
58            }),
59            (None, None) => Region::UsEast1,
60        };
61
62        // Resolve credentials with fallback to environment variables
63        let access_key = config
64            .access_key_id
65            .clone()
66            .or_else(|| std::env::var("S3_ACCESS_KEY_ID").ok())
67            .or_else(|| std::env::var("AWS_ACCESS_KEY_ID").ok());
68
69        let secret_key = config
70            .secret_access_key
71            .clone()
72            .or_else(|| std::env::var("S3_SECRET_ACCESS_KEY").ok())
73            .or_else(|| std::env::var("AWS_SECRET_ACCESS_KEY").ok());
74
75        let credentials = Credentials::new(
76            access_key.as_deref(),
77            secret_key.as_deref(),
78            None, // security_token
79            None, // session_token
80            None, // profile
81        )
82        .context("Failed to create S3 credentials")?;
83
84        let bucket = Bucket::new(&bucket_name, region, credentials)
85            .context("Failed to create S3 bucket client")?
86            .with_path_style(); // Required for some S3-compatible services
87
88        let url_prefix = config
89            .normalized_url_prefix()
90            .context("url_prefix is required")?;
91
92        let config_id = config.config_id();
93
94        Ok(Self {
95            bucket,
96            url_prefix,
97            config_id,
98        })
99    }
100
101    /// Get the storage configuration identifier
102    pub fn config_id(&self) -> &str {
103        &self.config_id
104    }
105
106    /// Get the URL prefix
107    pub fn url_prefix(&self) -> &str {
108        &self.url_prefix
109    }
110
111    /// Upload an asset to S3
112    /// Per [[RFC-0004:C-UPLOAD-TRACKING]] and [[RFC-0004:C-URL-CONSTRUCTION]]
113    pub async fn upload(&self, local_path: &Path, data: &[u8]) -> Result<UploadResult> {
114        let content_hash = Self::compute_hash(data);
115        let extension = Self::normalize_extension(local_path);
116        let remote_key = Self::build_object_key(&content_hash, &extension);
117        let mime_type = mime_type_from_path(local_path);
118
119        // Upload to S3
120        let response = self
121            .bucket
122            .put_object_with_content_type(&remote_key, data, mime_type)
123            .await;
124
125        match response {
126            Ok(_) => {}
127            Err(e) => {
128                // Check if it's an AlreadyExists-like error (treat as success per RFC-0004)
129                let err_str = e.to_string();
130                if !err_str.contains("PreconditionFailed")
131                    && !err_str.contains("AlreadyExists")
132                    && !err_str.contains("409")
133                {
134                    return Err(e).context(format!(
135                        "Failed to upload asset '{}' to S3",
136                        local_path.display()
137                    ));
138                }
139                // AlreadyExists is success for content-addressable keys
140            }
141        }
142
143        let remote_url = Self::build_url(&self.url_prefix, &remote_key);
144
145        Ok(UploadResult {
146            remote_key,
147            remote_url,
148            content_hash,
149            extension,
150        })
151    }
152
153    /// Check if an object exists in S3
154    pub async fn exists(&self, key: &str) -> Result<bool> {
155        match self.bucket.head_object(key).await {
156            Ok(_) => Ok(true),
157            Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Ok(false),
158            Err(e) => Err(e).context(format!("Failed to check if object '{}' exists", key)),
159        }
160    }
161
162    /// Compute SHA-256 hash of data (lowercase hex, 64 chars)
163    /// Per [[RFC-0004:C-UPLOAD-TRACKING]]
164    pub fn compute_hash(data: &[u8]) -> String {
165        let mut hasher = Sha256::new();
166        hasher.update(data);
167        let result = hasher.finalize();
168        hex::encode(result)
169    }
170
171    /// Normalize file extension per [[RFC-0004:C-UPLOAD-TRACKING]]
172    /// 1. Extract extension from filename
173    /// 2. Convert to lowercase
174    /// 3. Remove non-alphanumeric characters
175    /// 4. Return empty string if result is empty
176    pub fn normalize_extension(path: &Path) -> String {
177        path.extension()
178            .and_then(|e| e.to_str())
179            .map(|e| {
180                e.to_lowercase()
181                    .chars()
182                    .filter(|c| c.is_ascii_alphanumeric())
183                    .collect::<String>()
184            })
185            .unwrap_or_default()
186    }
187
188    /// Build object key per [[RFC-0004:C-UPLOAD-TRACKING]]
189    /// Format: {content_hash}.{extension} or {content_hash} if no extension
190    pub fn build_object_key(content_hash: &str, extension: &str) -> String {
191        if extension.is_empty() {
192            content_hash.to_string()
193        } else {
194            format!("{}.{}", content_hash, extension)
195        }
196    }
197
198    /// Build public URL per [[RFC-0004:C-URL-CONSTRUCTION]]
199    /// Format: {url_prefix}/{object_key}
200    pub fn build_url(url_prefix: &str, object_key: &str) -> String {
201        format!("{}/{}", url_prefix, object_key)
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    #![allow(clippy::expect_used)]
208    use super::*;
209
210    #[test]
211    fn test_compute_hash() {
212        // SHA-256 of "hello"
213        let hash = S3Storage::compute_hash(b"hello");
214        assert_eq!(hash.len(), 64);
215        assert_eq!(
216            hash,
217            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
218        );
219    }
220
221    #[test]
222    fn test_normalize_extension() {
223        assert_eq!(
224            S3Storage::normalize_extension(Path::new("image.PNG")),
225            "png"
226        );
227        assert_eq!(
228            S3Storage::normalize_extension(Path::new("photo.JPEG")),
229            "jpeg"
230        );
231        assert_eq!(S3Storage::normalize_extension(Path::new("noext")), "");
232    }
233
234    #[test]
235    fn test_build_object_key() {
236        let hash = "abc123";
237        assert_eq!(S3Storage::build_object_key(hash, "png"), "abc123.png");
238        assert_eq!(S3Storage::build_object_key(hash, ""), "abc123");
239    }
240
241    #[test]
242    fn test_build_url() {
243        assert_eq!(
244            S3Storage::build_url("https://cdn.example.com", "abc123.png"),
245            "https://cdn.example.com/abc123.png"
246        );
247    }
248}