grain.social is a photo sharing platform built on atproto.
at main 25 kB view raw
1use anyhow::{Context, Result}; 2use clap::{Parser, Subcommand}; 3use dialoguer::{Confirm, Input}; 4use reqwest::{header::HeaderMap, Client, Method}; 5use serde::{Deserialize, Serialize}; 6use serde_json::Value; 7use std::collections::HashMap; 8use std::sync::{Arc, Mutex}; 9use std::fs; 10use std::path::Path; 11use std::process; 12use std::time::Duration; 13use hyper::server::conn::http1; 14use hyper::service::service_fn; 15use hyper::{body::Incoming as IncomingBody, Request, Response, StatusCode}; 16use hyper_util::rt::TokioIo; 17use http_body_util::Full; 18use tokio::net::TcpListener; 19use indicatif::{ProgressBar, ProgressStyle}; 20use dirs; 21 22mod photo_manip; 23use photo_manip::{do_resize, ResizeOptions}; 24 25const API_BASE: &str = "http://localhost:8080"; 26const OAUTH_PORT: u16 = 8787; 27const OAUTH_PATH: &str = "/callback"; 28const OAUTH_TIMEOUT: Duration = Duration::from_secs(300); // 5 minutes 29 30#[derive(Parser)] 31#[command(name = "grain")] 32#[command(about = "A CLI for grain.social")] 33struct Cli { 34 #[command(subcommand)] 35 command: Option<Commands>, 36 37 #[arg(short, long, global = true, help = "Enable verbose output")] 38 verbose: bool, 39} 40 41#[derive(Subcommand)] 42enum Commands { 43 #[command(about = "Authenticate with grain.social")] 44 Login, 45 #[command(about = "Manage your galleries")] 46 Gallery { 47 #[command(subcommand)] 48 action: GalleryAction, 49 }, 50} 51 52#[derive(Subcommand)] 53enum GalleryAction { 54 #[command(about = "List your galleries")] 55 List, 56 #[command(about = "Create a new gallery from a folder of images")] 57 Create, 58 #[command(about = "Delete a gallery")] 59 Delete, 60 #[command(about = "Open a gallery in the browser")] 61 Show, 62} 63 64#[derive(Serialize, Deserialize)] 65struct AuthData { 66 did: String, 67 token: String, 68 #[serde(rename = "expiresAt")] 69 expires_at: Option<String>, 70} 71 72#[derive(Deserialize)] 73struct GalleryItem { 74 title: Option<String>, 75 items: Option<Vec<Value>>, 76 uri: String, 77} 78 79#[derive(Debug, Deserialize)] 80struct LoginResponse { 81 url: Option<String>, 82} 83 84#[derive(Deserialize)] 85struct GalleriesResponse { 86 items: Option<Vec<GalleryItem>>, 87} 88 89#[derive(Debug, Deserialize)] 90struct CreateGalleryResponse { 91 #[serde(rename = "galleryUri")] 92 gallery_uri: String, 93} 94 95#[derive(Deserialize)] 96struct UploadPhotoResponse { 97 #[serde(rename = "photoUri")] 98 photo_uri: String, 99} 100 101fn exit_with_error(message: &str, code: i32) -> ! { 102 eprintln!("{}", message); 103 process::exit(code); 104} 105 106async fn make_request<T>( 107 client: &Client, 108 url: &str, 109 method: Method, 110 body: Option<Vec<u8>>, 111 token: Option<&str>, 112 content_type: Option<&str>, 113) -> Result<T> 114where 115 T: for<'de> Deserialize<'de>, 116{ 117 let mut headers = HeaderMap::new(); 118 headers.insert("Accept", "application/json".parse()?); 119 120 if let Some(token) = token { 121 headers.insert("Authorization", format!("Bearer {}", token).parse()?); 122 } 123 124 if let Some(ct) = content_type { 125 headers.insert("Content-Type", ct.parse()?); 126 } 127 128 let mut request = client.request(method, url).headers(headers); 129 130 if let Some(body) = body { 131 request = request.body(body); 132 } 133 134 let response = request.send().await?; 135 136 let status = response.status(); 137 if !status.is_success() { 138 let text = response.text().await?; 139 return Err(anyhow::anyhow!("HTTP {}: {}", status, text)); 140 } 141 142 let content_type = response.headers() 143 .get("content-type") 144 .and_then(|v| v.to_str().ok()) 145 .unwrap_or(""); 146 147 if !content_type.contains("application/json") { 148 return Err(anyhow::anyhow!("Expected JSON response")); 149 } 150 151 let data: T = response.json().await?; 152 Ok(data) 153} 154 155fn get_auth_file_path() -> Result<std::path::PathBuf> { 156 let config_dir = dirs::config_dir() 157 .ok_or_else(|| anyhow::anyhow!("Unable to determine config directory"))?; 158 let grain_config_dir = config_dir.join("grain"); 159 fs::create_dir_all(&grain_config_dir)?; 160 Ok(grain_config_dir.join("auth.json")) 161} 162 163async fn load_auth() -> Result<AuthData> { 164 let auth_file = get_auth_file_path()?; 165 let auth_text = fs::read_to_string(&auth_file) 166 .with_context(|| "Please run 'login' first")?; 167 168 let auth: AuthData = serde_json::from_str(&auth_text) 169 .with_context(|| "Invalid auth file format")?; 170 171 if auth.did.is_empty() || auth.token.is_empty() { 172 exit_with_error("Please re-authenticate.", 1); 173 } 174 175 if let Some(expires_at) = &auth.expires_at { 176 if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(expires_at) { 177 if chrono::Utc::now() >= expires { 178 exit_with_error("Authentication expired. Please re-authenticate.", 1); 179 } 180 } 181 } 182 183 Ok(auth) 184} 185 186 187async fn handle_login(client: &Client, verbose: bool) -> Result<()> { 188 let handle: String = Input::new() 189 .with_prompt("Enter your handle") 190 .default("ansel.grain.social".to_string()) 191 .interact()?; 192 193 let login_url = format!("{}/oauth/login?handle={}&client=cli", API_BASE, urlencoding::encode(&handle)); 194 195 let data: LoginResponse = make_request( 196 client, 197 &login_url, 198 Method::POST, 199 None, 200 None, 201 None, 202 ).await?; 203 204 if verbose { 205 println!("Login response: {:?}", data); 206 } 207 208 if let Some(url) = data.url { 209 let result = Arc::new(Mutex::new(None::<HashMap<String, String>>)); 210 let (tx, mut rx) = tokio::sync::oneshot::channel::<()>(); 211 let tx = Arc::new(Mutex::new(Some(tx))); 212 213 if verbose { 214 println!("Waiting for OAuth redirect on http://localhost:{}{}...", OAUTH_PORT, OAUTH_PATH); 215 } 216 217 // Open browser 218 open::that(&url)?; 219 if verbose { 220 println!("Opened browser for: {}", url); 221 } 222 223 // Start OAuth server with timeout 224 let listener = TcpListener::bind((std::net::Ipv4Addr::new(127, 0, 0, 1), OAUTH_PORT)).await?; 225 226 let result_for_task = result.clone(); 227 let tx_for_task = tx.clone(); 228 229 let server_task = async move { 230 if verbose { 231 println!("OAuth server listening on port {}...", OAUTH_PORT); 232 } 233 234 match listener.accept().await { 235 Ok((stream, addr)) => { 236 if verbose { 237 println!("Received connection from: {}", addr); 238 } 239 240 let io = TokioIo::new(stream); 241 let result_clone = result_for_task.clone(); 242 let tx_clone = tx_for_task.clone(); 243 244 let service = service_fn(move |req: Request<IncomingBody>| { 245 let result = result_clone.clone(); 246 let tx = tx_clone.clone(); 247 async move { 248 let uri = req.uri(); 249 if verbose { 250 println!("Received request: {} {}", req.method(), uri); 251 } 252 253 if uri.path() == OAUTH_PATH { 254 if verbose { 255 println!("Matched OAuth callback path: {}", OAUTH_PATH); 256 } 257 258 if let Some(query) = uri.query() { 259 if verbose { 260 println!("Query string: {}", query); 261 } 262 263 let params: HashMap<String, String> = url::form_urlencoded::parse(query.as_bytes()) 264 .into_owned() 265 .collect(); 266 267 if verbose { 268 println!("Parsed parameters: {:?}", params); 269 } 270 271 if let Ok(mut r) = result.lock() { 272 *r = Some(params); 273 if verbose { 274 println!("Successfully stored OAuth parameters"); 275 } 276 277 // Signal that we have the parameters 278 if let Ok(mut tx_guard) = tx.lock() { 279 if let Some(sender) = tx_guard.take() { 280 let _ = sender.send(()); 281 if verbose { 282 println!("Sent completion signal"); 283 } 284 } 285 } 286 } else if verbose { 287 println!("Failed to lock result mutex"); 288 } 289 } else if verbose { 290 println!("No query string found in OAuth callback"); 291 } 292 293 let html = r#" 294 <!DOCTYPE html> 295 <html lang="en"> 296 <head> 297 <meta charset="UTF-8" /> 298 <title>Authentication Complete</title> 299 <style> 300 body { font-family: sans-serif; text-align: center; margin-top: 10vh; } 301 .box { display: inline-block; padding: 2em; border: 1px solid #ccc; border-radius: 8px; background: #fafafa; } 302 </style> 303 </head> 304 <body> 305 <div class="box"> 306 <h1>✅ Authentication Complete</h1> 307 <p>You may now return to your terminal.</p> 308 <p>You can close this tab.</p> 309 </div> 310 </body> 311 </html> 312 "#; 313 314 Ok::<_, anyhow::Error>(Response::builder() 315 .status(StatusCode::OK) 316 .header("content-type", "text/html; charset=utf-8") 317 .body(Full::new(hyper::body::Bytes::from(html)))?) 318 } else { 319 if verbose { 320 println!("Path '{}' does not match OAuth callback path '{}'", uri.path(), OAUTH_PATH); 321 } 322 323 Ok(Response::builder() 324 .status(StatusCode::NOT_FOUND) 325 .body(Full::new(hyper::body::Bytes::from("Not found")))?) 326 } 327 } 328 }); 329 330 // Handle the connection with timeout 331 if verbose { 332 println!("Serving HTTP connection..."); 333 } 334 335 let connection = http1::Builder::new() 336 .serve_connection(io, service); 337 338 match tokio::time::timeout(Duration::from_secs(10), connection).await { 339 Ok(Ok(_)) => { 340 if verbose { 341 println!("Connection served successfully"); 342 } 343 } 344 Ok(Err(e)) => { 345 if verbose { 346 println!("Connection error: {}", e); 347 } 348 } 349 Err(_) => { 350 if verbose { 351 println!("Connection handling timed out"); 352 } 353 } 354 } 355 } 356 Err(e) => { 357 if verbose { 358 println!("Failed to accept connection: {}", e); 359 } 360 } 361 } 362 363 if verbose { 364 println!("Server task ending"); 365 } 366 }; 367 368 // Start the server task in the background 369 tokio::spawn(server_task); 370 371 // Wait for either the OAuth callback or timeout 372 tokio::select! { 373 _ = &mut rx => { 374 if verbose { 375 println!("OAuth callback received, proceeding..."); 376 } 377 }, 378 _ = tokio::time::sleep(OAUTH_TIMEOUT) => { 379 eprintln!("Timed out waiting for OAuth redirect."); 380 } 381 } 382 383 let params_result = { 384 let guard = result.lock(); 385 match guard { 386 Ok(p) => p.clone(), 387 Err(_) => None, 388 } 389 }; 390 391 if let Some(params) = params_result { 392 if verbose { 393 println!("Received redirect with params: {:?}", params); 394 } 395 save_auth_params(&params).await?; 396 println!("Login successful! You can now use other commands."); 397 } else { 398 eprintln!("No redirect received."); 399 } 400 } 401 402 Ok(()) 403} 404 405async fn save_auth_params(params: &HashMap<String, String>) -> Result<()> { 406 let auth_file = get_auth_file_path()?; 407 let json = serde_json::to_string_pretty(params)?; 408 fs::write(&auth_file, json)?; 409 println!("Saved config data to {}", auth_file.display()); 410 Ok(()) 411} 412 413async fn fetch_galleries(client: &Client) -> Result<Vec<GalleryItem>> { 414 let auth = load_auth().await?; 415 let galleries_url = format!( 416 "{}/xrpc/social.grain.gallery.getActorGalleries?actor={}", 417 API_BASE, 418 urlencoding::encode(&auth.did) 419 ); 420 421 let data: GalleriesResponse = make_request( 422 client, 423 &galleries_url, 424 Method::GET, 425 None, 426 Some(&auth.token), 427 None, 428 ).await?; 429 430 Ok(data.items.unwrap_or_default()) 431} 432 433async fn handle_galleries_list(client: &Client) -> Result<()> { 434 let items = fetch_galleries(client).await?; 435 436 if items.is_empty() { 437 println!("No galleries found."); 438 } else { 439 for item in items { 440 let count = item.items.as_ref().map(|i| i.len()).unwrap_or(0); 441 let title = item.title.as_deref().unwrap_or("Untitled"); 442 println!("{} ({})", title, count); 443 } 444 } 445 446 Ok(()) 447} 448 449async fn delete_gallery(client: &Client, gallery_uri: &str) -> Result<()> { 450 let auth = load_auth().await?; 451 let delete_url = format!("{}/xrpc/social.grain.gallery.deleteGallery", API_BASE); 452 453 let payload = serde_json::json!({ 454 "uri": gallery_uri 455 }); 456 457 let _response: Value = make_request( 458 client, 459 &delete_url, 460 Method::POST, 461 Some(payload.to_string().into_bytes()), 462 Some(&auth.token), 463 Some("application/json"), 464 ).await?; 465 466 Ok(()) 467} 468 469async fn handle_gallery_delete(client: &Client) -> Result<()> { 470 let galleries = fetch_galleries(client).await?; 471 472 if galleries.is_empty() { 473 println!("No galleries found to delete."); 474 return Ok(()); 475 } 476 477 // Create selection options for dialoguer 478 let gallery_options: Vec<String> = galleries 479 .iter() 480 .map(|item| { 481 let count = item.items.as_ref().map(|i| i.len()).unwrap_or(0); 482 let title = item.title.as_deref().unwrap_or("Untitled"); 483 format!("{} ({} items)", title, count) 484 }) 485 .collect(); 486 487 let selection = dialoguer::Select::new() 488 .with_prompt("Select a gallery to delete") 489 .items(&gallery_options) 490 .default(0) 491 .interact()?; 492 493 let selected_gallery = &galleries[selection]; 494 let title = selected_gallery.title.as_deref().unwrap_or("Untitled"); 495 496 let confirm = dialoguer::Confirm::new() 497 .with_prompt(format!("Are you sure you want to delete gallery '{}'? This action cannot be undone.", title)) 498 .default(false) 499 .interact()?; 500 501 if confirm { 502 delete_gallery(client, &selected_gallery.uri).await?; 503 println!("Gallery '{}' deleted successfully.", title); 504 } else { 505 println!("Gallery deletion cancelled."); 506 } 507 508 Ok(()) 509} 510 511async fn handle_gallery_show(client: &Client) -> Result<()> { 512 let galleries = fetch_galleries(client).await?; 513 514 if galleries.is_empty() { 515 println!("No galleries found to show."); 516 return Ok(()); 517 } 518 519 // Create selection options for dialoguer 520 let gallery_options: Vec<String> = galleries 521 .iter() 522 .map(|item| { 523 let count = item.items.as_ref().map(|i| i.len()).unwrap_or(0); 524 let title = item.title.as_deref().unwrap_or("Untitled"); 525 format!("{} ({} items)", title, count) 526 }) 527 .collect(); 528 529 let selection = dialoguer::Select::new() 530 .with_prompt("Select a gallery to open") 531 .items(&gallery_options) 532 .default(0) 533 .interact()?; 534 535 let selected_gallery = &galleries[selection]; 536 let web_url = selected_gallery.uri.strip_prefix("at://").unwrap_or(&selected_gallery.uri); 537 let formatted_url = format!("https://grain.social/{}", web_url); 538 539 println!("Opening gallery in browser: {}", formatted_url); 540 open::that(&formatted_url)?; 541 542 Ok(()) 543} 544 545async fn create_gallery(client: &Client, title: &str, description: &str) -> Result<String> { 546 let auth = load_auth().await?; 547 let create_url = format!("{}/xrpc/social.grain.gallery.createGallery", API_BASE); 548 549 let payload = serde_json::json!({ 550 "title": title, 551 "description": description 552 }); 553 554 let response: CreateGalleryResponse = make_request( 555 client, 556 &create_url, 557 Method::POST, 558 Some(payload.to_string().into_bytes()), 559 Some(&auth.token), 560 Some("application/json"), 561 ).await?; 562 563 Ok(response.gallery_uri) 564} 565 566async fn upload_photo(client: &Client, image_buffer: &[u8]) -> Result<String> { 567 let auth = load_auth().await?; 568 let upload_url = format!("{}/xrpc/social.grain.photo.uploadPhoto", API_BASE); 569 570 let response: UploadPhotoResponse = make_request( 571 client, 572 &upload_url, 573 Method::POST, 574 Some(image_buffer.to_vec()), 575 Some(&auth.token), 576 Some("image/jpeg"), 577 ).await?; 578 579 println!("Photo uploaded successfully: {}", response.photo_uri); 580 Ok(response.photo_uri) 581} 582 583async fn create_gallery_item( 584 client: &Client, 585 gallery_uri: &str, 586 photo_uri: &str, 587 position: u32, 588) -> Result<()> { 589 let auth = load_auth().await?; 590 let create_url = format!("{}/xrpc/social.grain.gallery.createItem", API_BASE); 591 592 let payload = serde_json::json!({ 593 "galleryUri": gallery_uri, 594 "photoUri": photo_uri, 595 "position": position 596 }); 597 598 let _response: Value = make_request( 599 client, 600 &create_url, 601 Method::POST, 602 Some(payload.to_string().into_bytes()), 603 Some(&auth.token), 604 Some("application/json"), 605 ).await?; 606 607 Ok(()) 608} 609 610async fn handle_gallery_create(client: &Client, verbose: bool) -> Result<()> { 611 let title: String = Input::new() 612 .with_prompt("Gallery title") 613 .interact()?; 614 615 let description: String = Input::new() 616 .with_prompt("Gallery description (optional)") 617 .allow_empty(true) 618 .interact()?; 619 620 let folder_path: String = Input::new() 621 .with_prompt("Path to folder of image files to upload") 622 .validate_with(|input: &String| -> Result<(), &str> { 623 let path = Path::new(input); 624 if !path.exists() { 625 return Err("Directory does not exist"); 626 } 627 if !path.is_dir() { 628 return Err("Path is not a directory"); 629 } 630 Ok(()) 631 }) 632 .interact()?; 633 634 // List image files in the folder 635 let image_extensions = [".jpg", ".jpeg"]; 636 let mut image_files = Vec::new(); 637 638 let entries = fs::read_dir(&folder_path)?; 639 for entry in entries { 640 let entry = entry?; 641 if entry.file_type()?.is_file() { 642 let file_name = entry.file_name(); 643 let file_name_str = file_name.to_string_lossy().to_lowercase(); 644 if image_extensions.iter().any(|ext| file_name_str.ends_with(ext)) { 645 image_files.push(entry.file_name().to_string_lossy().to_string()); 646 } 647 } 648 } 649 650 if image_files.is_empty() { 651 exit_with_error("No image files found in the selected folder.", 1); 652 } 653 654 println!("Found {} image files in '{}':", image_files.len(), folder_path); 655 for file in &image_files { 656 println!(" - {}", file); 657 } 658 659 let confirm = Confirm::new() 660 .with_prompt(format!("Are you sure you want to upload these {} images?", image_files.len())) 661 .default(true) 662 .interact()?; 663 664 if !confirm { 665 println!("Aborted by user."); 666 return Ok(()); 667 } 668 669 let gallery_uri = create_gallery(client, &title, &description).await?; 670 671 let pb = ProgressBar::new(image_files.len() as u64); 672 pb.set_style( 673 ProgressStyle::default_bar() 674 .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} {msg}") 675 .unwrap() 676 .progress_chars("#>-") 677 ); 678 679 let mut position = 0; 680 681 for file_name in image_files { 682 pb.set_message(format!("Processing {}", file_name)); 683 684 let file_path = format!("{}/{}", folder_path, file_name); 685 let file_data = fs::read(&file_path)?; 686 687 let resized = do_resize(&file_data, ResizeOptions { 688 width: 2000, 689 height: 2000, 690 max_size: 1000 * 1000, // 1MB 691 mode: "inside".to_string(), 692 verbose, 693 })?; 694 695 let photo_uri = upload_photo(client, &resized.buffer).await?; 696 697 create_gallery_item(client, &gallery_uri, &photo_uri, position).await?; 698 position += 1; 699 pb.inc(1); 700 } 701 702 pb.finish_with_message("All images uploaded successfully!"); 703 704 let web_url = gallery_uri.strip_prefix("at://").unwrap_or(&gallery_uri); 705 let formatted_url = format!("https://grain.social/{}", web_url); 706 println!("Here's a link to the gallery: {}", formatted_url); 707 708 Ok(()) 709} 710 711#[tokio::main] 712async fn main() -> Result<()> { 713 let cli = Cli::parse(); 714 let client = Client::new(); 715 716 match cli.command { 717 None => { 718 println!(r#" 719██████╗ ██████╗ █████╗ ██╗███╗ ██╗ 720██╔════╝ ██╔══██╗██╔══██╗██║████╗ ██║ 721██║ ███╗██████╔╝███████║██║██╔██╗ ██║ 722██║ ██║██╔══██╗██╔══██║██║██║╚██╗██║ 723╚██████╔╝██║ ██║██║ ██║██║██║ ╚████║ 724 ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ 725 726https://grain.social CLI 727 728Use --help to see available commands. 729"#); 730 } 731 Some(Commands::Login) => { 732 if let Err(e) = handle_login(&client, cli.verbose).await { 733 eprintln!("Login failed: {}", e); 734 } 735 } 736 Some(Commands::Gallery { action }) => { 737 match action { 738 GalleryAction::List => { 739 if let Err(e) = handle_galleries_list(&client).await { 740 exit_with_error(&format!("Failed to fetch galleries: {}", e), 1); 741 } 742 } 743 GalleryAction::Create => { 744 if let Err(e) = handle_gallery_create(&client, cli.verbose).await { 745 exit_with_error(&format!("Failed to create gallery: {}", e), 1); 746 } 747 } 748 GalleryAction::Delete => { 749 if let Err(e) = handle_gallery_delete(&client).await { 750 exit_with_error(&format!("Failed to delete gallery: {}", e), 1); 751 } 752 } 753 GalleryAction::Show => { 754 if let Err(e) = handle_gallery_show(&client).await { 755 exit_with_error(&format!("Failed to show gallery: {}", e), 1); 756 } 757 } 758 } 759 } 760 } 761 762 Ok(()) 763}