Skip to main content

weave_content/
thumbnail.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use crate::output::CaseOutput;
5
6/// Maximum concurrent thumbnail downloads.
7const MAX_CONCURRENT: usize = 8;
8
9/// S3 configuration for thumbnail uploads.
10#[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    /// Build from CLI flags, falling back to environment variables.
20    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
48/// Process all thumbnails in a case output: download, resize, upload to S3,
49/// and replace thumbnail URLs with Garage public URLs.
50///
51/// Failures are warnings -- the node gets `thumbnail: null` in the output.
52pub fn process_thumbnails(
53    output: &mut CaseOutput,
54    config: &S3Config,
55    rt: &tokio::runtime::Runtime,
56) {
57    // Collect unique thumbnail source URLs
58    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    // Process all thumbnails concurrently
78    let url_map = rt.block_on(process_all(source_urls, config));
79
80    // Replace thumbnail URLs in output
81    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                    // Processing failed, null out thumbnail
89                    node.thumbnail = None;
90                }
91                None => {}
92            }
93        }
94    }
95}
96
97/// Process all thumbnail URLs concurrently. Returns a map of
98/// `source_url -> Option<garage_url>` (None = failed).
99async 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
146/// Process a single thumbnail: check S3 existence, download/resize/upload if needed.
147async 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    // Check if already exists via HEAD
152    if let Ok((_, 200)) = bucket.head_object(&key).await {
153        eprintln!("  exists {source_url} -> {garage_url}");
154        return Some(garage_url);
155    }
156
157    // Download and resize
158    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    // Upload to S3
167    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        // Without env vars or args, should return None
236        let config = S3Config::from_args_or_env(None, None, None, None);
237        // This depends on env vars; if none set, returns None
238        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        // Should be a no-op
264        process_thumbnails(&mut output, &config, &rt);
265        assert!(output.nodes.is_empty());
266    }
267}