grain.social is a photo sharing platform built on atproto.
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(¶ms).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}