diff --git a/src/demos.rs b/src/demos.rs new file mode 100644 index 0000000..445ae64 --- /dev/null +++ b/src/demos.rs @@ -0,0 +1,34 @@ +use crate::tui; +use log::*; +use steamguard::{Confirmation, ConfirmationType}; + +pub fn demo_confirmation_menu() { + info!("showing demo menu"); + let (accept, deny) = tui::prompt_confirmation_menu(vec![ + Confirmation { + id: 1234, + key: 12345, + conf_type: ConfirmationType::Trade, + creator: 09870987, + }, + Confirmation { + id: 1234, + key: 12345, + conf_type: ConfirmationType::MarketSell, + creator: 09870987, + }, + Confirmation { + id: 1234, + key: 12345, + conf_type: ConfirmationType::AccountRecovery, + creator: 09870987, + }, + Confirmation { + id: 1234, + key: 12345, + conf_type: ConfirmationType::Trade, + creator: 09870987, + }, + ]); + println!("accept: {}, deny: {}", accept.len(), deny.len()); +} diff --git a/src/main.rs b/src/main.rs index 96895ef..7e58f5a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,22 +1,14 @@ extern crate rpassword; use clap::{crate_version, App, Arg}; use log::*; -use regex::Regex; -use std::collections::HashSet; use std::{ - io::{stdin, stdout, Write}, + io::{stdout, Write}, path::Path, sync::{Arc, Mutex}, }; use steamguard::{ - steamapi, AccountLinkError, AccountLinker, Confirmation, ConfirmationType, FinalizeLinkError, - LoginError, SteamGuardAccount, UserLogin, -}; -use termion::{ - event::{Event, Key}, - input::TermRead, - raw::IntoRawMode, - screen::AlternateScreen, + steamapi, AccountLinkError, AccountLinker, Confirmation, FinalizeLinkError, LoginError, + SteamGuardAccount, UserLogin, }; #[macro_use] @@ -25,11 +17,8 @@ extern crate lazy_static; extern crate anyhow; extern crate dirs; mod accountmanager; - -lazy_static! { - static ref CAPTCHA_VALID_CHARS: Regex = - Regex::new("^([A-H]|[J-N]|[P-R]|[T-Z]|[2-4]|[7-9]|[@%&])+$").unwrap(); -} +mod demos; +mod tui; fn main() { let matches = App::new("steamguard-cli") @@ -121,7 +110,7 @@ fn main() { if let Some(demo_matches) = matches.subcommand_matches("debug") { if demo_matches.is_present("demo-conf-menu") { - demo_confirmation_menu(); + demos::demo_confirmation_menu(); } return; } @@ -140,7 +129,7 @@ fn main() { "Would you like to create a manifest in {} ? [Yn] ", mafiles_dir ); - match prompt().to_lowercase().as_str() { + match tui::prompt().to_lowercase().as_str() { "n" => { info!("Aborting!"); return; @@ -185,7 +174,7 @@ fn main() { Err(AccountLinkError::MustProvidePhoneNumber) => { println!("Enter your phone number in the following format: +1 123-456-7890"); print!("Phone number: "); - linker.phone_number = prompt().replace(&['(', ')', '-'][..], ""); + linker.phone_number = tui::prompt().replace(&['(', ')', '-'][..], ""); } Err(AccountLinkError::AuthenticatorPresent) => { println!("An authenticator is already present on this account."); @@ -193,7 +182,7 @@ fn main() { } Err(AccountLinkError::MustConfirmEmail) => { println!("Check your email and click the link."); - pause(); + tui::pause(); } Err(err) => { error!( @@ -228,7 +217,7 @@ fn main() { debug!("attempting link finalization"); print!("Enter SMS code: "); - let sms_code = prompt(); + let sms_code = tui::prompt(); let mut tries = 0; loop { match linker.finalize(&mut account, sms_code.clone()) { @@ -333,7 +322,7 @@ fn main() { } } else { if termion::is_tty(&stdout()) { - let (accept, deny) = prompt_confirmation_menu(confirmations); + let (accept, deny) = tui::prompt_confirmation_menu(confirmations); for conf in &accept { let result = account.accept_confirmation(conf); debug!("accept confirmation result: {:?}", result); @@ -364,7 +353,7 @@ fn main() { ); print!("Do you want to continue? [yN] "); - match prompt().as_str() { + match tui::prompt().as_str() { "y" => {} _ => { println!("Aborting!"); @@ -411,175 +400,12 @@ fn main() { } } -fn validate_captcha_text(text: &String) -> bool { - return CAPTCHA_VALID_CHARS.is_match(text); -} - -#[test] -fn test_validate_captcha_text() { - assert!(validate_captcha_text(&String::from("2WWUA@"))); - assert!(validate_captcha_text(&String::from("3G8HT2"))); - assert!(validate_captcha_text(&String::from("3J%@X3"))); - assert!(validate_captcha_text(&String::from("2GCZ4A"))); - assert!(validate_captcha_text(&String::from("3G8HT2"))); - assert!(!validate_captcha_text(&String::from("asd823"))); - assert!(!validate_captcha_text(&String::from("!PQ4RD"))); - assert!(!validate_captcha_text(&String::from("1GQ4XZ"))); - assert!(!validate_captcha_text(&String::from("8GO4XZ"))); - assert!(!validate_captcha_text(&String::from("IPQ4RD"))); - assert!(!validate_captcha_text(&String::from("0PT4RD"))); - assert!(!validate_captcha_text(&String::from("APTSRD"))); - assert!(!validate_captcha_text(&String::from("AP5TRD"))); - assert!(!validate_captcha_text(&String::from("AP6TRD"))); -} - -/// Prompt the user for text input. -fn prompt() -> String { - let mut text = String::new(); - let _ = std::io::stdout().flush(); - stdin() - .read_line(&mut text) - .expect("Did not enter a correct string"); - return String::from(text.strip_suffix('\n').unwrap()); -} - -fn prompt_captcha_text(captcha_gid: &String) -> String { - println!("Captcha required. Open this link in your web browser: https://steamcommunity.com/public/captcha.php?gid={}", captcha_gid); - let mut captcha_text; - loop { - print!("Enter captcha text: "); - captcha_text = prompt(); - if captcha_text.len() > 0 && validate_captcha_text(&captcha_text) { - break; - } - warn!("Invalid chars for captcha text found in user's input. Prompting again..."); - } - return captcha_text; -} - -/// Returns a tuple of (accepted, denied). Ignored confirmations are not included. -fn prompt_confirmation_menu( - confirmations: Vec, -) -> (Vec, Vec) { - println!("press a key other than enter to show the menu."); - let mut to_accept_idx: HashSet = HashSet::new(); - let mut to_deny_idx: HashSet = HashSet::new(); - - let mut screen = AlternateScreen::from(stdout().into_raw_mode().unwrap()); - let stdin = stdin(); - - let mut selected_idx = 0; - - for c in stdin.events() { - match c.expect("could not get events") { - Event::Key(Key::Char('a')) => { - to_accept_idx.insert(selected_idx); - to_deny_idx.remove(&selected_idx); - } - Event::Key(Key::Char('d')) => { - to_accept_idx.remove(&selected_idx); - to_deny_idx.insert(selected_idx); - } - Event::Key(Key::Char('i')) => { - to_accept_idx.remove(&selected_idx); - to_deny_idx.remove(&selected_idx); - } - Event::Key(Key::Char('A')) => { - (0..confirmations.len()).for_each(|i| { - to_accept_idx.insert(i); - to_deny_idx.remove(&i); - }); - } - Event::Key(Key::Char('D')) => { - (0..confirmations.len()).for_each(|i| { - to_accept_idx.remove(&i); - to_deny_idx.insert(i); - }); - } - Event::Key(Key::Char('I')) => { - (0..confirmations.len()).for_each(|i| { - to_accept_idx.remove(&i); - to_deny_idx.remove(&i); - }); - } - Event::Key(Key::Up) if selected_idx > 0 => { - selected_idx -= 1; - } - Event::Key(Key::Down) if selected_idx < confirmations.len() - 1 => { - selected_idx += 1; - } - Event::Key(Key::Char('\n')) => { - break; - } - Event::Key(Key::Esc) | Event::Key(Key::Ctrl('c')) => { - return (vec![], vec![]); - } - _ => {} - } - - write!( - screen, - "{}{}{}arrow keys to select, [a]ccept, [d]eny, [i]gnore, [enter] confirm choices\n\n", - termion::clear::All, - termion::cursor::Goto(1, 1), - termion::color::Fg(termion::color::White) - ) - .unwrap(); - for i in 0..confirmations.len() { - if selected_idx == i { - write!( - screen, - "\r{} >", - termion::color::Fg(termion::color::LightYellow) - ) - .unwrap(); - } else { - write!(screen, "\r{} ", termion::color::Fg(termion::color::White)).unwrap(); - } - - if to_accept_idx.contains(&i) { - write!( - screen, - "{}[a]", - termion::color::Fg(termion::color::LightGreen) - ) - .unwrap(); - } else if to_deny_idx.contains(&i) { - write!( - screen, - "{}[d]", - termion::color::Fg(termion::color::LightRed) - ) - .unwrap(); - } else { - write!(screen, "[ ]").unwrap(); - } - - if selected_idx == i { - write!( - screen, - "{}", - termion::color::Fg(termion::color::LightYellow) - ) - .unwrap(); - } - - write!(screen, " {}\n", confirmations[i].description()).unwrap(); - } - } - - return ( - to_accept_idx.iter().map(|i| confirmations[*i]).collect(), - to_deny_idx.iter().map(|i| confirmations[*i]).collect(), - ); -} - fn do_login(account: &mut SteamGuardAccount) { if account.account_name.len() > 0 { println!("Username: {}", account.account_name); } else { print!("Username: "); - account.account_name = prompt(); + account.account_name = tui::prompt(); } let _ = std::io::stdout().flush(); let password = rpassword::prompt_password_stdout("Password: ").unwrap(); @@ -604,12 +430,12 @@ fn do_login(account: &mut SteamGuardAccount) { } Err(LoginError::NeedCaptcha { captcha_gid }) => { debug!("need captcha to log in"); - login.captcha_text = prompt_captcha_text(&captcha_gid); + login.captcha_text = tui::prompt_captcha_text(&captcha_gid); } Err(LoginError::NeedEmail) => { println!("You should have received an email with a code."); print!("Enter code"); - login.email_code = prompt(); + login.email_code = tui::prompt(); } r => { error!("Fatal login result: {:?}", r); @@ -626,7 +452,7 @@ fn do_login(account: &mut SteamGuardAccount) { fn do_login_raw() -> anyhow::Result { print!("Username: "); - let username = prompt(); + let username = tui::prompt(); let _ = std::io::stdout().flush(); let password = rpassword::prompt_password_stdout("Password: ").unwrap(); if password.len() > 0 { @@ -644,16 +470,16 @@ fn do_login_raw() -> anyhow::Result { } Err(LoginError::Need2FA) => { print!("Enter 2fa code: "); - login.twofactor_code = prompt(); + login.twofactor_code = tui::prompt(); } Err(LoginError::NeedCaptcha { captcha_gid }) => { debug!("need captcha to log in"); - login.captcha_text = prompt_captcha_text(&captcha_gid); + login.captcha_text = tui::prompt_captcha_text(&captcha_gid); } Err(LoginError::NeedEmail) => { println!("You should have received an email with a code."); print!("Enter code: "); - login.email_code = prompt(); + login.email_code = tui::prompt(); } Err(r) => { error!("Fatal login result: {:?}", r); @@ -668,44 +494,6 @@ fn do_login_raw() -> anyhow::Result { } } -fn pause() { - println!("Press any key to continue..."); - let mut stdout = stdout().into_raw_mode().unwrap(); - stdout.flush().unwrap(); - stdin().events().next(); -} - -fn demo_confirmation_menu() { - info!("showing demo menu"); - let (accept, deny) = prompt_confirmation_menu(vec![ - Confirmation { - id: 1234, - key: 12345, - conf_type: ConfirmationType::Trade, - creator: 09870987, - }, - Confirmation { - id: 1234, - key: 12345, - conf_type: ConfirmationType::MarketSell, - creator: 09870987, - }, - Confirmation { - id: 1234, - key: 12345, - conf_type: ConfirmationType::AccountRecovery, - creator: 09870987, - }, - Confirmation { - id: 1234, - key: 12345, - conf_type: ConfirmationType::Trade, - creator: 09870987, - }, - ]); - println!("accept: {}, deny: {}", accept.len(), deny.len()); -} - fn get_mafiles_dir() -> String { let paths = vec![ Path::new(&dirs::config_dir().unwrap()).join("steamguard-cli/maFiles"), diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..4094969 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,186 @@ +use log::*; +use regex::Regex; +use std::collections::HashSet; +use std::io::Write; +use steamguard::Confirmation; +use termion::{ + event::{Event, Key}, + input::TermRead, + raw::IntoRawMode, + screen::AlternateScreen, +}; + +lazy_static! { + static ref CAPTCHA_VALID_CHARS: Regex = + Regex::new("^([A-H]|[J-N]|[P-R]|[T-Z]|[2-4]|[7-9]|[@%&])+$").unwrap(); +} + +pub fn validate_captcha_text(text: &String) -> bool { + return CAPTCHA_VALID_CHARS.is_match(text); +} + +#[test] +fn test_validate_captcha_text() { + assert!(validate_captcha_text(&String::from("2WWUA@"))); + assert!(validate_captcha_text(&String::from("3G8HT2"))); + assert!(validate_captcha_text(&String::from("3J%@X3"))); + assert!(validate_captcha_text(&String::from("2GCZ4A"))); + assert!(validate_captcha_text(&String::from("3G8HT2"))); + assert!(!validate_captcha_text(&String::from("asd823"))); + assert!(!validate_captcha_text(&String::from("!PQ4RD"))); + assert!(!validate_captcha_text(&String::from("1GQ4XZ"))); + assert!(!validate_captcha_text(&String::from("8GO4XZ"))); + assert!(!validate_captcha_text(&String::from("IPQ4RD"))); + assert!(!validate_captcha_text(&String::from("0PT4RD"))); + assert!(!validate_captcha_text(&String::from("APTSRD"))); + assert!(!validate_captcha_text(&String::from("AP5TRD"))); + assert!(!validate_captcha_text(&String::from("AP6TRD"))); +} + +/// Prompt the user for text input. +pub fn prompt() -> String { + let mut text = String::new(); + let _ = std::io::stdout().flush(); + std::io::stdin() + .read_line(&mut text) + .expect("Did not enter a correct string"); + return String::from(text.strip_suffix('\n').unwrap()); +} + +pub fn prompt_captcha_text(captcha_gid: &String) -> String { + println!("Captcha required. Open this link in your web browser: https://steamcommunity.com/public/captcha.php?gid={}", captcha_gid); + let mut captcha_text; + loop { + print!("Enter captcha text: "); + captcha_text = prompt(); + if captcha_text.len() > 0 && validate_captcha_text(&captcha_text) { + break; + } + warn!("Invalid chars for captcha text found in user's input. Prompting again..."); + } + return captcha_text; +} + +/// Returns a tuple of (accepted, denied). Ignored confirmations are not included. +pub fn prompt_confirmation_menu( + confirmations: Vec, +) -> (Vec, Vec) { + println!("press a key other than enter to show the menu."); + let mut to_accept_idx: HashSet = HashSet::new(); + let mut to_deny_idx: HashSet = HashSet::new(); + + let mut screen = AlternateScreen::from(std::io::stdout().into_raw_mode().unwrap()); + let stdin = std::io::stdin(); + + let mut selected_idx = 0; + + for c in stdin.events() { + match c.expect("could not get events") { + Event::Key(Key::Char('a')) => { + to_accept_idx.insert(selected_idx); + to_deny_idx.remove(&selected_idx); + } + Event::Key(Key::Char('d')) => { + to_accept_idx.remove(&selected_idx); + to_deny_idx.insert(selected_idx); + } + Event::Key(Key::Char('i')) => { + to_accept_idx.remove(&selected_idx); + to_deny_idx.remove(&selected_idx); + } + Event::Key(Key::Char('A')) => { + (0..confirmations.len()).for_each(|i| { + to_accept_idx.insert(i); + to_deny_idx.remove(&i); + }); + } + Event::Key(Key::Char('D')) => { + (0..confirmations.len()).for_each(|i| { + to_accept_idx.remove(&i); + to_deny_idx.insert(i); + }); + } + Event::Key(Key::Char('I')) => { + (0..confirmations.len()).for_each(|i| { + to_accept_idx.remove(&i); + to_deny_idx.remove(&i); + }); + } + Event::Key(Key::Up) if selected_idx > 0 => { + selected_idx -= 1; + } + Event::Key(Key::Down) if selected_idx < confirmations.len() - 1 => { + selected_idx += 1; + } + Event::Key(Key::Char('\n')) => { + break; + } + Event::Key(Key::Esc) | Event::Key(Key::Ctrl('c')) => { + return (vec![], vec![]); + } + _ => {} + } + + write!( + screen, + "{}{}{}arrow keys to select, [a]ccept, [d]eny, [i]gnore, [enter] confirm choices\n\n", + termion::clear::All, + termion::cursor::Goto(1, 1), + termion::color::Fg(termion::color::White) + ) + .unwrap(); + for i in 0..confirmations.len() { + if selected_idx == i { + write!( + screen, + "\r{} >", + termion::color::Fg(termion::color::LightYellow) + ) + .unwrap(); + } else { + write!(screen, "\r{} ", termion::color::Fg(termion::color::White)).unwrap(); + } + + if to_accept_idx.contains(&i) { + write!( + screen, + "{}[a]", + termion::color::Fg(termion::color::LightGreen) + ) + .unwrap(); + } else if to_deny_idx.contains(&i) { + write!( + screen, + "{}[d]", + termion::color::Fg(termion::color::LightRed) + ) + .unwrap(); + } else { + write!(screen, "[ ]").unwrap(); + } + + if selected_idx == i { + write!( + screen, + "{}", + termion::color::Fg(termion::color::LightYellow) + ) + .unwrap(); + } + + write!(screen, " {}\n", confirmations[i].description()).unwrap(); + } + } + + return ( + to_accept_idx.iter().map(|i| confirmations[*i]).collect(), + to_deny_idx.iter().map(|i| confirmations[*i]).collect(), + ); +} + +pub fn pause() { + println!("Press any key to continue..."); + let mut stdout = std::io::stdout().into_raw_mode().unwrap(); + stdout.flush().unwrap(); + std::io::stdin().events().next(); +}