weave_content/
thumbnail.rs1use std::collections::HashMap;
2use std::sync::Arc;
3
4use crate::output::CaseOutput;
5
6const MAX_CONCURRENT: usize = 8;
8
9#[derive(Debug, Clone)]
11pub struct S3Config {
12 pub endpoint: String,
13 pub bucket: String,
14 pub region: String,
15 pub public_url: String,
16}
17
18impl S3Config {
19 pub fn from_args_or_env(
21 endpoint: Option<&str>,
22 bucket: Option<&str>,
23 region: Option<&str>,
24 public_url: Option<&str>,
25 ) -> Option<Self> {
26 let endpoint = endpoint
27 .map(String::from)
28 .or_else(|| std::env::var("S3_ENDPOINT").ok())?;
29 let bucket = bucket
30 .map(String::from)
31 .or_else(|| std::env::var("S3_FILES_BUCKET").ok())?;
32 let region = region
33 .map(String::from)
34 .or_else(|| std::env::var("S3_REGION").ok())?;
35 let public_url = public_url
36 .map(String::from)
37 .or_else(|| std::env::var("S3_FILES_PUBLIC_URL").ok())?;
38
39 Some(Self {
40 endpoint,
41 bucket,
42 region,
43 public_url,
44 })
45 }
46}
47
48pub fn process_thumbnails(
53 output: &mut CaseOutput,
54 config: &S3Config,
55 rt: &tokio::runtime::Runtime,
56) {
57 let mut source_urls: Vec<String> = Vec::new();
59 for node in &output.nodes {
60 if let Some(ref url) = node.thumbnail
61 && !source_urls.contains(url)
62 {
63 source_urls.push(url.clone());
64 }
65 }
66
67 if source_urls.is_empty() {
68 return;
69 }
70
71 eprintln!(
72 " processing {} thumbnail(s) (concurrency={})",
73 source_urls.len(),
74 MAX_CONCURRENT
75 );
76
77 let url_map = rt.block_on(process_all(source_urls, config));
79
80 for node in &mut output.nodes {
82 if let Some(ref source_url) = node.thumbnail {
83 match url_map.get(source_url) {
84 Some(Some(garage_url)) => {
85 node.thumbnail = Some(garage_url.clone());
86 }
87 Some(None) => {
88 node.thumbnail = None;
90 }
91 None => {}
92 }
93 }
94 }
95}
96
97async fn process_all(
100 source_urls: Vec<String>,
101 config: &S3Config,
102) -> HashMap<String, Option<String>> {
103 let bucket = match create_bucket(config) {
104 Ok(b) => Arc::new(b),
105 Err(e) => {
106 eprintln!(" S3 bucket init failed: {e}");
107 let mut map = HashMap::new();
108 for url in source_urls {
109 map.insert(url, None);
110 }
111 return map;
112 }
113 };
114
115 let semaphore = Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT));
116 let public_url = Arc::new(config.public_url.trim_end_matches('/').to_string());
117
118 let mut handles = Vec::new();
119
120 for url in source_urls {
121 let sem = semaphore.clone();
122 let bkt = bucket.clone();
123 let pub_url = public_url.clone();
124 handles.push(tokio::spawn(async move {
125 let _permit = sem.acquire().await;
126 let result = process_one(&url, &bkt, &pub_url).await;
127 (url, result)
128 }));
129 }
130
131 let mut map = HashMap::new();
132 for handle in handles {
133 match handle.await {
134 Ok((url, result)) => {
135 map.insert(url, result);
136 }
137 Err(e) => {
138 eprintln!(" thumbnail task panicked: {e}");
139 }
140 }
141 }
142
143 map
144}
145
146async fn process_one(source_url: &str, bucket: &s3::Bucket, public_url: &str) -> Option<String> {
148 let key = weave_image::thumbnail_key(source_url);
149 let garage_url = format!("{public_url}/{key}");
150
151 if let Ok((_, 200)) = bucket.head_object(&key).await {
153 eprintln!(" exists {source_url} -> {garage_url}");
154 return Some(garage_url);
155 }
156
157 let thumb = match weave_image::process_thumbnail(source_url) {
159 Ok(t) => t,
160 Err(e) => {
161 eprintln!(" warn {source_url}: {e}");
162 return None;
163 }
164 };
165
166 match bucket
168 .put_object_with_content_type(&key, &thumb.data, "image/webp")
169 .await
170 {
171 Ok(response) => {
172 let status = response.status_code();
173 if status == 200 || status == 201 {
174 eprintln!(
175 " uploaded {source_url} -> {garage_url} ({} bytes)",
176 thumb.data.len()
177 );
178 Some(garage_url)
179 } else {
180 eprintln!(" warn {source_url}: S3 PUT returned HTTP {status}");
181 None
182 }
183 }
184 Err(e) => {
185 eprintln!(" warn {source_url}: S3 upload failed: {e}");
186 None
187 }
188 }
189}
190
191fn create_bucket(config: &S3Config) -> Result<s3::Bucket, String> {
192 let region = s3::Region::Custom {
193 region: config.region.clone(),
194 endpoint: config.endpoint.clone(),
195 };
196
197 let access_key =
198 std::env::var("AWS_ACCESS_KEY_ID").map_err(|_| "AWS_ACCESS_KEY_ID not set".to_string())?;
199 let secret_key = std::env::var("AWS_SECRET_ACCESS_KEY")
200 .map_err(|_| "AWS_SECRET_ACCESS_KEY not set".to_string())?;
201
202 let credentials =
203 s3::creds::Credentials::new(Some(&access_key), Some(&secret_key), None, None, None)
204 .map_err(|e| e.to_string())?;
205
206 let bucket = s3::Bucket::new(&config.bucket, region, credentials)
207 .map_err(|e| e.to_string())?
208 .with_path_style();
209
210 Ok(*bucket)
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn s3_config_from_args() {
219 let config = S3Config::from_args_or_env(
220 Some("http://localhost:3900"),
221 Some("files"),
222 Some("garage"),
223 Some("http://localhost:3902"),
224 );
225 assert!(config.is_some());
226 let c = config.unwrap_or_else(|| unreachable!());
227 assert_eq!(c.endpoint, "http://localhost:3900");
228 assert_eq!(c.bucket, "files");
229 assert_eq!(c.region, "garage");
230 assert_eq!(c.public_url, "http://localhost:3902");
231 }
232
233 #[test]
234 fn s3_config_missing_returns_none() {
235 let config = S3Config::from_args_or_env(None, None, None, None);
237 if std::env::var("S3_ENDPOINT").is_err() {
239 assert!(config.is_none());
240 }
241 }
242
243 #[test]
244 fn process_thumbnails_no_thumbnails() {
245 let mut output = CaseOutput {
246 case_id: "test".into(),
247 title: "Test".into(),
248 summary: String::new(),
249 nodes: vec![],
250 relationships: vec![],
251 sources: vec![],
252 };
253 let config = S3Config {
254 endpoint: "http://localhost:3900".into(),
255 bucket: "files".into(),
256 region: "garage".into(),
257 public_url: "http://localhost:3902".into(),
258 };
259 let rt = tokio::runtime::Builder::new_current_thread()
260 .enable_all()
261 .build()
262 .unwrap_or_else(|_| unreachable!());
263 process_thumbnails(&mut output, &config, &rt);
265 assert!(output.nodes.is_empty());
266 }
267}