1use anyhow::Result;
4use aws_config::BehaviorVersion;
5use aws_sdk_s3::{config::Credentials, primitives::ByteStream, Client};
6use colored::Colorize;
7use std::collections::HashMap;
8
9use super::{decode_base64, encode_base64, ConfigFile};
10
11#[derive(Debug, Clone)]
13pub struct S3Config {
14 pub bucket_name: String,
15 pub region: String,
16 pub access_key_id: String,
17 pub secret_access_key: String,
18 pub endpoint_url: Option<String>,
19}
20
21pub struct S3Provider {
23 client: Client,
24 bucket_name: String,
25 folder_prefix: String,
26}
27
28impl S3Provider {
29 pub async fn new_with_provider(provider_name: &str) -> Result<Self> {
31 let s3_config = Self::get_s3_config(provider_name).await?;
32
33 let mut config_builder = aws_config::defaults(BehaviorVersion::latest())
35 .region(aws_config::Region::new(s3_config.region.clone()))
36 .credentials_provider(Credentials::new(
37 s3_config.access_key_id.clone(),
38 s3_config.secret_access_key.clone(),
39 None,
40 None,
41 "lc-sync",
42 ));
43
44 if let Some(endpoint_url) = &s3_config.endpoint_url {
46 config_builder = config_builder.endpoint_url(endpoint_url);
47 }
48
49 let config = config_builder.load().await;
50 let client = Client::new(&config);
51
52 let folder_prefix = "llm_client_config".to_string();
53
54 Ok(Self {
55 client,
56 bucket_name: s3_config.bucket_name,
57 folder_prefix,
58 })
59 }
60
61 async fn get_s3_config(provider_name: &str) -> Result<S3Config> {
63 use crate::sync::config::{ProviderConfig, SyncConfig};
64 use std::io::{self, Write};
65
66 if let Ok(sync_config) = SyncConfig::load() {
68 if let Some(ProviderConfig::S3 {
69 bucket_name,
70 region,
71 access_key_id,
72 secret_access_key,
73 endpoint_url,
74 }) = sync_config.get_provider(provider_name)
75 {
76 println!("{} Using stored S3 configuration for '{}'", "â".green(), provider_name);
77 return Ok(S3Config {
78 bucket_name: bucket_name.clone(),
79 region: region.clone(),
80 access_key_id: access_key_id.clone(),
81 secret_access_key: secret_access_key.clone(),
82 endpoint_url: endpoint_url.clone(),
83 });
84 }
85 }
86
87 println!("{} S3 Configuration Setup for '{}'", "đ§".blue(), provider_name);
88 println!("{} No stored configuration found. You can:", "đĄ".yellow());
89 println!(
90 " - Set up configuration: {}",
91 format!("lc sync configure {} setup", provider_name).dimmed()
92 );
93 println!(" - Use environment variables:");
94 println!(" LC_S3_BUCKET, LC_S3_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, LC_S3_ENDPOINT");
95 println!(" - Enter credentials interactively (below)");
96 println!();
97
98 let bucket_name = if let Ok(bucket) = std::env::var("LC_S3_BUCKET") {
100 println!("{} Using bucket from LC_S3_BUCKET: {}", "â".green(), bucket);
101 bucket
102 } else {
103 print!("Enter S3 bucket name: ");
104 io::stdout().flush()?;
106 let mut input = String::new();
107 io::stdin().read_line(&mut input)?;
108 let bucket = input.trim().to_string();
109 if bucket.is_empty() {
110 anyhow::bail!("Bucket name cannot be empty");
111 }
112 bucket
113 };
114
115 let region = if let Ok(region) = std::env::var("LC_S3_REGION") {
116 println!("{} Using region from LC_S3_REGION: {}", "â".green(), region);
117 region
118 } else {
119 print!("Enter AWS region (default: us-east-1): ");
120 io::stdout().flush()?;
122 let mut input = String::new();
123 io::stdin().read_line(&mut input)?;
124 let region = input.trim().to_string();
125 if region.is_empty() {
126 "us-east-1".to_string()
127 } else {
128 region
129 }
130 };
131
132 let access_key_id = if let Ok(key) = std::env::var("AWS_ACCESS_KEY_ID") {
133 println!("{} Using access key from AWS_ACCESS_KEY_ID", "â".green());
134 key
135 } else {
136 print!("Enter AWS Access Key ID: ");
137 io::stdout().flush()?;
139 let mut input = String::new();
140 io::stdin().read_line(&mut input)?;
141 let key = input.trim().to_string();
142 if key.is_empty() {
143 anyhow::bail!("Access Key ID cannot be empty");
144 }
145 key
146 };
147
148 let secret_access_key = if let Ok(secret) = std::env::var("AWS_SECRET_ACCESS_KEY") {
149 println!(
150 "{} Using secret key from AWS_SECRET_ACCESS_KEY",
151 "â".green()
152 );
153 secret
154 } else {
155 print!("Enter AWS Secret Access Key: ");
156 io::stdout().flush()?;
158 let secret = rpassword::read_password()?;
159 if secret.is_empty() {
160 anyhow::bail!("Secret Access Key cannot be empty");
161 }
162 secret
163 };
164
165 let endpoint_url = if let Ok(endpoint) = std::env::var("LC_S3_ENDPOINT") {
166 println!(
167 "{} Using custom endpoint from LC_S3_ENDPOINT: {}",
168 "â".green(),
169 endpoint
170 );
171 Some(endpoint)
172 } else {
173 print!("Enter custom S3 endpoint URL (optional, for Backblaze/Cloudflare R2/etc., press Enter to skip): ");
174 io::stdout().flush()?;
176 let mut input = String::new();
177 io::stdin().read_line(&mut input)?;
178 let endpoint = input.trim().to_string();
179 if endpoint.is_empty() {
180 None
181 } else {
182 Some(endpoint)
183 }
184 };
185
186 Ok(S3Config {
187 bucket_name,
188 region,
189 access_key_id,
190 secret_access_key,
191 endpoint_url,
192 })
193 }
194
195 pub async fn upload_configs(&self, files: &[ConfigFile], encrypted: bool) -> Result<()> {
197 println!(
198 "{} Uploading to S3 bucket: {}",
199 "đ¤".blue(),
200 self.bucket_name
201 );
202
203 match self
205 .client
206 .head_bucket()
207 .bucket(&self.bucket_name)
208 .send()
209 .await
210 {
211 Ok(_) => {
212 println!("{} Bucket access verified", "â".green());
213 }
214 Err(e) => {
215 anyhow::bail!("Cannot access S3 bucket '{}': {}. Please check your AWS credentials and bucket permissions.", self.bucket_name, e);
216 }
217 }
218
219 let mut uploaded_count = 0;
220
221 for file in files {
222 let key = format!("{}/{}", self.folder_prefix, file.name);
223
224 let content_b64 = encode_base64(&file.content);
226
227 let mut metadata = HashMap::new();
229 metadata.insert("original-name".to_string(), file.name.clone());
230 metadata.insert("encrypted".to_string(), encrypted.to_string());
231 metadata.insert("encoding".to_string(), "base64".to_string());
232 metadata.insert("sync-tool".to_string(), "lc".to_string());
233 metadata.insert("sync-version".to_string(), "1.0".to_string());
234
235 let file_type = if file.name.ends_with(".toml") {
237 "config"
238 } else if file.name == "logs.db" {
239 "database"
240 } else {
241 "unknown"
242 };
243 metadata.insert("file-type".to_string(), file_type.to_string());
244
245 metadata.insert("file-size".to_string(), file.content.len().to_string());
247
248 match self
249 .client
250 .put_object()
251 .bucket(&self.bucket_name)
252 .key(&key)
253 .body(ByteStream::from(content_b64.into_bytes()))
254 .content_type("text/plain")
255 .set_metadata(Some(metadata))
256 .send()
257 .await
258 {
259 Ok(_) => {
260 println!(" {} Uploaded: {}", "â".green(), file.name);
261 uploaded_count += 1;
262 }
263 Err(e) => {
264 crate::debug_log!("Failed to upload {}: {}", file.name, e);
265 eprintln!(" {} Failed to upload {}: {}", "â".red(), file.name, e);
266 }
267 }
268 }
269
270 if uploaded_count == files.len() {
271 println!(
272 "{} All {} files uploaded successfully",
273 "đ".green(),
274 uploaded_count
275 );
276 } else {
277 println!(
278 "{} Uploaded {}/{} files",
279 "â ī¸".yellow(),
280 uploaded_count,
281 files.len()
282 );
283 }
284
285 Ok(())
286 }
287
288 pub async fn download_configs(&self, encrypted: bool) -> Result<Vec<ConfigFile>> {
290 println!(
291 "{} Downloading from S3 bucket: {}",
292 "đĨ".blue(),
293 self.bucket_name
294 );
295
296 let list_response = self
298 .client
299 .list_objects_v2()
300 .bucket(&self.bucket_name)
301 .prefix(&self.folder_prefix)
302 .send()
303 .await
304 .map_err(|e| {
305 anyhow::anyhow!(
306 "Failed to list objects in bucket '{}': {}",
307 self.bucket_name,
308 e
309 )
310 })?;
311
312 let objects = list_response.contents();
313
314 if objects.is_empty() {
315 println!("{} No configuration files found in S3", "âšī¸".blue());
316 return Ok(Vec::new());
317 }
318
319 println!("{} Found {} objects in S3", "đ".blue(), objects.len());
320
321 let mut downloaded_files = Vec::new();
322
323 for object in objects {
324 if let Some(key) = object.key() {
325 if key.ends_with('/') {
327 continue;
328 }
329
330 let filename = key
332 .strip_prefix(&format!("{}/", self.folder_prefix))
333 .unwrap_or(key)
334 .to_string();
335
336 match self
337 .client
338 .get_object()
339 .bucket(&self.bucket_name)
340 .key(key)
341 .send()
342 .await
343 {
344 Ok(response) => {
345 let metadata = response.metadata().cloned().unwrap_or_default();
347 let is_encrypted = metadata
348 .get("encrypted")
349 .map(|v| v == "true")
350 .unwrap_or(false);
351
352 let body =
354 response.body.collect().await.map_err(|e| {
355 anyhow::anyhow!("Failed to read object body: {}", e)
356 })?;
357 let content_b64 =
358 String::from_utf8(body.into_bytes().to_vec()).map_err(|e| {
359 anyhow::anyhow!("Invalid UTF-8 in object content: {}", e)
360 })?;
361
362 let content = decode_base64(&content_b64).map_err(|e| {
364 anyhow::anyhow!(
365 "Failed to decode base64 content for {}: {}",
366 filename,
367 e
368 )
369 })?;
370
371 if encrypted && !is_encrypted {
372 crate::debug_log!(
373 "Warning: {} is not encrypted but --encrypted flag was used",
374 filename
375 );
376 eprintln!(
377 " {} Warning: {} is not encrypted but --encrypted flag was used",
378 "â ī¸".yellow(),
379 filename
380 );
381 } else if !encrypted && is_encrypted {
382 crate::debug_log!(
383 "Warning: {} is encrypted but --encrypted flag was not used",
384 filename
385 );
386 eprintln!(
387 " {} Warning: {} is encrypted but --encrypted flag was not used",
388 "â ī¸".yellow(),
389 filename
390 );
391 }
392
393 downloaded_files.push(ConfigFile {
394 name: filename.clone(),
395 path: std::path::PathBuf::from(&filename),
396 content,
397 });
398
399 println!(" {} Downloaded: {}", "â".green(), filename);
400 }
401 Err(e) => {
402 crate::debug_log!("Failed to download {}: {}", filename, e);
403 eprintln!(" {} Failed to download {}: {}", "â".red(), filename, e);
404 }
405 }
406 }
407 }
408
409 println!(
410 "{} Downloaded {} files successfully",
411 "đ".green(),
412 downloaded_files.len()
413 );
414
415 Ok(downloaded_files)
416 }
417
418 #[allow(dead_code)]
420 pub async fn list_configs(&self) -> Result<Vec<String>> {
421 let list_response = self
422 .client
423 .list_objects_v2()
424 .bucket(&self.bucket_name)
425 .prefix(&self.folder_prefix)
426 .send()
427 .await
428 .map_err(|e| anyhow::anyhow!("Failed to list objects: {}", e))?;
429
430 let mut filenames = Vec::new();
431
432 for object in list_response.contents() {
433 if let Some(key) = object.key() {
434 if !key.ends_with('/') {
435 let filename = key
436 .strip_prefix(&format!("{}/", self.folder_prefix))
437 .unwrap_or(key)
438 .to_string();
439 filenames.push(filename);
440 }
441 }
442 }
443
444 Ok(filenames)
445 }
446
447 #[allow(dead_code)]
449 pub async fn delete_configs(&self, filenames: &[String]) -> Result<()> {
450 for filename in filenames {
451 let key = format!("{}/{}", self.folder_prefix, filename);
452
453 match self
454 .client
455 .delete_object()
456 .bucket(&self.bucket_name)
457 .key(&key)
458 .send()
459 .await
460 {
461 Ok(_) => {
462 println!(" {} Deleted: {}", "â".green(), filename);
463 }
464 Err(e) => {
465 crate::debug_log!("Failed to delete {}: {}", filename, e);
466 eprintln!(" {} Failed to delete {}: {}", "â".red(), filename, e);
467 }
468 }
469 }
470
471 Ok(())
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 #[test]
480 fn test_s3_provider_creation() {
481 assert_eq!("llm_client_config", "llm_client_config");
483 }
484
485 #[test]
486 fn test_s3_config_creation() {
487 let config = S3Config {
489 bucket_name: "test-bucket".to_string(),
490 region: "us-east-1".to_string(),
491 access_key_id: "test-key".to_string(),
492 secret_access_key: "test-secret".to_string(),
493 endpoint_url: None,
494 };
495
496 assert_eq!(config.bucket_name, "test-bucket");
497 assert_eq!(config.region, "us-east-1");
498 assert_eq!(config.access_key_id, "test-key");
499 assert_eq!(config.secret_access_key, "test-secret");
500 assert!(config.endpoint_url.is_none());
501 }
502}