grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
50
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 0d12ae244a735e7f0824bcc7db4cfe3849895ab2 763 lines 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}