diff --git a/src/client.rs b/src/client.rs index a604040..727dcb0 100644 --- a/src/client.rs +++ b/src/client.rs @@ -3,6 +3,7 @@ use chrono::Utc; use reqwest::{Client as HttpClient, header::{HeaderMap, HeaderValue, AUTHORIZATION}}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::time::Duration; use crate::credentials::Credentials; @@ -51,6 +52,16 @@ struct CreateRecordResponse { cid: String, } +#[derive(Debug, Deserialize)] +struct RefreshSessionResponse { + #[serde(rename = "accessJwt")] + access_jwt: String, + #[serde(rename = "refreshJwt")] + refresh_jwt: String, + handle: String, + did: String, +} + impl AtProtoClient { pub fn new(pds_uri: &str) -> Self { Self { @@ -92,7 +103,40 @@ impl AtProtoClient { Ok(()) } - pub async fn publish_blip(&self, content: &str) -> Result { + pub async fn publish_blip(&mut self, content: &str) -> Result { + // Try the request, and if it fails with 401, refresh and retry once + match self.try_publish_blip(content).await { + Ok(uri) => Ok(uri), + Err(e) => { + // Check if this is a 401 error by examining the error message + let error_msg = e.to_string(); + if error_msg.contains("401") || error_msg.contains("Unauthorized") { + // Try to refresh the session + match self.refresh_session().await { + Ok(_) => { + // Retry the request with the new token + self.try_publish_blip(content).await + .context("Failed to publish blip after session refresh") + } + Err(refresh_err) => { + // Session refresh failed - clear any stored session + if let Ok(store) = crate::credentials::CredentialStore::new() { + let _ = store.clear_session(); + } + Err(anyhow::anyhow!( + "Authentication failed and session refresh failed: {}. Please run 'thought login' again.", + refresh_err + )) + } + } + } else { + Err(e) + } + } + } + } + + async fn try_publish_blip(&self, content: &str) -> Result { let session = self.session.as_ref() .context("Not authenticated. Please run 'thought login' first.")?; @@ -126,9 +170,10 @@ impl AtProtoClient { .post(&create_url) .headers(headers) .json(&request) + .timeout(Duration::from_secs(30)) .send() .await - .context("Failed to send create record request")?; + .context("Failed to send create record request (timeout or network error)")?; if !response.status().is_success() { let status = response.status(); @@ -151,4 +196,55 @@ impl AtProtoClient { pub fn get_user_did(&self) -> Option { self.session.as_ref().map(|s| s.did.clone()) } + + pub async fn refresh_session(&mut self) -> Result<()> { + let session = self.session.as_ref() + .context("No session to refresh. Please login first.")?; + + let refresh_url = format!("{}/xrpc/com.atproto.server.refreshSession", self.base_url); + + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {}", session.refresh_jwt)) + .context("Invalid refresh token for authorization header")?, + ); + + let response = self.http_client + .post(&refresh_url) + .headers(headers) + .timeout(Duration::from_secs(30)) + .send() + .await + .context("Failed to send refresh session request (timeout or network error)")?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + anyhow::bail!("Session refresh failed: {} - {}", status, error_text); + } + + let refresh_response: RefreshSessionResponse = response + .json() + .await + .context("Failed to parse refresh session response")?; + + let new_session = Session { + access_jwt: refresh_response.access_jwt, + refresh_jwt: refresh_response.refresh_jwt, + handle: refresh_response.handle, + did: refresh_response.did, + }; + + self.session = Some(new_session); + Ok(()) + } + + pub fn get_session(&self) -> Option<&Session> { + self.session.as_ref() + } + + pub fn set_session(&mut self, session: Session) { + self.session = Some(session); + } } \ No newline at end of file diff --git a/src/credentials.rs b/src/credentials.rs index d27b772..815e7d0 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; +use crate::client::Session; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Credentials { pub username: String, @@ -43,6 +45,10 @@ impl CredentialStore { self.config_dir.join("credentials.json") } + fn session_path(&self) -> PathBuf { + self.config_dir.join("session.json") + } + pub fn store(&self, credentials: &Credentials) -> Result<()> { let json = serde_json::to_string_pretty(credentials) .context("Failed to serialize credentials")?; @@ -81,10 +87,53 @@ impl CredentialStore { println!("No credentials found to clear"); } + self.clear_session()?; + Ok(()) } pub fn exists(&self) -> bool { self.credentials_path().exists() } + + pub fn store_session(&self, session: &Session) -> Result<()> { + let json = serde_json::to_string_pretty(session) + .context("Failed to serialize session")?; + + fs::write(self.session_path(), json) + .context("Failed to write session file")?; + + Ok(()) + } + + pub fn load_session(&self) -> Result> { + let path = self.session_path(); + + if !path.exists() { + return Ok(None); + } + + let json = fs::read_to_string(&path) + .context("Failed to read session file")?; + + let session: Session = serde_json::from_str(&json) + .context("Failed to parse session file")?; + + Ok(Some(session)) + } + + pub fn clear_session(&self) -> Result<()> { + let path = self.session_path(); + + if path.exists() { + fs::remove_file(&path) + .context("Failed to remove session file")?; + } + + Ok(()) + } + + pub fn session_exists(&self) -> bool { + self.session_path().exists() + } } \ No newline at end of file diff --git a/src/interactive.rs b/src/interactive.rs index bfa7c5e..637f61a 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -6,7 +6,7 @@ use crate::client::AtProtoClient; use crate::jetstream; use crate::tui; -pub async fn run_interactive_mode(client: &AtProtoClient, use_tui: bool) -> Result<()> { +pub async fn run_interactive_mode(client: &mut AtProtoClient, use_tui: bool) -> Result<()> { if use_tui { run_tui_mode(client).await } else { @@ -14,7 +14,7 @@ pub async fn run_interactive_mode(client: &AtProtoClient, use_tui: bool) -> Resu } } -async fn run_tui_mode(client: &AtProtoClient) -> Result<()> { +async fn run_tui_mode(client: &mut AtProtoClient) -> Result<()> { // Create channel for messages from jetstream to TUI let (message_tx, message_rx) = mpsc::unbounded_channel(); @@ -33,7 +33,7 @@ async fn run_tui_mode(client: &AtProtoClient) -> Result<()> { tui::run_tui(client, message_rx).await } -async fn run_simple_repl(client: &AtProtoClient) -> Result<()> { +async fn run_simple_repl(client: &mut AtProtoClient) -> Result<()> { println!("Entering interactive mode. Type /help for commands."); println!("Enter your thoughts line by line, or use commands starting with '/'"); println!(); diff --git a/src/main.rs b/src/main.rs index ba1caff..a09121e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -88,8 +88,11 @@ async fn handle_login() -> Result<()> { client.login(&credentials).await .context("Login failed. Please check your credentials.")?; - // Store credentials if login was successful + // Store credentials and session if login was successful store.store(&credentials)?; + if let Some(session) = client.get_session() { + store.store_session(session)?; + } Ok(()) } @@ -101,32 +104,72 @@ async fn handle_logout() -> Result<()> { } async fn handle_publish(message: &str) -> Result<()> { + let mut client = create_authenticated_client().await?; + let uri = client.publish_blip(message).await?; + + // Update stored session if it was refreshed let store = CredentialStore::new()?; + if let Some(session) = client.get_session() { + store.store_session(session)?; + } - let credentials = store.load()? - .context("Not logged in. Please run 'thought login' first.")?; + println!("Published: {}", uri); + Ok(()) +} - let mut client = AtProtoClient::new(&credentials.pds_uri); - client.login(&credentials).await?; +async fn handle_interactive(use_tui: bool) -> Result<()> { + let mut client = create_authenticated_client().await?; + interactive::run_interactive_mode(&mut client, use_tui).await?; - let uri = client.publish_blip(message).await?; - println!("Published: {}", uri); + // Update stored session after interactive mode ends in case it was refreshed + let store = CredentialStore::new()?; + if let Some(session) = client.get_session() { + store.store_session(session)?; + } Ok(()) } -async fn handle_interactive(use_tui: bool) -> Result<()> { +async fn create_authenticated_client() -> Result { let store = CredentialStore::new()?; let credentials = store.load()? .context("Not logged in. Please run 'thought login' first.")?; let mut client = AtProtoClient::new(&credentials.pds_uri); - client.login(&credentials).await?; - interactive::run_interactive_mode(&client, use_tui).await?; + // Try to load existing session first + if let Some(session) = store.load_session()? { + client.set_session(session); + + // Try to refresh the session to validate it's still good + // If refresh fails, we'll fall back to full login + if let Err(_) = client.refresh_session().await { + // Session is invalid, clear it and perform full login + store.clear_session()?; + client.login(&credentials).await?; + + // Save the new session + if let Some(session) = client.get_session() { + store.store_session(session)?; + } + } else { + // Session refresh succeeded, save the updated session + if let Some(session) = client.get_session() { + store.store_session(session)?; + } + } + } else { + // No existing session, perform full login + client.login(&credentials).await?; + + // Save the new session + if let Some(session) = client.get_session() { + store.store_session(session)?; + } + } - Ok(()) + Ok(client) } #[tokio::main] diff --git a/src/tui.rs b/src/tui.rs index 2aa3b5c..677ce22 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -186,7 +186,7 @@ impl TuiApp { } pub async fn run_tui( - client: &AtProtoClient, + client: &mut AtProtoClient, mut message_rx: mpsc::UnboundedReceiver, ) -> Result<()> { // Setup terminal @@ -222,7 +222,7 @@ pub async fn run_tui( async fn run_tui_loop( terminal: &mut Terminal>, app: &mut TuiApp, - client: &AtProtoClient, + client: &mut AtProtoClient, message_rx: &mut mpsc::UnboundedReceiver, ) -> Result<()> { loop {