diff --git a/.gitignore b/.gitignore index 088ba6ba..22d35163 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk + + +# Added by cargo + +/target diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..13566b81 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/cli.iml b/.idea/cli.iml new file mode 100644 index 00000000..d900b784 --- /dev/null +++ b/.idea/cli.iml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 00000000..cff13543 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 00000000..105ce2da --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 00000000..dd602185 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..6fcc0dcf --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/sonarlint/issuestore/index.pb b/.idea/sonarlint/issuestore/index.pb new file mode 100644 index 00000000..e69de29b diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..94a25f7f --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..f11b327d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "cli" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "screenly" +path = "src/main.rs" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1.0.65" +clap = { version = "4.0.17", features = ["derive"] } +envtestkit = "1.1.2" +log = { version = "0.4.17", features = ["release_max_level_info", "max_level_trace"] } +reqwest = { version = "0.11.12", features = ["json", "blocking"] } +simple_logger = "3.0.0" +tempdir = "0.3.7" +thiserror = "1.0.37" + +[dev-dependencies] +httpmock = "0.6" diff --git a/src/authentication.rs b/src/authentication.rs new file mode 100644 index 00000000..59e6c45e --- /dev/null +++ b/src/authentication.rs @@ -0,0 +1,149 @@ +use reqwest::header; +use std::{env, fs}; +use thiserror::Error; + +const API_BASE_URL: &str = "https://api.screenlyapp.com/api"; + +pub struct Config { + url: String, +} + +#[derive(Error, Debug)] +pub enum AuthenticationError { + #[error("wrong credentials error")] + WrongCredentialsError, + #[error("request error")] + RequestError(#[from] reqwest::Error), + #[error("i/o error")] + IoError(#[from] std::io::Error), + #[error("env error")] + EnvError(#[from] env::VarError), + #[error("unknown error")] + Unknown, +} + +pub struct Authentication { + config: Config, +} + +impl Config { + pub fn default() -> Self { + Self { + url: API_BASE_URL.to_string(), + } + } + + #[cfg(test)] + pub fn new(url: String) -> Self { + Self { + url: url.to_string(), + } + } +} + +impl Authentication { + pub fn new() -> Self { + Self { + config: Config::default(), + } + } + + #[cfg(test)] + pub fn new_with_config(config: Config) -> Self { + Self { config } + } + + pub fn verify_and_store_token(&self, token: &str) -> anyhow::Result<(), AuthenticationError> { + self.verify_token(token)?; + + match std::env::var("HOME") { + Ok(home) => { + fs::write(home + "/.screenly", &token)?; + Ok(()) + } + Err(e) => Err(AuthenticationError::EnvError(e)), + } + } + + fn verify_token(&self, token: &str) -> anyhow::Result<(), AuthenticationError> { + // Using uuid of non existing playlist. If we get 404 it means we authenticated successfully. + let url = self.config.url.clone() + "/v3/groups/11CF9Z3GZR0005XXKH00F8V20R/"; + let secret = "Token ".to_owned() + token; + let client = reqwest::blocking::Client::builder().build()?; + + let res = client + .get(url) + .header(header::AUTHORIZATION, &secret) + .send()?; + + return match res.status().as_u16() { + 401 => Err(AuthenticationError::WrongCredentialsError), + 404 => Ok(()), + _ => Err(AuthenticationError::Unknown), + }; + } +} + +#[cfg(test)] +mod tests { + use std::ffi::OsString; + use crate::authentication::Config; + use crate::Authentication; + use httpmock::{Method::GET, MockServer}; + use simple_logger::SimpleLogger; + + use std::fs; + use tempdir::TempDir; + + use envtestkit::lock::lock_test; + use envtestkit::set_env; + + #[test] + fn test_verify_and_store_token_when_token_is_valid() { + SimpleLogger::new() + .with_level(log::LevelFilter::Debug) + .init() + .unwrap(); + let tmp_dir = TempDir::new("test").unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + let mock_server = MockServer::start(); + mock_server.mock(|when, then| { + when.method(GET) + .path("/v3/groups/11CF9Z3GZR0005XXKH00F8V20R/"); + then.status(404); + }); + + let config = Config::new(mock_server.base_url()); + let authentication = Authentication::new_with_config(config); + assert!(authentication + .verify_and_store_token("correct_token") + .is_ok()); + let path = tmp_dir.path().join(".screenly"); + assert!(path.exists()); + let contents = fs::read_to_string(path).unwrap(); + assert!(contents.eq("correct_token")); + tmp_dir.close().unwrap(); + } + + #[test] + fn test_verify_and_store_token_when_token_is_invalid() { + let tmp_dir = TempDir::new("invalid").unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + let mock_server = MockServer::start(); + mock_server.mock(|when, then| { + when.method(GET) + .path("/v3/groups/11CF9Z3GZR0005XXKH00F8V20R/"); + then.status(401); + }); + + let config = Config::new(mock_server.base_url()); + let authentication = Authentication::new_with_config(config); + assert!(authentication + .verify_and_store_token("wrong_token") + .is_err()); + let path = tmp_dir.path().join(".screenly"); + assert!(!path.exists()); + } +} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 00000000..384daf04 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,15 @@ +use crate::Authentication; + +pub struct ScreenCommand { + authentication: Authentication, +} + +impl ScreenCommand { + fn new(authentication: Authentication) -> Self { + Self { authentication } + } + + fn list() {} + + fn get(_id: &str) {} +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 00000000..9b84d2a0 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,68 @@ +mod authentication; +mod commands; + +use crate::authentication::{Authentication, AuthenticationError}; +use clap::{command, Parser, Subcommand}; +use log::warn; +use simple_logger::SimpleLogger; + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +#[command(propagate_version = true)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Logins with the token and stores it for further use if it's valid + Login { token: String }, + /// Screen related commands + #[command(subcommand)] + Screen(ScreenCommands), +} + +#[derive(Subcommand, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum ScreenCommands { + /// Lists your screens + List, + /// Gets a single screen by id + Get { id: String }, +} + +fn main() { + SimpleLogger::new() + .with_level(log::LevelFilter::Info) + .init() + .unwrap(); + let cli = Cli::parse(); + let authentication = Authentication::new(); + match &cli.command { + Commands::Login { token } => match authentication.verify_and_store_token(token) { + Ok(()) => { + println!("Login credentials have been saved."); + std::process::exit(0); + } + + Err(e) => match e { + AuthenticationError::WrongCredentialsError => { + println!("Token verification failed."); + std::process::exit(1); + } + _ => { + println!("Unknown error"); + std::process::exit(2); + } + }, + }, + Commands::Screen(command) => match command { + ScreenCommands::List => { + warn!("List: to be implemented"); + } + ScreenCommands::Get { id: _ } => { + warn!("Get: to be implemented"); + } + }, + } +}