1use 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
14pub struct S3Storage {
17 bucket: Box<Bucket>,
18 url_prefix: String,
19 config_id: String,
20}
21
22#[derive(Debug, Clone)]
24pub struct UploadResult {
25 pub remote_key: String,
27 pub remote_url: String,
29 pub content_hash: String,
31 pub extension: String,
33}
34
35impl S3Storage {
36 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(), 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 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, None, None, )
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(); 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 pub fn config_id(&self) -> &str {
103 &self.config_id
104 }
105
106 pub fn url_prefix(&self) -> &str {
108 &self.url_prefix
109 }
110
111 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 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 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 }
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 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 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 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 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 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 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}