From ea3bd35ffde1974eecdfd78cb3191653cb2c6bb7 Mon Sep 17 00:00:00 2001 From: Elias Projahn Date: Sat, 14 Nov 2020 22:32:21 +0100 Subject: [PATCH] Add HTTP client and login support --- Cargo.toml | 4 + data/de.johrpan.musicus.gschema.xml | 4 + meson.build | 2 + res/musicus.gresource.xml | 2 + res/ui/login_dialog.ui | 219 ++++++++++++++++++++++++++++ res/ui/preferences.ui | 52 +++++++ res/ui/server_dialog.ui | 96 ++++++++++++ src/{ => backend}/backend.rs | 70 ++++++++- src/backend/client.rs | 31 ++++ src/backend/mod.rs | 7 + src/backend/secure.rs | 108 ++++++++++++++ src/dialogs/login_dialog.rs | 88 +++++++++++ src/dialogs/mod.rs | 6 + src/dialogs/preferences.rs | 96 +++++++++--- src/dialogs/server_dialog.rs | 65 +++++++++ src/meson.build | 7 +- 16 files changed, 832 insertions(+), 25 deletions(-) create mode 100644 res/ui/login_dialog.ui create mode 100644 res/ui/server_dialog.ui rename src/{ => backend}/backend.rs (88%) create mode 100644 src/backend/client.rs create mode 100644 src/backend/mod.rs create mode 100644 src/backend/secure.rs create mode 100644 src/dialogs/login_dialog.rs create mode 100644 src/dialogs/server_dialog.rs diff --git a/Cargo.toml b/Cargo.toml index 5414ca8..e84df8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,10 @@ gtk = { version = "0.9.2", features = ["v3_24"] } gtk-macros = "0.2.0" gstreamer = "0.16.4" gstreamer-player = "0.16.3" +isahc = "0.9.12" libhandy = "0.7.0" pango = "0.9.1" rand = "0.7.3" +secret-service = "1.1.1" +serde = { version = "1.0.117", features = ["derive"] } +serde_json = "1.0.59" diff --git a/data/de.johrpan.musicus.gschema.xml b/data/de.johrpan.musicus.gschema.xml index 2609270..db93385 100644 --- a/data/de.johrpan.musicus.gschema.xml +++ b/data/de.johrpan.musicus.gschema.xml @@ -5,5 +5,9 @@ "" Path to the music library folder + + "https://musicus.johrpan.de" + URL of the Musicus server to use + diff --git a/meson.build b/meson.build index 0c4ba05..01999e8 100644 --- a/meson.build +++ b/meson.build @@ -4,10 +4,12 @@ project('musicus', 'rust', license: 'AGPLv3+', ) +dependency('dbus-1', version: '>= 1.3') dependency('glib-2.0', version: '>= 2.56') dependency('gio-2.0', version: '>= 2.56') dependency('gstreamer-1.0', version: '>= 1.12') dependency('gtk+-3.0', version: '>= 3.24.7') +dependency('libcurl', version: '>= 7.24.0') dependency('libhandy-1', version: '>= 1.0.0') dependency('pango', version: '>= 1.0') dependency('sqlite3', version: '>= 3.20') diff --git a/res/musicus.gresource.xml b/res/musicus.gresource.xml index 92e3c7d..2e487fc 100644 --- a/res/musicus.gresource.xml +++ b/res/musicus.gresource.xml @@ -6,6 +6,7 @@ ui/ensemble_screen.ui ui/instrument_editor.ui ui/instrument_selector.ui + ui/login_dialog.ui ui/part_editor.ui ui/performance_editor.ui ui/person_editor.ui @@ -21,6 +22,7 @@ ui/recording_selector.ui ui/recording_selector_screen.ui ui/section_editor.ui + ui/server_dialog.ui ui/tracks_editor.ui ui/track_editor.ui ui/window.ui diff --git a/res/ui/login_dialog.ui b/res/ui/login_dialog.ui new file mode 100644 index 0000000..25c83f2 --- /dev/null +++ b/res/ui/login_dialog.ui @@ -0,0 +1,219 @@ + + + + + + + False + True + 350 + dialog + + + True + False + crossfade + + + True + False + vertical + + + True + False + Login + + + Cancel + True + True + True + + + + + Login + True + True + True + True + False + + + + end + 1 + + + + + False + True + 0 + + + + + True + False + error + False + + + False + 6 + end + + + + + + False + False + 0 + + + + + False + 16 + + + True + False + The login credentials were wrong! + + + False + True + 0 + + + + + False + False + 0 + + + + + False + True + 1 + + + + + + True + False + 18 + 12 + 6 + + + True + False + end + Username + + + 0 + 0 + + + + + True + False + end + Password + + + 0 + 1 + + + + + True + True + True + True + + + 1 + 0 + + + + + True + True + False + True + password + + + 1 + 1 + + + + + False + True + 2 + + + + + content + + + + + True + False + vertical + + + True + False + Login + + + False + True + 0 + + + + + True + False + True + + + True + True + 1 + + + + + loading + 1 + + + + + + + + + diff --git a/res/ui/preferences.ui b/res/ui/preferences.ui index 7c7d80b..3ba91cd 100644 --- a/res/ui/preferences.ui +++ b/res/ui/preferences.ui @@ -40,7 +40,59 @@ + + + True + False + Server connection + + + True + True + False + Server URL + url_button + Not set + + + Change + True + True + True + center + + + + + + + True + True + False + Login credentials + login_button + Not logged in + + + Change + True + True + True + center + + + + + + + + + + + + + diff --git a/res/ui/server_dialog.ui b/res/ui/server_dialog.ui new file mode 100644 index 0000000..c4401b5 --- /dev/null +++ b/res/ui/server_dialog.ui @@ -0,0 +1,96 @@ + + + + + + + False + True + True + dialog + + + True + False + vertical + + + True + False + Server + + + Cancel + True + True + True + + + + + Set + True + True + True + True + True + + + + end + 1 + + + + + False + True + 0 + + + + + + True + False + 18 + 12 + 6 + + + True + False + end + URL + + + 0 + 0 + + + + + True + True + True + True + True + + + 1 + 0 + + + + + False + True + 1 + + + + + + diff --git a/src/backend.rs b/src/backend/backend.rs similarity index 88% rename from src/backend.rs rename to src/backend/backend.rs index bd412c2..eaf15f0 100644 --- a/src/backend.rs +++ b/src/backend/backend.rs @@ -1,13 +1,23 @@ -use super::database::*; +use super::secure; +use crate::database::*; use crate::player::*; use anyhow::{anyhow, Result}; use futures_channel::oneshot::Sender; use futures_channel::{mpsc, oneshot}; use gio::prelude::*; +use serde::Serialize; use std::cell::RefCell; use std::path::PathBuf; use std::rc::Rc; +/// Credentials used for login. +#[derive(Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct LoginData { + pub username: String, + pub password: String, +} + pub enum BackendState { NoMusicLibrary, Loading, @@ -50,6 +60,10 @@ pub struct Backend { state_sender: RefCell>, action_sender: RefCell>>, settings: gio::Settings, + secrets: secret_service::SecretService, + server_url: RefCell>, + login_data: RefCell>, + token: RefCell>, music_library_path: RefCell>, player: RefCell>>, } @@ -57,13 +71,19 @@ pub struct Backend { impl Backend { pub fn new() -> Self { let (state_sender, state_stream) = mpsc::channel(1024); + let secrets = secret_service::SecretService::new(secret_service::EncryptionType::Dh) + .expect("Failed to connect to SecretsService!"); Backend { state_stream: RefCell::new(state_stream), state_sender: RefCell::new(state_sender), action_sender: RefCell::new(None), settings: gio::Settings::new("de.johrpan.musicus"), + secrets, music_library_path: RefCell::new(None), + server_url: RefCell::new(None), + login_data: RefCell::new(None), + token: RefCell::new(None), player: RefCell::new(None), } } @@ -72,13 +92,25 @@ impl Backend { if let Some(path) = self.settings.get_string("music-library-path") { if !path.is_empty() { let context = glib::MainContext::default(); + let clone = self.clone(); context.spawn_local(async move { - self.set_music_library_path_priv(PathBuf::from(path.to_string())) + clone + .set_music_library_path_priv(PathBuf::from(path.to_string())) .await .unwrap(); }); } } + + if let Some(data) = secure::load_login_data().unwrap() { + self.login_data.replace(Some(data)); + } + + if let Some(url) = self.settings.get_string("server-url") { + if !url.is_empty() { + self.server_url.replace(Some(url.to_string())); + } + } } pub async fn update_person(&self, person: Person) -> Result<()> { @@ -270,6 +302,40 @@ impl Backend { self.music_library_path.borrow().clone() } + /// Get the currently stored login credentials. + pub fn get_login_data(&self) -> Option { + self.login_data.borrow().clone() + } + + /// Set the URL of the Musicus server to connect to. + pub fn set_server_url(&self, url: &str) -> Result<()> { + self.settings.set_string("server-url", url)?; + self.server_url.replace(Some(url.to_string())); + Ok(()) + } + + /// Get the currently used login token. + pub fn get_token(&self) -> Option { + self.token.borrow().clone() + } + + /// Set the login token to use. This will be done automatically by the login method. + pub fn set_token(&self, token: &str) { + self.token.replace(Some(token.to_string())); + } + + /// Get the currently set server URL. + pub fn get_server_url(&self) -> Option { + self.server_url.borrow().clone() + } + + /// Set the user credentials to use. + pub async fn set_login_data(&self, data: LoginData) -> Result<()> { + secure::store_login_data(data.clone()).await?; + self.login_data.replace(Some(data)); + Ok(()) + } + pub fn get_player(&self) -> Option> { self.player.borrow().clone() } diff --git a/src/backend/client.rs b/src/backend/client.rs new file mode 100644 index 0000000..c8955a4 --- /dev/null +++ b/src/backend/client.rs @@ -0,0 +1,31 @@ +use super::Backend; +use anyhow::{anyhow, bail, Result}; +use isahc::http::StatusCode; +use isahc::prelude::*; + +impl Backend { + /// Try to login a user with the provided credentials and return, wether the login suceeded. + pub async fn login(&self) -> Result { + let server_url = self.get_server_url().ok_or(anyhow!("No server URL set!"))?; + let data = self.get_login_data().ok_or(anyhow!("No login data set!"))?; + + let request = Request::post(format!("{}/login", server_url)) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&data)?)?; + + let mut response = isahc::send_async(request).await?; + + let success = match response.status() { + StatusCode::OK => { + let token = response.text_async().await?; + self.set_token(&token); + println!("{}", &token); + true + } + StatusCode::UNAUTHORIZED => false, + _ => bail!("Unexpected response status!"), + }; + + Ok(success) + } +} diff --git a/src/backend/mod.rs b/src/backend/mod.rs new file mode 100644 index 0000000..02ec571 --- /dev/null +++ b/src/backend/mod.rs @@ -0,0 +1,7 @@ +pub mod backend; +pub use backend::*; + +pub mod client; +pub use client::*; + +mod secure; \ No newline at end of file diff --git a/src/backend/secure.rs b/src/backend/secure.rs new file mode 100644 index 0000000..d52bada --- /dev/null +++ b/src/backend/secure.rs @@ -0,0 +1,108 @@ +use super::LoginData; +use anyhow::{anyhow, Result}; +use futures_channel::oneshot; +use secret_service::{Collection, EncryptionType, SecretService}; + +/// Savely store the user's current login credentials. +pub async fn store_login_data(data: LoginData) -> Result<()> { + let (sender, receiver) = oneshot::channel::>(); + std::thread::spawn(move || sender.send(store_login_data_priv(data))); + receiver.await? +} + +/// Savely store the user's current login credentials. +fn store_login_data_priv(data: LoginData) -> Result<()> { + let ss = get_ss()?; + let collection = get_collection(&ss)?; + + let key = "musicus-login-data"; + delete_secrets(&collection, key)?; + + collection + .create_item( + key, + vec![("username", &data.username)], + data.password.as_bytes(), + true, + "text/plain", + ) + .or(Err(anyhow!( + "Failed to save login data using SecretService!" + )))?; + + Ok(()) +} + +/// Get the login credentials from secret storage. +pub fn load_login_data() -> Result> { + let ss = get_ss()?; + let collection = get_collection(&ss)?; + + let items = collection.get_all_items().or(Err(anyhow!( + "Failed to get items from SecretService collection!" + )))?; + + let key = "musicus-login-data"; + let item = items + .iter() + .find(|item| item.get_label().unwrap_or_default() == key); + + Ok(match item { + Some(item) => { + let attrs = item.get_attributes().or(Err(anyhow!( + "Failed to get attributes for ScretService item!" + )))?; + + let username = attrs + .iter() + .find(|attr| attr.0 == "username") + .ok_or(anyhow!("No username in login data!"))? + .1 + .clone(); + + let password = std::str::from_utf8( + &item + .get_secret() + .or(Err(anyhow!("Failed to get secret from SecretService!")))?, + )? + .to_string(); + + Some(LoginData { username, password }) + } + None => None, + }) +} + +/// Delete all stored secrets for the provided key. +fn delete_secrets(collection: &Collection, key: &str) -> Result<()> { + let items = collection.get_all_items().or(Err(anyhow!( + "Failed to get items from SecretService collection!" + )))?; + + for item in items { + if item.get_label().unwrap_or_default() == key { + item.delete() + .or(Err(anyhow!("Failed to delete SecretService item!")))?; + } + } + + Ok(()) +} + +/// Get the SecretService interface. +fn get_ss() -> Result { + SecretService::new(EncryptionType::Dh).or(Err(anyhow!("Failed to get SecretService!"))) +} + +/// Get the default SecretService collection and unlock it. +fn get_collection(ss: &SecretService) -> Result { + let collection = ss + .get_default_collection() + .or(Err(anyhow!("Failed to get SecretService connection!")))?; + + collection + .unlock() + .or(Err(anyhow!("Failed to unclock SecretService collection!")))?; + + Ok(collection) +} diff --git a/src/dialogs/login_dialog.rs b/src/dialogs/login_dialog.rs new file mode 100644 index 0000000..e13c2cd --- /dev/null +++ b/src/dialogs/login_dialog.rs @@ -0,0 +1,88 @@ +use crate::backend::{Backend, LoginData}; +use glib::clone; +use gtk::prelude::*; +use gtk_macros::get_widget; +use std::cell::RefCell; +use std::rc::Rc; + +/// A dialog for entering login credentials. +pub struct LoginDialog { + backend: Rc, + window: libhandy::Window, + stack: gtk::Stack, + info_bar: gtk::InfoBar, + username_entry: gtk::Entry, + password_entry: gtk::Entry, + selected_cb: RefCell ()>>>, +} + +impl LoginDialog { + /// Create a new login dialog. + pub fn new>(backend: Rc, parent: &P) -> Rc { + // Create UI + let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/login_dialog.ui"); + + get_widget!(builder, libhandy::Window, window); + get_widget!(builder, gtk::Stack, stack); + get_widget!(builder, gtk::InfoBar, info_bar); + get_widget!(builder, gtk::Button, cancel_button); + get_widget!(builder, gtk::Button, login_button); + get_widget!(builder, gtk::Entry, username_entry); + get_widget!(builder, gtk::Entry, password_entry); + + window.set_transient_for(Some(parent)); + + let this = Rc::new(Self { + backend, + window, + stack, + info_bar, + username_entry, + password_entry, + selected_cb: RefCell::new(None), + }); + + // Connect signals and callbacks + + cancel_button.connect_clicked(clone!(@strong this => move |_| { + this.window.close(); + })); + + login_button.connect_clicked(clone!(@strong this => move |_| { + this.stack.set_visible_child_name("loading"); + + let data = LoginData { + username: this.username_entry.get_text().to_string(), + password: this.password_entry.get_text().to_string(), + }; + + let c = glib::MainContext::default(); + let clone = this.clone(); + c.spawn_local(async move { + clone.backend.set_login_data(data.clone()).await.unwrap(); + if clone.backend.login().await.unwrap() { + if let Some(cb) = &*clone.selected_cb.borrow() { + cb(data); + } + + clone.window.close(); + } else { + clone.stack.set_visible_child_name("content"); + clone.info_bar.set_revealed(true); + } + }); + })); + + this + } + + /// The closure to call when the login succeded. + pub fn set_selected_cb () + 'static>(&self, cb: F) { + self.selected_cb.replace(Some(Box::new(cb))); + } + + /// Show the login dialog. + pub fn show(&self) { + self.window.show(); + } +} diff --git a/src/dialogs/mod.rs b/src/dialogs/mod.rs index ab86e94..afa2dc2 100644 --- a/src/dialogs/mod.rs +++ b/src/dialogs/mod.rs @@ -13,6 +13,9 @@ pub use instrument_editor::*; pub mod instrument_selector; pub use instrument_selector::*; +pub mod login_dialog; +pub use login_dialog::*; + pub mod person_editor; pub use person_editor::*; @@ -22,6 +25,9 @@ pub use person_selector::*; pub mod preferences; pub use preferences::*; +pub mod server_dialog; +pub use server_dialog::*; + pub mod recording; pub use recording::*; diff --git a/src/dialogs/preferences.rs b/src/dialogs/preferences.rs index cc0673a..a12fe77 100644 --- a/src/dialogs/preferences.rs +++ b/src/dialogs/preferences.rs @@ -1,3 +1,4 @@ +use super::{LoginDialog, ServerDialog}; use crate::backend::Backend; use gettextrs::gettext; use glib::clone; @@ -6,47 +7,98 @@ use gtk_macros::get_widget; use libhandy::prelude::*; use std::rc::Rc; +/// A dialog for configuring the app. pub struct Preferences { + backend: Rc, window: libhandy::Window, + music_library_path_row: libhandy::ActionRow, + url_row: libhandy::ActionRow, + login_row: libhandy::ActionRow, } impl Preferences { - pub fn new>(backend: Rc, parent: &P) -> Self { + /// Create a new preferences dialog. + pub fn new>(backend: Rc, parent: &P) -> Rc { + // Create UI let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/preferences.ui"); get_widget!(builder, libhandy::Window, window); get_widget!(builder, libhandy::ActionRow, music_library_path_row); get_widget!(builder, gtk::Button, select_music_library_path_button); + get_widget!(builder, libhandy::ActionRow, url_row); + get_widget!(builder, gtk::Button, url_button); + get_widget!(builder, libhandy::ActionRow, login_row); + get_widget!(builder, gtk::Button, login_button); window.set_transient_for(Some(parent)); - if let Some(path) = backend.get_music_library_path() { - music_library_path_row.set_subtitle(Some(path.to_str().unwrap())); + let this = Rc::new(Self { + backend, + window, + music_library_path_row, + url_row, + login_row, + }); + + // Connect signals and callbacks + + select_music_library_path_button.connect_clicked(clone!(@strong this => move |_| { + let dialog = gtk::FileChooserNative::new( + Some(&gettext("Select music library folder")), + Some(&this.window), gtk::FileChooserAction::SelectFolder,None, None); + + if let gtk::ResponseType::Accept = dialog.run() { + if let Some(path) = dialog.get_filename() { + this.music_library_path_row.set_subtitle(Some(path.to_str().unwrap())); + + let context = glib::MainContext::default(); + let backend = this.backend.clone(); + context.spawn_local(async move { + backend.set_music_library_path(path).await.unwrap(); + }); + } + } + })); + + url_button.connect_clicked(clone!(@strong this => move |_| { + let dialog = ServerDialog::new(this.backend.clone(), &this.window); + + dialog.set_selected_cb(clone!(@strong this => move |url| { + this.url_row.set_subtitle(Some(&url)); + })); + + dialog.show(); + })); + + login_button.connect_clicked(clone!(@strong this => move |_| { + let dialog = LoginDialog::new(this.backend.clone(), &this.window); + + dialog.set_selected_cb(clone!(@strong this => move |data| { + this.login_row.set_subtitle(Some(&data.username)); + })); + + dialog.show(); + })); + + // Initialize + + if let Some(path) = this.backend.get_music_library_path() { + this.music_library_path_row + .set_subtitle(Some(path.to_str().unwrap())); } - select_music_library_path_button.connect_clicked( - clone!(@strong window, @strong backend, @strong music_library_path_row => move |_| { - let dialog = gtk::FileChooserNative::new( - Some(&gettext("Select music library folder")), - Some(&window), gtk::FileChooserAction::SelectFolder,None, None); + if let Some(url) = this.backend.get_server_url() { + this.url_row.set_subtitle(Some(&url)); + } - if let gtk::ResponseType::Accept = dialog.run() { - if let Some(path) = dialog.get_filename() { - music_library_path_row.set_subtitle(Some(path.to_str().unwrap())); + if let Some(data) = this.backend.get_login_data() { + this.login_row.set_subtitle(Some(&data.username)); + } - let context = glib::MainContext::default(); - let backend = backend.clone(); - context.spawn_local(async move { - backend.set_music_library_path(path).await.unwrap(); - }); - } - } - }), - ); - - Self { window } + this } + /// Show the preferences dialog. pub fn show(&self) { self.window.show(); } diff --git a/src/dialogs/server_dialog.rs b/src/dialogs/server_dialog.rs new file mode 100644 index 0000000..4d23550 --- /dev/null +++ b/src/dialogs/server_dialog.rs @@ -0,0 +1,65 @@ +use crate::backend::Backend; +use glib::clone; +use gtk::prelude::*; +use gtk_macros::get_widget; +use std::cell::RefCell; +use std::rc::Rc; + +/// A dialog for setting up the server. +pub struct ServerDialog { + backend: Rc, + window: libhandy::Window, + url_entry: gtk::Entry, + selected_cb: RefCell ()>>>, +} + +impl ServerDialog { + /// Create a new server dialog. + pub fn new>(backend: Rc, parent: &P) -> Rc { + // Create UI + let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/server_dialog.ui"); + + get_widget!(builder, libhandy::Window, window); + get_widget!(builder, gtk::Button, cancel_button); + get_widget!(builder, gtk::Button, set_button); + get_widget!(builder, gtk::Entry, url_entry); + + window.set_transient_for(Some(parent)); + + let this = Rc::new(Self { + backend, + window, + url_entry, + selected_cb: RefCell::new(None), + }); + + // Connect signals and callbacks + + cancel_button.connect_clicked(clone!(@strong this => move |_| { + this.window.close(); + })); + + set_button.connect_clicked(clone!(@strong this => move |_| { + let url = this.url_entry.get_text().to_string(); + this.backend.set_server_url(&url).unwrap(); + + if let Some(cb) = &*this.selected_cb.borrow() { + cb(url); + } + + this.window.close(); + })); + + this + } + + /// The closure to call when the server was set. + pub fn set_selected_cb () + 'static>(&self, cb: F) { + self.selected_cb.replace(Some(Box::new(cb))); + } + + /// Show the server dialog. + pub fn show(&self) { + self.window.show(); + } +} diff --git a/src/meson.build b/src/meson.build index bf0a04d..5d851d2 100644 --- a/src/meson.build +++ b/src/meson.build @@ -33,6 +33,10 @@ run_command( ) sources = files( + 'backend/backend.rs', + 'backend/client.rs', + 'backend/mod.rs', + 'backend/secure.rs', 'database/database.rs', 'database/mod.rs', 'database/models.rs', @@ -43,10 +47,12 @@ sources = files( 'dialogs/ensemble_selector.rs', 'dialogs/instrument_editor.rs', 'dialogs/instrument_selector.rs', + 'dialogs/login_dialog.rs', 'dialogs/mod.rs', 'dialogs/person_editor.rs', 'dialogs/person_selector.rs', 'dialogs/preferences.rs', + 'dialogs/server_dialog.rs', 'dialogs/recording/mod.rs', 'dialogs/recording/performance_editor.rs', 'dialogs/recording/recording_dialog.rs', @@ -78,7 +84,6 @@ sources = files( 'widgets/player_bar.rs', 'widgets/poe_list.rs', 'widgets/selector_row.rs', - 'backend.rs', 'config.rs', 'config.rs.in', 'main.rs',