From d78e71871acd20ce872e89b4a73c2cd87902b8c5 Mon Sep 17 00:00:00 2001 From: Michael Edwards Date: Wed, 27 Mar 2019 22:35:31 +0100 Subject: [PATCH] Credentials helper if config not present or unparseable Fixes #1 --- Cargo.toml | 6 +- src/authentication.rs | 169 ++++++++++++++++++++++++++++++++++++++++++ src/config.rs | 57 +++++++++++++- src/main.rs | 38 +++++----- src/spotify.rs | 4 +- 5 files changed, 248 insertions(+), 26 deletions(-) create mode 100644 src/authentication.rs diff --git a/Cargo.toml b/Cargo.toml index 595eecc..b12bf1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ maintenance = { status = "experimental" } [dependencies] clap = "2.32.0" chrono = "0.4" +reqwest = "0.9.11" crossbeam-channel = "0.3.8" directories = "1.0" failure = "0.1.3" @@ -34,13 +35,16 @@ rand = "0.6.5" [dependencies.librespot] git = "https://github.com/librespot-org/librespot.git" -rev = "2fb901a743906a5b49b3148dbfa85074964dd745" +rev = "14721f4" default-features = false [dependencies.cursive] version = "0.11.1" default-features = false +[dependencies.winapi] +git = "https://github.com/overdrivenpotato/winapi-rs" + [features] pulseaudio_backend = ["librespot/pulseaudio-backend"] rodio_backend = ["librespot/rodio-backend"] diff --git a/src/authentication.rs b/src/authentication.rs new file mode 100644 index 0000000..5637287 --- /dev/null +++ b/src/authentication.rs @@ -0,0 +1,169 @@ +use std::path::Path; + +use cursive::view::Identifiable; +use cursive::views::*; +use cursive::{CbSink, Cursive}; + +use librespot::core::authentication::Credentials as RespotCredentials; +use librespot::protocol::authentication::AuthenticationType; + +pub fn create_credentials(path: &Path) -> Result { + let mut login_cursive = Cursive::default(); + let mut info_buf = TextContent::new("Failed to authenticate\n"); + info_buf.append(format!( + "Cannot read config file from {}\n", + path.to_str().unwrap() + )); + let info_view = Dialog::around(TextView::new_with_content(info_buf.clone())) + .button("Login", move |s| { + let login_view = Dialog::new() + .title("Login with Spotify username and password") + .content( + ListView::new() + .child("username", EditView::new().with_id("spotify_user")) + .child( + "password", + EditView::new().secret().with_id("spotify_password"), + ), + ) + .button("Login", |s| { + let username = s + .call_on_id("spotify_user", |view: &mut EditView| view.get_content()) + .unwrap() + .to_string(); + let auth_data = s + .call_on_id("spotify_password", |view: &mut EditView| view.get_content()) + .unwrap() + .to_string() + .as_bytes() + .to_vec(); + s.set_user_data::>(Ok(RespotCredentials { + username, + auth_type: AuthenticationType::AUTHENTICATION_USER_PASS, + auth_data, + })); + s.quit(); + }) + .button("Quit", |s| s.quit()); + s.pop_layer(); + s.add_layer(login_view); + }) + .button("Login with Facebook", |s| { + let urls: std::collections::HashMap = + reqwest::get("https://login2.spotify.com/v1/config") + .expect("didn't connect") + .json() + .expect("didn't parse"); + // not a dialog to let people copy & paste the URL + let url_notice = TextView::new(format!("Browse to {}", urls.get("login_url").unwrap())); + + let controls = Button::new("Quit", |s| s.quit()); + + let login_view = LinearLayout::new(cursive::direction::Orientation::Vertical) + .child(url_notice) + .child(controls); + url_open(urls.get("login_url").unwrap().to_string()); + crappy_poller(urls.get("credentials_url").unwrap(), &s.cb_sink()); + s.pop_layer(); + s.add_layer(login_view) + }) + .button("Quit", |s| s.quit()); + + login_cursive.add_layer(info_view); + login_cursive.run(); + + login_cursive + .user_data() + .cloned() + .unwrap_or(Err("Didn't obtain any credentials".to_string())) +} + +// TODO: better with futures? +fn crappy_poller(url: &str, app_sink: &CbSink) { + let app_sink = app_sink.clone(); + let url = url.to_string(); + std::thread::spawn(move || { + let timeout = std::time::Duration::from_secs(5 * 60); + let start_time = std::time::SystemTime::now(); + while std::time::SystemTime::now() + .duration_since(start_time) + .unwrap_or(timeout) + < timeout + { + if let Ok(mut response) = reqwest::get(&url) { + if response.status() != reqwest::StatusCode::ACCEPTED { + let result = match response.status() { + reqwest::StatusCode::OK => { + let creds = response + .json::() + .expect("Unable to parse") + .credentials; + Ok(creds) + } + + _ => Err(format!( + "Facebook auth failed with code {}: {}", + response.status(), + response.text().unwrap() + )), + }; + app_sink + .send(Box::new(|s: &mut Cursive| { + s.set_user_data(result); + s.quit(); + })) + .unwrap(); + return; + } + } + std::thread::sleep(std::time::Duration::from_millis(1000)); + } + + app_sink + .send(Box::new(|s: &mut Cursive| { + s.set_user_data::>(Err( + "Timed out authenticating".to_string(), + )); + s.quit(); + })) + .unwrap(); + }); +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct AuthResponse { + pub credentials: RespotCredentials, + pub error: Option, +} + +// Thanks to Marko Mijalkovic for this, but I don't want the url crate + +#[cfg(target_os = "windows")] +pub fn url_open(url: String) { + extern crate shell32; + extern crate winapi; + + use std::ffi::CString; + use std::ptr; + + unsafe { + shell32::ShellExecuteA( + ptr::null_mut(), + CString::new("open").unwrap().as_ptr(), + CString::new(url.replace("\n", "%0A")).unwrap().as_ptr(), + ptr::null(), + ptr::null(), + winapi::SW_SHOWNORMAL, + ); + } +} + +#[cfg(target_os = "macos")] +pub fn url_open(url: String) { + let _ = std::process::Command::new("open").arg(url).output(); +} + +#[cfg(target_os = "linux")] +pub fn url_open(url: String) { + let _ = std::process::Command::new("xdg-open").arg(url).output(); +} diff --git a/src/config.rs b/src/config.rs index 41b642f..ef67666 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use directories::ProjectDirs; @@ -8,8 +8,6 @@ pub const CLIENT_ID: &str = "d420a117a32841c2b3474932e49fb54b"; #[derive(Serialize, Deserialize, Debug, Default)] pub struct Config { - pub username: String, - pub password: String, pub keybindings: Option>, pub theme: Option, pub use_nerdfont: Option, @@ -42,7 +40,7 @@ pub fn config_path(file: &str) -> PathBuf { let proj_dirs = proj_dirs(); let cfg_dir = proj_dirs.config_dir(); trace!("{:?}", cfg_dir); - if !cfg_dir.is_dir() { + if cfg_dir.exists() && !cfg_dir.is_dir() { fs::remove_file(cfg_dir).expect("unable to remove old config file"); } if !cfg_dir.exists() { @@ -63,3 +61,54 @@ pub fn cache_path(file: &str) -> PathBuf { pb.push(file); pb } + +/// Configuration and credential file helper +/// Creates a default configuration if none exist, otherwise will optionally overwrite +/// the file if it fails to parse +pub fn load_or_generate_default< + P: AsRef, + T: serde::Serialize + serde::de::DeserializeOwned, + F: Fn(&Path) -> Result, +>( + path: P, + default: F, + default_on_parse_failure: bool, +) -> Result { + let path = path.as_ref(); + // Nothing exists so just write the default and return it + if !path.exists() { + let value = default(&path)?; + return write_content_helper(&path, value); + } + + // load the serialized content. Always report this failure + let contents = std::fs::read_to_string(&path) + .map_err(|e| format!("Unable to read {}: {}", path.to_string_lossy(), e))?; + + // Deserialize the content, optionally fall back to default if it fails + let result = toml::from_str(&contents); + if default_on_parse_failure { + if let Err(_) = result { + let value = default(&path)?; + return write_content_helper(&path, value); + } + } + result.map_err(|e| format!("Unable to parse {}: {}", path.to_string_lossy(), e)) +} + +fn write_content_helper, T: serde::Serialize>( + path: P, + value: T, +) -> Result { + let content = + toml::to_string_pretty(&value).map_err(|e| format!("Failed serializing value: {}", e))?; + fs::write(path.as_ref(), content) + .map(|_| value) + .map_err(|e| { + format!( + "Failed writing content to {}: {}", + path.as_ref().display(), + e + ) + }) +} diff --git a/src/main.rs b/src/main.rs index d486ce7..2d898e1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,6 +35,9 @@ use clap::{App, Arg}; use cursive::traits::Identifiable; use cursive::Cursive; +use librespot::core::authentication::Credentials; + +mod authentication; mod commands; mod config; mod events; @@ -98,21 +101,22 @@ fn main() { // Things here may cause the process to abort; we must do them before creating curses windows // otherwise the error message will not be seen by a user - let path = config::config_path("config.toml"); + let cfg: ::config::Config = { + let path = config::config_path("config.toml"); + ::config::load_or_generate_default(path, |_| Ok(::config::Config::default()), false) + .unwrap_or_else(|e| { + eprintln!("{}", e); + process::exit(1); + }) + }; - let cfg: config::Config = { - let contents = std::fs::read_to_string(&path).unwrap_or_else(|_| { - eprintln!("Cannot read config file from {}", path.to_str().unwrap()); - eprintln!( - "Expected a config file with this format:\n{}", - toml::to_string_pretty(&config::Config::default()).unwrap() - ); - process::exit(1) - }); - toml::from_str(&contents).unwrap_or_else(|e| { - eprintln!("{}", e); - process::exit(1) - }) + let credentials: Credentials = { + let path = config::config_path("credentials.toml"); + ::config::load_or_generate_default(path, authentication::create_credentials, true) + .unwrap_or_else(|e| { + eprintln!("{}", e); + process::exit(1); + }) }; let theme = theme::load(&cfg); @@ -122,11 +126,7 @@ fn main() { let event_manager = EventManager::new(cursive.cb_sink().clone()); - let spotify = Arc::new(spotify::Spotify::new( - event_manager.clone(), - cfg.username.clone(), - cfg.password.clone(), - )); + let spotify = Arc::new(spotify::Spotify::new(event_manager.clone(), credentials)); let queue = Arc::new(queue::Queue::new(spotify.clone())); diff --git a/src/spotify.rs b/src/spotify.rs index f5b9c17..de7c37b 100644 --- a/src/spotify.rs +++ b/src/spotify.rs @@ -184,13 +184,13 @@ impl futures::Future for Worker { } impl Spotify { - pub fn new(events: EventManager, user: String, password: String) -> Spotify { + pub fn new(events: EventManager, credentials: Credentials) -> Spotify { let player_config = PlayerConfig { bitrate: Bitrate::Bitrate320, normalisation: false, normalisation_pregain: 0.0, }; - let credentials = Credentials::with_password(user.clone(), password.clone()); + let user = credentials.username.clone(); let (tx, rx) = mpsc::unbounded(); {