diff --git a/Cargo.lock b/Cargo.lock index 35cc886..8137cf1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -400,6 +400,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.3.0" @@ -748,16 +759,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" -[[package]] -name = "colored" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbf2150cce219b664a8a70df7a1f933836724b503f8a413af9365b4dcc4d90b8" -dependencies = [ - "lazy_static", - "windows-sys 0.48.0", -] - [[package]] name = "compact_str" version = "0.8.0" @@ -1035,6 +1036,19 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "env_logger" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + [[package]] name = "envmnt" version = "0.8.4" @@ -1490,6 +1504,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hermit-abi" version = "0.3.9" @@ -1616,6 +1639,15 @@ dependencies = [ "url", ] +[[package]] +name = "humantime" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df004cfca50ef23c36850aaaa59ad52cc70d0e90243c3c7737a4dd32dc7a3c4f" +dependencies = [ + "quick-error 1.2.3", +] + [[package]] name = "hyper" version = "0.14.30" @@ -2274,6 +2306,7 @@ dependencies = [ "once_cell", "open", "pretty_assertions", + "pretty_env_logger", "ratatui", "ratatui-image", "reqwest", @@ -2281,7 +2314,6 @@ dependencies = [ "rusty-hook", "serde", "serde_json", - "simple_logger", "strum", "strum_macros", "throbber-widgets-tui", @@ -2806,6 +2838,16 @@ dependencies = [ "yansi", ] +[[package]] +name = "pretty_env_logger" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "926d36b9553851b8b0005f1275891b392ee4d2d833852c417ed025477350fb9d" +dependencies = [ + "env_logger", + "log", +] + [[package]] name = "proc-macro2" version = "1.0.85" @@ -2843,6 +2885,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-error" version = "2.0.1" @@ -2970,7 +3018,7 @@ dependencies = [ "avif-serialize", "imgref", "loop9", - "quick-error", + "quick-error 2.0.1", "rav1e", "rayon", "rgb", @@ -3363,18 +3411,6 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1de1d4f81173b03af4c0cbed3c898f6bff5b870e4a7f5d6f4057d62a7a4b686e" -[[package]] -name = "simple_logger" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8c5dfa5e08767553704aa0ffd9d9794d527103c736aba9854773851fd7497eb" -dependencies = [ - "colored", - "log", - "time", - "windows-sys 0.48.0", -] - [[package]] name = "siphasher" version = "0.3.11" @@ -3586,6 +3622,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.61" @@ -3644,14 +3689,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", - "itoa", "libc", "num-conv", "num_threads", "powerfmt", "serde", "time-core", - "time-macros", ] [[package]] @@ -3660,16 +3703,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" -[[package]] -name = "time-macros" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tiny-keccak" version = "2.0.2" diff --git a/Cargo.toml b/Cargo.toml index 3f501a5..059caf1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,7 @@ epub-builder = "0.7.4" http = "1.0" keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] } log = { version = "0.4", features = ["std", "serde"] } -simple_logger = "5.0.0" +pretty_env_logger = "0.4" [dev-dependencies] httpmock = "0.7.0-rc.1" diff --git a/src/backend/secrets.rs b/src/backend/secrets.rs index 3307e22..25cb733 100644 --- a/src/backend/secrets.rs +++ b/src/backend/secrets.rs @@ -6,8 +6,38 @@ use std::error::Error; /// Abstraction of the location where secrets will be stored pub trait SecretStorage { fn save_secret>(&mut self, secret_name: T, value: T) -> Result<(), Box>; - fn save_multiple_secrets>(&mut self, values: HashMap) -> Result<(), Box>; - fn get_secret>(&self, _secret_name: T) -> Result, Box> { - Err("not implemented".into()) + + fn save_multiple_secrets>(&mut self, values: HashMap) -> Result<(), Box> { + for (name, value) in values { + self.save_secret(name, value)? + } + Ok(()) + } + + fn remove_multiple_secrets>(&mut self, values: impl Iterator) -> Result<(), Box> { + for name in values { + self.remove_secret(name)? + } + Ok(()) } + + fn get_multiple_secrets>( + &self, + secrets_names: impl Iterator, + ) -> Result, Box> { + let mut secrets_collected: HashMap = HashMap::new(); + + for secrets in secrets_names { + let secret_to_find: String = secrets.into(); + if let Some(secret) = self.get_secret(secret_to_find.clone())? { + secrets_collected.insert(secret_to_find, secret); + } + } + + Ok(secrets_collected) + } + + fn get_secret>(&self, _secret_name: T) -> Result, Box>; + + fn remove_secret>(&mut self, secret_name: T) -> Result<(), Box>; } diff --git a/src/backend/secrets/anilist.rs b/src/backend/secrets/anilist.rs index 53ac2a3..0b89309 100644 --- a/src/backend/secrets/anilist.rs +++ b/src/backend/secrets/anilist.rs @@ -4,12 +4,12 @@ use keyring::Entry; use super::SecretStorage; #[derive(Debug)] -struct AnilistStorage { +pub struct AnilistStorage { service_name: &'static str, } impl AnilistStorage { - fn new() -> Self { + pub fn new() -> Self { Self { service_name: crate_name!(), } @@ -37,13 +37,11 @@ impl SecretStorage for AnilistStorage { } } - fn save_multiple_secrets>( - &mut self, - values: std::collections::HashMap, - ) -> Result<(), Box> { - for (name, value) in values { - self.save_secret(name, value)? - } + fn remove_secret>(&mut self, secret_name: T) -> Result<(), Box> { + let secret = Entry::new(self.service_name, secret_name.as_ref())?; + + secret.delete_credential()?; + Ok(()) } } diff --git a/src/backend/tracker/anilist.rs b/src/backend/tracker/anilist.rs index 5811120..03786dc 100644 --- a/src/backend/tracker/anilist.rs +++ b/src/backend/tracker/anilist.rs @@ -1,4 +1,4 @@ -static BASE_ANILIST_API_URL: &str = "https://graphql.anilist.co"; +pub static BASE_ANILIST_API_URL: &str = "https://graphql.anilist.co"; static REDIRECT_URI: &str = "https://anilist.co/api/v2/oauth/pin"; static GET_ACCESS_TOKEN_URL: &str = "https://anilist.co/api/v2/oauth/token"; //https://anilist.co/api/v2/oauth/authorize?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code" @@ -14,9 +14,10 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use crate::backend::tracker::{MangaToTrack, MangaTracker, MarkAsRead}; +use crate::cli::AnilistTokenChecker; #[derive(Debug, Deserialize, Serialize)] -struct GetMangaByTitleQuery<'a> { +pub struct GetMangaByTitleQuery<'a> { title: &'a str, } @@ -65,7 +66,7 @@ impl<'a> GraphqlBody for GetMangaByTitleQuery<'a> { } #[derive(Debug, Deserialize, Serialize)] -struct MarkMangaAsReadQuery { +pub struct MarkMangaAsReadQuery { id: u32, chapter_count: u32, volume_number: u32, @@ -102,38 +103,83 @@ impl GraphqlBody for MarkMangaAsReadQuery { } #[derive(Debug, Deserialize, Serialize, Default)] -struct GetMangaByTitleResponse { +pub struct GetMangaByTitleResponse { data: GetMangaByTitleData, } #[derive(Debug, Deserialize, Serialize, Default)] -struct GetMangaByTitleData { +pub struct GetMangaByTitleData { #[serde(rename = "Media")] media: GetMangaByTitleMedia, } #[derive(Debug, Deserialize, Serialize, Default)] -struct GetMangaByTitleMedia { +pub struct GetMangaByTitleMedia { id: u32, } +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct GetUserIdQueryResponse { + data: GetUserIdQueryData, +} + +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct GetUserIdQueryData { + #[serde(rename = "User")] + user: UserId, +} + +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct UserId { + id: u32, +} + +#[derive(Debug, Deserialize, Serialize, Default, Clone)] +pub struct GetUserIdBody { + id: String, +} + +impl GetUserIdBody { + pub fn new(id: String) -> Self { + Self { id } + } +} + +impl GraphqlBody for GetUserIdBody { + fn query(&self) -> &'static str { + r#" + query User($id: Int) { + User(id: $id) { + id + } + } + "# + } + + fn variables(&self) -> serde_json::Value { + json!({ + "id" : self.id + }) + } +} + #[derive(Debug)] -struct Anilist { +pub struct Anilist { base_url: Url, - access_token_url: Url, access_token: String, + client_id: String, client: Client, } #[derive(Debug, Clone, Serialize, Deserialize)] -struct GetAnilistAccessTokenBody { +pub struct GetAnilistAccessTokenBody { id: String, secret: String, code: String, } #[derive(Debug, Clone, Serialize, Deserialize)] -struct AnilistAccessTokenResponse { +pub struct AnilistAccessTokenResponse { access_token: String, } @@ -166,7 +212,7 @@ impl From for Body { } impl Anilist { - pub fn new(base_url: Url, access_token_url: Url) -> Self { + pub fn new(base_url: Url) -> Self { let mut default_headers = HeaderMap::new(); default_headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); @@ -181,7 +227,7 @@ impl Anilist { Self { base_url, client, - access_token_url, + client_id: String::default(), access_token: "".to_string(), } } @@ -191,17 +237,29 @@ impl Anilist { self } - async fn request_access_token(&self, body: GetAnilistAccessTokenBody) -> Result> { - let response = self.client.post(self.access_token_url.clone()).body(body).send().await?; + pub fn with_client_id(mut self, client_id: String) -> Self { + self.client_id = client_id; + self + } + + async fn check_credentials_are_valid(&self) -> Result> { + let body = GetUserIdBody::new(self.client_id.clone()); + + let body = body.into_body(); - if response.status() == StatusCode::OK { - let response = dbg!(response); - let access_token: AnilistAccessTokenResponse = response.json().await?; - Ok(access_token.access_token) - } else { - let response = dbg!(response); - Err(format!("could not request anilist access token, more details about the request: \n {:#?} ", response).into()) + let response = self + .client + .post(self.base_url.clone()) + .body(body) + .header(AUTHORIZATION, self.access_token.clone()) + .send() + .await?; + + if response.status() == StatusCode::UNAUTHORIZED || response.status() == StatusCode::BAD_REQUEST { + return Ok(false); } + + Ok(true) } } @@ -250,6 +308,12 @@ impl MangaTracker for Anilist { } } +impl AnilistTokenChecker for Anilist { + async fn verify_token(&self, token: String) -> Result> { + self.check_credentials_are_valid().await + } +} + #[cfg(test)] mod tests { use httpmock::Method::POST; @@ -294,11 +358,34 @@ mod tests { assert_eq!(expected.get("variables"), as_json.get("variables")); } + #[test] + fn get_user_id_query_is_built_as_expected() { + let expected = json!({ + "query" : r#" + query User($id: Int) { + User(id: $id) { + id + } + } + "#, + "variables" : { + "id" : 123.to_string() + } + }); + + let query = GetUserIdBody::new("123".to_string()); + + let as_json = query.into_json(); + + assert_str_eq!(expected.get("query").unwrap().remove_whitespace(), as_json.get("query").unwrap().remove_whitespace()); + assert_eq!(expected.get("variables"), as_json.get("variables")); + } + #[tokio::test] async fn anilist_searches_a_manga_by_its_title() { let server = MockServer::start_async().await; let base_url: Url = server.base_url().parse().unwrap(); - let anilist = Anilist::new(base_url.clone(), base_url); + let anilist = Anilist::new(base_url.clone()); let expected_manga = MangaToTrack { id: "123123".to_string(), @@ -336,7 +423,7 @@ mod tests { async fn anilist_searches_a_manga_by_its_title_and_returns_none_if_not_found() { let server = MockServer::start_async().await; let base_url: Url = server.base_url().parse().unwrap(); - let anilist = Anilist::new(base_url.clone(), base_url); + let anilist = Anilist::new(base_url.clone()); let expected_body_sent = GetMangaByTitleQuery::new("some_title").into_json(); @@ -399,58 +486,33 @@ mod tests { } #[tokio::test] - async fn anilist_gets_authorization_token() { + async fn anilist_checks_its_access_token_is_valid() { let server = MockServer::start_async().await; + let token = Uuid::new_v4().to_string(); + let user_id = 123.to_string(); + let base_url: Url = server.base_url().parse().unwrap(); - let anilist = Anilist::new(base_url.clone(), base_url); + let anilist = Anilist::new(base_url).with_token(token.clone()).with_client_id(user_id.clone()); //let mut anilist = Anilist::new(BASE_ANILIST_API_URL.parse().unwrap(), GET_ACCESS_TOKEN_URL.parse().unwrap()); - let expected_body_sent = GetAnilistAccessTokenBody::new("22248", "some_secret", "some_code"); + let expected_body_sent = GetUserIdBody::new(user_id.clone()); let request = server .mock_async(|when, then| { - when.method(POST).json_body_obj(&expected_body_sent.clone().into_json()); - then.status(200).json_body_obj(&AnilistAccessTokenResponse { - access_token: token.clone(), - }); + when.method(POST) + .header("Authorization", token) + .json_body_obj(&expected_body_sent.clone().into_json()); + then.status(200); }) .await; - let token_requested = anilist.request_access_token(expected_body_sent).await.expect("should not fail"); + let is_valid = anilist.check_credentials_are_valid().await.expect("should not fail"); request.assert_async().await; - assert_eq!(token_requested, token); - } - - //#[tokio::test] - //async fn anilist_checks_its_access_token_is_valid() { - // let server = MockServer::start_async().await; - // - // let token = Uuid::new_v4().to_string(); - // - // let base_url: Url = server.base_url().parse().unwrap(); - // let anilist = Anilist::new(base_url.clone(), base_url); - // //let mut anilist = Anilist::new(BASE_ANILIST_API_URL.parse().unwrap(), GET_ACCESS_TOKEN_URL.parse().unwrap()); - // - // let expected_body_sent = GetAnilistAccessTokenBody::new("22248", "some_secret", "some_code"); - // - // let request = server - // .mock_async(|when, then| { - // when.method(POST).json_body_obj(&expected_body_sent.clone().into_json()); - // then.status(200).json_body_obj(&AnilistAccessTokenResponse { - // access_token: token.clone(), - // }); - // }) - // .await; - // - // anilist.request_access_token(expected_body_sent).await.expect("should not fail"); - // - // request.assert_async().await; - // - // assert_eq!(token, anilist.access_token); - //} + assert!(is_valid); + } #[tokio::test] async fn anilist_marks_manga_as_reading_with_chapter_and_volume_count() { @@ -458,7 +520,7 @@ mod tests { let access_token = Uuid::new_v4().to_string(); let base_url: Url = server.base_url().parse().unwrap(); - let anilist = Anilist::new(base_url.clone(), base_url).with_token(access_token.clone()); + let anilist = Anilist::new(base_url.clone()).with_token(access_token.clone()); //let anilist = Anilist::new(BASE_ANILIST_API_URL.parse().unwrap(), base_url).with_token(access_token.clone()); let manga_id = 86635; diff --git a/src/cli.rs b/src/cli.rs index 02d2bb4..4b5b7bd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,15 +1,19 @@ use std::collections::HashMap; use std::error::Error; +use std::future::Future; use std::io::BufRead; +use std::process::exit; use clap::{crate_version, Parser, Subcommand}; use strum::{Display, IntoEnumIterator}; use crate::backend::filter::Languages; +use crate::backend::secrets::anilist::AnilistStorage; use crate::backend::secrets::SecretStorage; +use crate::backend::tracker::anilist::{self, BASE_ANILIST_API_URL}; use crate::backend::APP_DATA_DIR; use crate::global::PREFERRED_LANGUAGE; -use crate::logger::ILogger; +use crate::logger::{self, ILogger, Logger}; fn read_input(mut input_reader: impl BufRead, logger: &impl ILogger, message: &str) -> Result> { logger.inform(message); @@ -18,21 +22,22 @@ fn read_input(mut input_reader: impl BufRead, logger: &impl ILogger, message: &s Ok(input_provided) } -#[derive(Debug)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AnilistStatus { Setup, MissigCredentials, + InvalidAccessToken, } -#[derive(Subcommand)] +#[derive(Subcommand, Clone, Copy)] pub enum AnilistCommand { /// setup anilist client to be able to sync reading progress Init, /// check wheter or not anilist is setup correctly - Status, + Check, } -#[derive(Subcommand)] +#[derive(Subcommand, Clone)] pub enum Commands { Lang { #[arg(short, long)] @@ -47,7 +52,7 @@ pub enum Commands { }, } -#[derive(Debug, Display)] +#[derive(Debug, Display, Clone, Copy)] enum AnilistCredentials { #[strum(to_string = "anilist_client_id")] ClientId, @@ -65,7 +70,7 @@ impl From for String { } } -#[derive(Parser)] +#[derive(Parser, Clone)] #[command(version = crate_version!())] pub struct CliArgs { #[command(subcommand)] @@ -75,11 +80,16 @@ pub struct CliArgs { } pub struct AnilistCredentialsProvided<'a> { - pub code: &'a str, - pub secret: &'a str, + pub access_token: &'a str, pub client_id: &'a str, } +#[derive(Debug, Clone)] +pub struct Credentials { + pub access_token: String, + pub client_id: String, +} + impl CliArgs { pub fn new() -> Self { Self { @@ -100,34 +110,30 @@ impl CliArgs { }); } - fn save_anilist_credentials( - &self, - credentials: AnilistCredentialsProvided<'_>, - storage: &mut impl SecretStorage, - ) -> Result<(), Box> { - storage.save_multiple_secrets(HashMap::from([ - (AnilistCredentials::ClientId.to_string(), credentials.client_id.to_string()), - (AnilistCredentials::Code.to_string(), credentials.code.to_string()), - (AnilistCredentials::Secret.to_string(), credentials.secret.to_string()), - ]))?; - - Ok(()) - } - pub fn init_anilist( - self, + &self, mut input_reader: impl BufRead, storage: &mut impl SecretStorage, logger: impl ILogger, ) -> Result<(), Box> { - let client_id = read_input(&mut input_reader, &logger, "Provide the client id")?; - let secret = read_input(&mut input_reader, &logger, "Provide the secret")?; - let code = read_input(&mut input_reader, &logger, "Provide the code")?; + let client_id = read_input(&mut input_reader, &logger, "Provide your client id")?; + let client_id = client_id.trim(); + + let anilist_retrieve_access_token_url = + format!("https://anilist.co/api/v2/oauth/authorize?client_id={client_id}&response_type=token"); + + let open_in_browser_message = format!("Opening {anilist_retrieve_access_token_url} to get the access token "); + + logger.inform(open_in_browser_message); + + open::that(anilist_retrieve_access_token_url)?; + + let access_token = read_input(&mut input_reader, &logger, "Enter the access token")?; + let access_token = access_token.trim(); self.save_anilist_credentials( AnilistCredentialsProvided { - code: &code, - secret: &secret, + access_token: &access_token, client_id: &client_id, }, storage, @@ -138,35 +144,87 @@ impl CliArgs { Ok(()) } - fn anilist_status(&self, storage: &impl SecretStorage) -> Result> { - let credentials = [ - storage.get_secret(AnilistCredentials::Code)?, - storage.get_secret(AnilistCredentials::Secret)?, - storage.get_secret(AnilistCredentials::ClientId)?, - ]; + /// This method must check if both client_id and access_token are stored and they are not empty + fn anilist_check_credentials_stored(&self, storage: &impl SecretStorage) -> Result, Box> { + let credentials = + storage.get_multiple_secrets([AnilistCredentials::ClientId, AnilistCredentials::AccessToken].into_iter())?; - for credential in credentials { - if credential.is_none() { - return Ok(AnilistStatus::MissigCredentials); - } + let client_id = credentials.get(&AnilistCredentials::ClientId.to_string()).cloned(); + let access_token = credentials.get(&AnilistCredentials::AccessToken.to_string()).cloned(); + + match client_id.zip(access_token) { + Some((id, token)) => { + if id.is_empty() || token.is_empty() { + return Ok(None); + } + + Ok(Some(Credentials { + access_token: token, + client_id: id.parse().unwrap(), + })) + }, + None => Ok(None), } + } - Ok(AnilistStatus::Setup) + fn save_anilist_credentials( + &self, + credentials: AnilistCredentialsProvided<'_>, + storage: &mut impl SecretStorage, + ) -> Result<(), Box> { + storage.save_multiple_secrets(HashMap::from([ + (AnilistCredentials::AccessToken.to_string(), credentials.access_token.to_string()), + (AnilistCredentials::ClientId.to_string(), credentials.client_id.to_string()), + ]))?; + Ok(()) } - pub fn proccess_args(self) -> Result<(), Box> { + async fn check_anilist_token(&self, token_checker: &impl AnilistTokenChecker, token: String) -> Result> { + token_checker.verify_token(token).await + } + + async fn check_anilist_status(&self, logger: &impl ILogger) -> Result<(), Box> { + let storage = AnilistStorage::new(); + logger.inform("Checking client id and access token are stored"); + + let credentials_are_stored = self.anilist_check_credentials_stored(&storage)?; + if credentials_are_stored.is_none() { + logger.warn("The client id or the access token are empty, it is recommended you run `manga-tui anilist reset`"); + exit(0) + } + + let credentials_are_stored = credentials_are_stored.unwrap(); + logger.inform("Checking your access token is valid, this may take a while"); + + let anilist = anilist::Anilist::new(BASE_ANILIST_API_URL.parse().unwrap()) + .with_token(credentials_are_stored.access_token.clone()) + .with_client_id(credentials_are_stored.client_id); + + let access_token_is_valid = self.check_anilist_token(&anilist, credentials_are_stored.access_token).await?; + + if access_token_is_valid { + logger.inform("Everything is setup correctly :D"); + } else { + logger.error("The anilist access token is not valid, please run `manga-tui anilist reset`".into()); + exit(0) + } + + Ok(()) + } + + pub async fn proccess_args(self) -> Result<(), Box> { if self.data_dir { let app_dir = APP_DATA_DIR.as_ref().unwrap(); println!("{}", app_dir.to_str().unwrap()); return Ok(()); } - match self.command { + match &self.command { Some(command) => match command { Commands::Lang { print, set } => { - if print { + if *print { Self::print_available_languages(); - std::process::exit(1) + exit(0) } match set { @@ -180,7 +238,7 @@ impl CliArgs { env!("CARGO_BIN_NAME") ); - std::process::exit(1) + exit(0) } PREFERRED_LANGUAGE.set(try_lang.unwrap()).unwrap(); @@ -192,7 +250,22 @@ impl CliArgs { Ok(()) }, - Commands::Anilist { command } => todo!(), + Commands::Anilist { command } => match command { + AnilistCommand::Init => { + let mut storage = AnilistStorage::new(); + self.init_anilist(std::io::stdin().lock(), &mut storage, Logger)?; + exit(0) + }, + AnilistCommand::Check => { + let logger = Logger; + if let Err(e) = self.check_anilist_status(&logger).await { + logger.error(e); + exit(1); + } else { + exit(0) + } + }, + }, }, None => { PREFERRED_LANGUAGE.set(Languages::default()).unwrap(); @@ -202,6 +275,10 @@ impl CliArgs { } } +pub trait AnilistTokenChecker { + fn verify_token(&self, token: String) -> impl Future>> + Send; +} + #[cfg(test)] mod tests { use std::collections::HashMap; @@ -224,78 +301,125 @@ mod tests { Ok(()) } - fn save_multiple_secrets>(&mut self, values: HashMap) -> Result<(), Box> { - for (key, name) in values { - self.save_secret(key, name)?; - } - Ok(()) - } - fn get_secret>(&self, secret_name: T) -> Result, Box> { Ok(self.secrets_stored.get(&secret_name.into()).cloned()) } + + fn remove_secret>(&mut self, secret_name: T) -> Result<(), Box> { + match self.secrets_stored.remove(secret_name.as_ref()) { + Some(_val) => Ok(()), + None => Err("secret did not exist".into()), + } + } } #[test] - fn it_saves_anilist_account_credentials() { + fn it_saves_anilist_access_token_and_user_id() { let cli = CliArgs::new(); - let client_id_provided = Uuid::new_v4().to_string(); - let secret_provided = Uuid::new_v4().to_string(); - let code_provided = Uuid::new_v4().to_string(); + let acess_token = Uuid::new_v4().to_string(); + let user_id = "120398".to_string(); let mut storage = MockStorage::default(); cli.save_anilist_credentials( AnilistCredentialsProvided { - code: &code_provided, - secret: &secret_provided, - client_id: &client_id_provided, + access_token: &acess_token, + client_id: &user_id, }, &mut storage, ) - .expect("should not panic"); + .expect("should not fail"); - let (name, id) = storage.secrets_stored.get_key_value("anilist_client_id").unwrap(); - let (key_name2, secret) = storage.secrets_stored.get_key_value("anilist_secret").unwrap(); - let (key_name3, code) = storage.secrets_stored.get_key_value("anilist_code").unwrap(); + let (secret_name, token) = storage.secrets_stored.get_key_value("anilist_access_token").unwrap(); - assert_eq!("anilist_client_id", name); - assert_eq!(client_id_provided, *id); + assert_eq!("anilist_access_token", secret_name); + assert_eq!(acess_token, *token); - assert_eq!("anilist_secret", key_name2); - assert_eq!(secret_provided, *secret); + let (secret_name, value) = storage.secrets_stored.get_key_value("anilist_client_id").unwrap(); - assert_eq!("anilist_code", key_name3); - assert_eq!(code_provided, *code); + assert_eq!("anilist_client_id", secret_name); + assert_eq!(user_id.parse::().unwrap(), value.parse::().unwrap()); } #[test] - fn it_checks_anilist_is_setup() { + fn it_checks_anilist_credentials_are_stored() -> Result<(), Box> { let cli = CliArgs::new(); let mut storage = MockStorage::default(); - let not_setup = cli.anilist_status(&storage).unwrap(); + let not_stored = cli.anilist_check_credentials_stored(&storage)?; - assert!(!matches!(not_setup, AnilistStatus::Setup)); + assert!(not_stored.is_none()); - let client_id_provided = Uuid::new_v4().to_string(); - let secret_provided = Uuid::new_v4().to_string(); - let code_provided = Uuid::new_v4().to_string(); + storage.secrets_stored.insert(AnilistCredentials::AccessToken.to_string(), "".to_string()); - // after storing the credentials it should have a ok status - cli.save_anilist_credentials( - AnilistCredentialsProvided { - code: &code_provided, - secret: &secret_provided, - client_id: &client_id_provided, - }, - &mut storage, - ) - .expect("should not panic"); + storage.secrets_stored.insert(AnilistCredentials::ClientId.to_string(), "".to_string()); + + let stored_but_empty = cli.anilist_check_credentials_stored(&storage)?; + + assert!(stored_but_empty.is_none()); - let is_setup = cli.anilist_status(&storage).unwrap(); + storage + .secrets_stored + .insert(AnilistCredentials::AccessToken.to_string(), "some_access_token".to_string()); - assert!(matches!(is_setup, AnilistStatus::Setup)); + storage + .secrets_stored + .insert(AnilistCredentials::ClientId.to_string(), "some_client_id".to_string()); + + let stored = cli.anilist_check_credentials_stored(&storage)?; + + assert!(stored.is_some_and(|credentials| credentials.access_token == "some_access_token")); + + Ok(()) + } + + #[derive(Debug)] + struct AnilistCheckerTest { + should_fail: bool, + invalid_token: bool, + } + + impl AnilistCheckerTest { + fn succesful() -> Self { + Self { + should_fail: false, + invalid_token: false, + } + } + + fn failing() -> Self { + Self { + should_fail: true, + invalid_token: true, + } + } + } + impl AnilistTokenChecker for AnilistCheckerTest { + async fn verify_token(&self, _token: String) -> Result> { + if self.invalid_token { + return Ok(false); + } + + Ok(true) + } + } + + #[tokio::test] + async fn it_checks_acess_token_is_valid() -> Result<(), Box> { + let cli = CliArgs::new(); + + let anilist_checker = AnilistCheckerTest::succesful(); + + let token_is_valid = cli.check_anilist_token(&anilist_checker, "some_token".to_string()).await?; + + assert!(token_is_valid); + + let anilist_checker = AnilistCheckerTest::failing(); + + let token_is_valid = cli.check_anilist_token(&anilist_checker, "some_token".to_string()).await?; + + assert!(!token_is_valid); + Ok(()) } } diff --git a/src/main.rs b/src/main.rs index 58676f8..3b5bc66 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,10 @@ #![allow(dead_code)] #![allow(deprecated)] -use backend::fetch::ApiClient; use clap::Parser; +use http::StatusCode; +use log::LevelFilter; use ratatui::backend::CrosstermBackend; -use reqwest::StatusCode; use self::backend::build_data_dir; use self::backend::database::Database; @@ -26,10 +26,14 @@ mod view; #[tokio::main(flavor = "multi_thread", worker_threads = 7)] async fn main() -> Result<(), Box> { - simple_logger::init()?; + pretty_env_logger::formatted_builder() + .format_module_path(false) + .filter_level(LevelFilter::Info) + .init(); + let cli_args = CliArgs::parse(); - cli_args.proccess_args()?; + cli_args.proccess_args().await?; match build_data_dir() { Ok(_) => {}, @@ -78,5 +82,6 @@ async fn main() -> Result<(), Box> { init()?; run_app(CrosstermBackend::new(std::io::stdout()), MangadexClient::global().clone()).await?; restore()?; + Ok(()) }