|
| 1 | +use anyhow::{anyhow, Context}; |
| 2 | +use clap::Args; |
| 3 | +use indicatif::{ProgressBar, ProgressStyle}; |
| 4 | +use reqwest::Client; |
| 5 | +use serde::{Deserialize, Serialize}; |
| 6 | +use std::path::PathBuf; |
| 7 | +use std::time::Duration; |
| 8 | + |
| 9 | +const DEFAULT_CLOUD_URL: &str = "https://api.rustapi.cloud"; |
| 10 | +const POLL_INTERVAL_SECS: u64 = 3; |
| 11 | +const POLL_TIMEOUT_SECS: u64 = 900; // 15 minutes |
| 12 | + |
| 13 | +#[derive(Args, Debug, Clone)] |
| 14 | +pub struct LoginArgs { |
| 15 | + /// RustAPI Cloud API URL |
| 16 | + #[arg(long, default_value = DEFAULT_CLOUD_URL)] |
| 17 | + pub cloud_url: String, |
| 18 | + |
| 19 | + /// Do not open browser automatically |
| 20 | + #[arg(long)] |
| 21 | + pub no_browser: bool, |
| 22 | +} |
| 23 | + |
| 24 | +#[derive(Deserialize)] |
| 25 | +struct DeviceResponse { |
| 26 | + device_code: String, |
| 27 | + user_code: String, |
| 28 | + #[allow(dead_code)] |
| 29 | + verification_uri: String, |
| 30 | + verification_uri_complete: String, |
| 31 | + #[allow(dead_code)] |
| 32 | + expires_in: u32, |
| 33 | + interval: u32, |
| 34 | +} |
| 35 | + |
| 36 | +#[derive(Serialize)] |
| 37 | +struct TokenRequest { |
| 38 | + grant_type: String, |
| 39 | + device_code: String, |
| 40 | +} |
| 41 | + |
| 42 | +#[derive(Deserialize)] |
| 43 | +#[serde(untagged)] |
| 44 | +enum TokenResponse { |
| 45 | + Success { |
| 46 | + access_token: String, |
| 47 | + refresh_token: String, |
| 48 | + #[allow(dead_code)] |
| 49 | + token_type: String, |
| 50 | + #[allow(dead_code)] |
| 51 | + expires_in: u32, |
| 52 | + }, |
| 53 | + Error { |
| 54 | + error: String, |
| 55 | + #[serde(default)] |
| 56 | + error_description: Option<String>, |
| 57 | + }, |
| 58 | +} |
| 59 | + |
| 60 | +#[derive(Serialize, Deserialize)] |
| 61 | +struct ConfigFile { |
| 62 | + token: Option<String>, |
| 63 | + refresh_token: Option<String>, |
| 64 | + user: Option<UserInfo>, |
| 65 | + last_login: Option<String>, |
| 66 | + cloud_url: Option<String>, |
| 67 | +} |
| 68 | + |
| 69 | +#[derive(Serialize, Deserialize)] |
| 70 | +struct UserInfo { |
| 71 | + login: String, |
| 72 | + tier: String, |
| 73 | + avatar_url: Option<String>, |
| 74 | +} |
| 75 | + |
| 76 | +pub async fn login(args: LoginArgs) -> anyhow::Result<()> { |
| 77 | + let client = Client::new(); |
| 78 | + let cloud_url = args.cloud_url.trim_end_matches('/'); |
| 79 | + |
| 80 | + // Step 1: Request device code |
| 81 | + let device_resp: DeviceResponse = client |
| 82 | + .post(format!("{}/auth/device", cloud_url)) |
| 83 | + .send() |
| 84 | + .await |
| 85 | + .context("Failed to connect to RustAPI Cloud")? |
| 86 | + .json() |
| 87 | + .await |
| 88 | + .context("Invalid response from /auth/device")?; |
| 89 | + |
| 90 | + // Step 2: Show activation instructions |
| 91 | + println!(); |
| 92 | + println!(" \x1b[1mRustAPI Cloud Login\x1b[0m"); |
| 93 | + println!(" ─────────────────────"); |
| 94 | + println!(); |
| 95 | + println!(" Open this URL in your browser:"); |
| 96 | + println!(); |
| 97 | + println!(" \x1b[36m{}\x1b[0m", device_resp.verification_uri_complete); |
| 98 | + println!(); |
| 99 | + println!( |
| 100 | + " And enter this code: \x1b[1;33m{}\x1b[0m", |
| 101 | + device_resp.user_code |
| 102 | + ); |
| 103 | + println!(); |
| 104 | + |
| 105 | + // Step 3: Open browser |
| 106 | + if !args.no_browser { |
| 107 | + let _ = open::that(&device_resp.verification_uri_complete); |
| 108 | + } |
| 109 | + |
| 110 | + // Step 4: Poll for token |
| 111 | + let spinner = ProgressBar::new_spinner(); |
| 112 | + spinner.set_style( |
| 113 | + ProgressStyle::with_template(" {spinner} Waiting for authorization...").unwrap(), |
| 114 | + ); |
| 115 | + spinner.enable_steady_tick(Duration::from_millis(100)); |
| 116 | + |
| 117 | + let start = std::time::Instant::now(); |
| 118 | + let interval = Duration::from_secs(std::cmp::max( |
| 119 | + device_resp.interval as u64, |
| 120 | + POLL_INTERVAL_SECS, |
| 121 | + )); |
| 122 | + |
| 123 | + loop { |
| 124 | + tokio::time::sleep(interval).await; |
| 125 | + |
| 126 | + if start.elapsed().as_secs() > POLL_TIMEOUT_SECS { |
| 127 | + spinner.finish_with_message("Login timed out"); |
| 128 | + return Err(anyhow!( |
| 129 | + "Device code expired. Please run `rustapi login` again." |
| 130 | + )); |
| 131 | + } |
| 132 | + |
| 133 | + let token_resp: TokenResponse = match client |
| 134 | + .post(format!("{}/auth/token", cloud_url)) |
| 135 | + .json(&TokenRequest { |
| 136 | + grant_type: "urn:ietf:params:oauth:grant-type:device_code".into(), |
| 137 | + device_code: device_resp.device_code.clone(), |
| 138 | + }) |
| 139 | + .send() |
| 140 | + .await |
| 141 | + { |
| 142 | + Ok(resp) => match resp.json().await { |
| 143 | + Ok(body) => body, |
| 144 | + Err(_) => continue, |
| 145 | + }, |
| 146 | + Err(_) => continue, |
| 147 | + }; |
| 148 | + |
| 149 | + match token_resp { |
| 150 | + TokenResponse::Success { |
| 151 | + access_token, |
| 152 | + refresh_token, |
| 153 | + .. |
| 154 | + } => { |
| 155 | + spinner.finish_and_clear(); |
| 156 | + |
| 157 | + // Step 5: Get user info |
| 158 | + let user_info: UserInfo = match client |
| 159 | + .post(format!("{}/auth/whoami", cloud_url)) |
| 160 | + .json(&serde_json::json!({ "grant_type": "", "device_code": access_token })) |
| 161 | + .send() |
| 162 | + .await |
| 163 | + { |
| 164 | + Ok(resp) => match resp.json::<serde_json::Value>().await { |
| 165 | + Ok(v) if v.get("sub").is_some() => UserInfo { |
| 166 | + login: v["login"].as_str().unwrap_or("unknown").into(), |
| 167 | + tier: v["tier"].as_str().unwrap_or("hobby").into(), |
| 168 | + avatar_url: v["avatar_url"].as_str().map(String::from), |
| 169 | + }, |
| 170 | + _ => continue, |
| 171 | + }, |
| 172 | + Err(_) => continue, |
| 173 | + }; |
| 174 | + |
| 175 | + // Step 6: Save config |
| 176 | + let config = ConfigFile { |
| 177 | + token: Some(access_token), |
| 178 | + refresh_token: Some(refresh_token), |
| 179 | + user: Some(user_info), |
| 180 | + last_login: Some(chrono::Utc::now().to_rfc3339()), |
| 181 | + cloud_url: Some(cloud_url.to_string()), |
| 182 | + }; |
| 183 | + |
| 184 | + save_config(&config)?; |
| 185 | + |
| 186 | + println!( |
| 187 | + " \x1b[32m✓ Logged in as {}\x1b[0m", |
| 188 | + config.user.as_ref().unwrap().login |
| 189 | + ); |
| 190 | + println!(" Tier: {}", config.user.as_ref().unwrap().tier); |
| 191 | + println!(); |
| 192 | + return Ok(()); |
| 193 | + } |
| 194 | + TokenResponse::Error { |
| 195 | + error, |
| 196 | + error_description, |
| 197 | + } => { |
| 198 | + if error == "authorization_pending" { |
| 199 | + continue; |
| 200 | + } |
| 201 | + let desc = error_description.unwrap_or_default(); |
| 202 | + spinner.finish_with_message(format!("Error: {} - {}", error, desc)); |
| 203 | + return Err(anyhow!("{}: {}", error, desc)); |
| 204 | + } |
| 205 | + } |
| 206 | + } |
| 207 | +} |
| 208 | + |
| 209 | +fn config_path() -> PathBuf { |
| 210 | + let home = std::env::var("USERPROFILE") |
| 211 | + .or_else(|_| std::env::var("HOME")) |
| 212 | + .unwrap_or_else(|_| ".".into()); |
| 213 | + |
| 214 | + PathBuf::from(home).join(".rustapi").join("config.json") |
| 215 | +} |
| 216 | + |
| 217 | +fn save_config(config: &ConfigFile) -> anyhow::Result<()> { |
| 218 | + let path = config_path(); |
| 219 | + if let Some(parent) = path.parent() { |
| 220 | + std::fs::create_dir_all(parent).context("Failed to create ~/.rustapi directory")?; |
| 221 | + } |
| 222 | + let json = serde_json::to_string_pretty(config).context("Failed to serialize config")?; |
| 223 | + std::fs::write(&path, json).context("Failed to write config file")?; |
| 224 | + Ok(()) |
| 225 | +} |
0 commit comments