diff --git a/Cargo.lock b/Cargo.lock index 3065763..7ee2b79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -168,7 +168,6 @@ dependencies = [ "rusqlite_migration", "serde", "serde_json", - "serde_rusqlite", "ureq", ] @@ -963,16 +962,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_rusqlite" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e73509831d054bb2d2a69e3457ad7c417a5820bab80a70676182148e29a503ed" -dependencies = [ - "rusqlite", - "serde", -] - [[package]] name = "slab" version = "0.4.6" diff --git a/Cargo.toml b/Cargo.toml index 5d11644..f9def43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,5 +11,4 @@ rusqlite = "*" rusqlite_migration = "*" serde = {version = "*", features = ["derive"]} serde_json = "*" -serde_rusqlite = "*" ureq = {version = "*", features = ["json", "native-certs", "gzip"]} diff --git a/shell.nix b/shell.nix index ef49024..36602e2 100644 --- a/shell.nix +++ b/shell.nix @@ -4,6 +4,7 @@ pkgs.mkShell { nativeBuildInputs = [ pkgs.rustc pkgs.cargo + pkgs.clippy pkgs.rust-analyzer pkgs.pkg-config pkgs.gtk4 diff --git a/src/db/mod.rs b/src/db/mod.rs index 4377fc1..0ca78e0 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,40 +1,325 @@ use std::path::PathBuf; -use dirs; -use rusqlite::{Connection, Error}; +use rusqlite::{Connection, named_params}; use rusqlite_migration::{Migrations, M}; -pub fn open_db() -> Result { +use crate::mastodon::oauth::RegisteredApp; +use crate::mastodon::accounts::UserAccount; + +#[derive(Debug)] +pub enum OpenDbError { + RusqliteError(rusqlite::Error), + IOError(std::io::Error) +} + +pub fn open_db() -> Result { let mut data_dir = dirs::data_dir(); let path = data_dir.get_or_insert(PathBuf::from("./")); path.push("federatz"); - std::fs::create_dir_all(&path); + std::fs::create_dir_all(&path).map_err(|err| OpenDbError::IOError(err))?; path.push("federatz.db"); - Connection::open(path) + Connection::open(path).map_err(|err| OpenDbError::RusqliteError(err)) } -pub fn run_migrations(mut conn: Connection) -> Result<(), rusqlite_migration::Error> { +pub fn run_migrations(conn: &mut Connection) -> Result<(), rusqlite_migration::Error> { let migrations = Migrations::new(vec![ M::up(r#" CREATE TABLE oauth_client( - id INTEGER PRIMARY KEY, - instance_uri TEXT NOT NULL, + instance_fqdn TEXT NOT NULL, client_id TEXT NOT NULL, - client_secret TEXT NOT NULL + client_secret TEXT NOT NULL, + name TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + website TEXT ); CREATE TABLE local_user( + id INTEGER, token TEXT NOT NULL, username TEXT NOT NULL, display_name TEXT NOT NULL, + locked BOOLEAN NOT NULL, + bot BOOLEAN NOT NULL, + created_at TEXT NOT NULL, note TEXT NOT NULL, - follower_count INTEGER NOT NULL, + url TEXT NOT NULL, + avatar TEXT NOT NULL, + avatar_static TEXT NOT NULL, + header TEXT NOT NULL, + header_static TEXT NOT NULL, + followers_count INTEGER NOT NULL, following_count INTEGER NOT NULL, statuses_count INTEGER NOT NULL, client INTEGER, FOREIGN KEY(client) REFERENCES oauth_client(id) - ); + ); "#) ]); - migrations.to_latest(&mut conn) + migrations.to_latest(conn) +} + +#[derive(Debug, PartialEq)] +pub enum OauthClientDatabaseError { + NoClient, + RusqliteError(rusqlite::Error) +} + +impl From for OauthClientDatabaseError { + fn from(error: rusqlite::Error) -> Self { + OauthClientDatabaseError::RusqliteError(error) + } +} +pub fn new_oauth_client(conn: &Connection, client: &RegisteredApp) -> Result { + let mut stmt = + conn.prepare(r#" + INSERT INTO oauth_client + (instance_fqdn, client_id, client_secret, name, redirect_uri, website) + VALUES (:instance_fqdn, :client_id, :client_secret, :name, :redirect_uri, :website)"# + )?; + stmt.insert( + named_params! { + ":instance_fqdn": client.instance_fqdn, + ":client_id": client.client_id, + ":client_secret": client.client_secret, + ":name": client.name, + ":redirect_uri": client.redirect_uri, + ":website": client.website, + } + ) +} + +pub fn delete_oauth_client(conn: &Connection, client: &RegisteredApp) -> Result { + let mut stmt = + conn.prepare(r#" + DELETE FROM oauth_client + WHERE client_id = :client_id AND client_secret = :client_secret"# + )?; + stmt.execute( + named_params! { + ":client_id": client.client_id, + ":client_secret": client.client_secret + } + ) +} + +pub fn get_oauth_client(conn: &Connection, client_id: &str) -> Result<(RegisteredApp, i64), OauthClientDatabaseError> { + let mut stmt = + conn.prepare( + r#"SELECT ROWID, instance_fqdn, client_id, client_secret, name, redirect_uri, website + FROM oauth_client + WHERE client_id = :client_id"#)?; + let mut rows = stmt.query_and_then( + named_params! { + ":client_id": client_id + }, + |row| Ok((RegisteredApp { + instance_fqdn: row.get("instance_fqdn")?, + client_id: row.get("client_id")?, + client_secret: row.get("client_secret")?, + name: row.get("name")?, + redirect_uri: row.get("redirect_uri")?, + website: row.get("website")?, + }, row.get("ROWID")?)))?; + rows.next().ok_or(OauthClientDatabaseError::NoClient)? +} + +#[derive(Debug, PartialEq)] +pub enum GetLocalUserDatabaseError { + NoUser, + ManyUsers, + RusqliteError(rusqlite::Error) +} + +impl From for GetLocalUserDatabaseError { + fn from(error: rusqlite::Error) -> Self { + GetLocalUserDatabaseError::RusqliteError(error) + } +} + +#[derive(Debug, PartialEq)] +pub enum NewLocalUserDatabaseError { + ClientError(OauthClientDatabaseError), + RusqliteError(rusqlite::Error) +} + +impl From for NewLocalUserDatabaseError { + fn from(error: rusqlite::Error) -> Self { + NewLocalUserDatabaseError::RusqliteError(error) + } +} + +impl From for NewLocalUserDatabaseError { + fn from(error:OauthClientDatabaseError) -> Self { + NewLocalUserDatabaseError::ClientError(error) + } +} + +/// Creates a new user account and returns its Sqlite ROWID. +pub fn new_local_user(conn: &Connection, user: &UserAccount, client: &RegisteredApp) -> Result { + let mut stmt = + conn.prepare(r#" + INSERT INTO local_user (id, token, username, display_name, locked, bot, created_at, note, url, avatar, + avatar_static, header, header_static, followers_count, following_count, + statuses_count, client) + VALUES (:id, :token, :username, :display_name, :locked, :bot, :created_at, :note, :url, :avatar, + :avatar_static, :header, :header_static, :followers_count, :following_count, + :statuses_count, :client)"# + )?; + let client = get_oauth_client(&conn, &client.client_id)?; + let new_user = stmt.insert( + named_params! { + ":id": user.id, + ":token": user.token, + ":username": user.username, + ":display_name": user.display_name, + ":locked": user.locked, + ":bot": user.bot, + ":created_at": user.created_at, + ":note": user.note, + ":url": user.url, + ":avatar": user.avatar, + ":avatar_static": user.avatar_static, + ":header": user.header, + ":header_static": user.header_static, + ":followers_count": user.followers_count, + ":following_count": user.following_count, + ":statuses_count": user.statuses_count, + } + )?; + Ok(new_user) +} + +pub fn get_local_user(conn: &Connection) -> Result { + let mut stmt = + conn.prepare(r#" + SELECT CAST(id AS TEXT) as id, token, username, display_name, locked, bot, created_at, note, url, avatar, + avatar_static, header, header_static, followers_count, following_count, + statuses_count FROM local_user"#).map_err(|err| GetLocalUserDatabaseError::RusqliteError(err))?; + let mut rows = stmt.query_and_then( + [], + |row| Ok(UserAccount { + id: row.get("id")?, + token: row.get("token")?, + username: row.get("username")?, + display_name: row.get("display_name")?, + locked: row.get("locked")?, + bot: row.get("bot")?, + created_at: row.get("created_at")?, + note: row.get("note")?, + url: row.get("url")?, + avatar: row.get("avatar")?, + avatar_static: row.get("avatar_static")?, + header: row.get("header")?, + header_static: row.get("header_static")?, + followers_count: row.get("followers_count")?, + following_count: row.get("following_count")?, + statuses_count: row.get("statuses_count")?, + })).map_err(|err| GetLocalUserDatabaseError::RusqliteError(err))?; + let user = rows.next().ok_or(GetLocalUserDatabaseError::NoUser)?; + if rows.next().is_none() { + user + } else { + Err(GetLocalUserDatabaseError::ManyUsers) + } +} + +pub fn delete_local_user(conn: &Connection, user: &UserAccount) -> Result { + let mut stmt = + conn.prepare("DELETE FROM local_user WHERE username = :username")?; + stmt.execute(named_params! { ":username": user.username }) +} + +#[cfg(test)] +mod test { + use crate::db::{UserAccount, RegisteredApp}; + + fn dummy_client () -> RegisteredApp { + RegisteredApp { + instance_fqdn: String::from("dummy.instance"), + client_id: String::from("dummy-client-id"), + client_secret: String::from("dummy-client-secret"), + name: String::from("dummy-name"), + redirect_uri: String::from("dummy-redirect-uri"), + website: None, + } + } + + fn dummy_local_user (id: &str) -> UserAccount { + UserAccount { + id: String::from(id), + token: String::from("dummy-token"), + username: String::from(format!("Ninjatrappeur{}", id)), + display_name: String::from("â ´Ninjatrappeurâ ¦"), + locked: false, + bot: false, + created_at: String::from("2018-05-26T13:07:04.000Z"), + note: String::from("dummy-note"), + url: String::from("https://social.alternativebit.fr/users/Ninjatrappeur"), + avatar: String::from("https://social.alternativebit.fr/media/3f9ad2d6f473c954506f404de5a3636905035f32ec9f1f07deca0a72e88ac3e8.blob"), + avatar_static: String::from("https://social.alternativebit.fr/media/3f9ad2d6f473c954506f404de5a3636905035f32ec9f1f07deca0a72e88ac3e8.blob"), + header: String::from("https://social.alternativebit.fr/media/b5f2cf7e-7892-4692-b0c8-157818ad1b87/57CF8FB0FE2143FF1267A90BD3217D8D433FA2381C56E36264352A3EA0F17838.png"), + header_static: String::from("https://social.alternativebit.fr/media/b5f2cf7e-7892-4692-b0c8-157818ad1b87/57CF8FB0FE2143FF1267A90BD3217D8D433FA2381C56E36264352A3EA0F17838.png"), + followers_count: 615, + following_count: 191, + statuses_count: 1297, + } + } + + fn setup_temp_db () -> rusqlite::Connection { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + super::run_migrations(&mut conn).unwrap(); + conn + } + + #[test] + fn new_oauth_client () { + let conn = setup_temp_db(); + let dummy_client = dummy_client(); + super::new_oauth_client(&conn, &dummy_client).unwrap(); + } + + #[test] + fn get_oauth_client () { + let conn = setup_temp_db(); + let dummy_client = dummy_client(); + super::new_oauth_client(&conn, &dummy_client).unwrap(); + assert_eq!(super::get_oauth_client(&conn, &dummy_client.client_id).unwrap().0, dummy_client); + } + + #[test] + fn new_local_user () { + let conn = setup_temp_db(); + let dummy_client = dummy_client(); + let dummy_local_user = dummy_local_user("1"); + super::new_oauth_client(&conn, &dummy_client).unwrap(); + super::new_local_user(&conn, &dummy_local_user, &dummy_client).unwrap(); + } + + #[test] + fn get_local_user_ok () { + let conn = setup_temp_db(); + let dummy_client = dummy_client(); + let dummy_local_user = dummy_local_user("1"); + super::new_oauth_client(&conn, &dummy_client).unwrap(); + super::new_local_user(&conn, &dummy_local_user, &dummy_client).unwrap(); + assert_eq!(super::get_local_user(&conn), Ok(dummy_local_user)); + } + + #[test] + fn get_local_user_fail_many () { + let conn = setup_temp_db(); + let dummy_client = dummy_client(); + let dummy_local_user_1 = dummy_local_user("1"); + let dummy_local_user_2 = dummy_local_user("2"); + super::new_oauth_client(&conn, &dummy_client).unwrap(); + super::new_local_user(&conn, &dummy_local_user_1, &dummy_client).unwrap(); + super::new_local_user(&conn, &dummy_local_user_2, &dummy_client).unwrap(); + assert_eq!(super::get_local_user(&conn), Err(super::GetLocalUserDatabaseError::ManyUsers)); + } + + #[test] + fn get_local_user_fail_none () { + let conn = setup_temp_db(); + assert_eq!(super::get_local_user(&conn), Err(super::GetLocalUserDatabaseError::NoUser)); + } } diff --git a/src/main.rs b/src/main.rs index 938f51f..0e96a5d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ use gtk4 as gtk; use gtk::prelude::*; use gtk::Application; -mod oauth; +mod mastodon; mod ui; mod db; @@ -11,8 +11,8 @@ use ui::widgets::oauth::create_oauth_assistant; fn main() { let conn = db::open_db(); match conn { - Ok(conn) => db::run_migrations(conn), - Err(e) => panic!("Error when running the DB migrations: {}", e), + Ok(mut conn) => db::run_migrations(&mut conn), + Err(e) => panic!("Error when running the DB migrations: {:?}", e), }; let app = Application::builder() .application_id("fr.alternativebit.federatz") diff --git a/src/mastodon/accounts/mod.rs b/src/mastodon/accounts/mod.rs new file mode 100644 index 0000000..fd87e2a --- /dev/null +++ b/src/mastodon/accounts/mod.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; + +use crate::mastodon; + +#[derive(Debug, Deserialize, Serialize, PartialEq)] +pub struct UserAccount { + pub id: String, + pub token: String, + pub username: String, + pub display_name: String, + pub locked: bool, + pub bot: bool, + pub created_at: String, + pub note: String, + pub url: String, + pub avatar: String, + pub avatar_static: String, + pub header: String, + pub header_static: String, + pub followers_count: u32, + pub following_count: u32, + pub statuses_count: u32, +} + +pub fn verify_credentials(instance_fqdn: &str, authorization_token: &str) -> Result { + let resp: Result = ureq::get(&format!("https://{}/api/v1/accounts/verify_credentials", instance_fqdn)) + .set("Authorization", authorization_token) + .call(); + match resp { + Ok(resp) => + resp.into_json().map_err(mastodon::RequestError::JsonError), + Err(ureq::Error::Status(code, response)) => + Err(mastodon::RequestError::HttpError(code, response.status_text().to_string())), + Err(_) => + Err(mastodon::RequestError::HttpError(0, "Transport error".to_string())), + } + +} diff --git a/src/mastodon/mod.rs b/src/mastodon/mod.rs new file mode 100644 index 0000000..82e90f0 --- /dev/null +++ b/src/mastodon/mod.rs @@ -0,0 +1,10 @@ +use std::io::Error; + +pub mod accounts; +pub mod oauth; + +#[derive(Debug)] +pub enum RequestError { + HttpError(u16, String), + JsonError(Error) +} diff --git a/src/oauth.rs b/src/mastodon/oauth/mod.rs similarity index 63% rename from src/oauth.rs rename to src/mastodon/oauth/mod.rs index 8ea1e26..b69318b 100644 --- a/src/oauth.rs +++ b/src/mastodon/oauth/mod.rs @@ -1,36 +1,30 @@ -use std::io::Error; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Deserialize, Clone)] +use crate::mastodon; + +#[derive(Debug, Deserialize, Serialize, PartialEq)] pub struct RegisteredApp { + pub instance_fqdn: String, pub client_id: String, pub client_secret: String, - pub id: String, pub name: String, pub redirect_uri: String, pub website: Option } -#[derive(Debug)] -pub enum RequestError { - HttpError(u16, String), - JsonError(Error) -} - -pub fn register_app(instance_fqdn: &str, app_name: &str) -> Result { +pub fn register_app(instance_fqdn: &str, app_name: &str) -> Result { let resp: Result = ureq::post(&format!("https://{}/api/v1/apps", instance_fqdn)) .send_form( &[("client_name", app_name), ("redirect_uris", "urn:ietf:wg:oauth:2.0:oob"), ("scopes", "read write push")]); - let io_error_to_request_error = |err| RequestError::JsonError(err); match resp { Ok(resp) => - resp.into_json().map_err(io_error_to_request_error), + resp.into_json().map_err(mastodon::RequestError::JsonError), Err(ureq::Error::Status(code, response)) => - Err(RequestError::HttpError(code,response.status_text().to_string())), + Err(mastodon::RequestError::HttpError(code,response.status_text().to_string())), Err(_) => - Err(RequestError::HttpError(0, "Transport error".to_string())) + Err(mastodon::RequestError::HttpError(0, "Transport error".to_string())) } } diff --git a/src/ui/widgets/oauth/mod.rs b/src/ui/widgets/oauth/mod.rs index 204e20d..4e97c7c 100644 --- a/src/ui/widgets/oauth/mod.rs +++ b/src/ui/widgets/oauth/mod.rs @@ -7,7 +7,7 @@ mod page1; mod page2; mod page3; -use crate::oauth; +use crate::mastodon::{accounts, oauth}; pub fn create_oauth_assistant(app: >k::Application) -> gtk::Assistant { let assistant = gtk::Assistant::builder() @@ -32,7 +32,6 @@ pub fn create_oauth_assistant(app: >k::Application) -> gtk::Assistant { let ores = oauth::register_app(&instance_uri, "federatz-0.1"); match ores { Ok(res) => { - println!("{:?}", res); assistant.set_page_complete(&page1, true); assistant.next_page(); let auth_link = oauth::gen_authorize_url(&instance_uri, &res); @@ -45,5 +44,20 @@ pub fn create_oauth_assistant(app: >k::Application) -> gtk::Assistant { } })); + page2.imp().authorization_token_entry.connect_activate( + glib::clone!(@weak page2, @weak page1, @weak assistant => move |_| { + let instance_uri = page1.imp().instance_uri_text_entry.buffer().text(); + let authorization_token = page2.imp().authorization_token_entry.buffer().text(); + match accounts::verify_credentials(&instance_uri, &authorization_token) { + Ok(res) => { + assistant.set_page_complete(&page2, true); + assistant.next_page(); + // TODO: save to DB + }, + Err(err) => println!("Error: {:?}", err), + + } + })); + assistant } diff --git a/src/ui/widgets/oauth/page2/imp.rs b/src/ui/widgets/oauth/page2/imp.rs index 9753b08..fa75da5 100644 --- a/src/ui/widgets/oauth/page2/imp.rs +++ b/src/ui/widgets/oauth/page2/imp.rs @@ -2,7 +2,7 @@ use gtk4 as gtk; use gtk::subclass::prelude::*; use gtk::subclass::window::WindowImpl; use gtk::prelude::*; -use gtk::{glib, CompositeTemplate, Box, Label}; +use gtk::{glib, CompositeTemplate, Box, Label, Entry}; use glib::subclass::InitializingObject; #[derive(Debug, Default, CompositeTemplate)] @@ -10,6 +10,8 @@ use glib::subclass::InitializingObject; pub struct OauthAssistantPage2 { #[template_child] pub authorization_link_label: TemplateChild