diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 00000000..a55e7a17 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/sonarlint/issuestore/6/8/68bbba69fbc9469ed10568ad6d2fda4fa93abffb b/.idea/sonarlint/issuestore/6/8/68bbba69fbc9469ed10568ad6d2fda4fa93abffb new file mode 100644 index 00000000..e69de29b diff --git a/Cargo.toml b/Cargo.toml index 93616eed..fbccfa7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ path = "src/main.rs" [dependencies] openssl = { version = '0.10', features = ["vendored"] } anyhow = "1.0.65" -clap = { version = "4.0.17", features = ["derive"] } +clap = { version = "4.0.17", features = ["derive", "cargo"] } envtestkit = "1.1.2" humantime = "2.1.0" indicatif = "0.17.2" @@ -26,6 +26,7 @@ tempdir = "0.3.7" term = "0.7.0" thiserror = "1.0.37" dirs = "4.0.0" +http-auth-basic = "0.3.3" [dev-dependencies] httpmock = "0.6" diff --git a/src/authentication.rs b/src/authentication.rs index 004ed1a6..fea7bb0b 100644 --- a/src/authentication.rs +++ b/src/authentication.rs @@ -13,19 +13,19 @@ pub struct Config { #[derive(Error, Debug)] pub enum AuthenticationError { #[error("wrong credentials error")] - WrongCredentialsError, + WrongCredentials, #[error("no credentials error")] - NoCredentialsError, + NoCredentials, #[error("request error")] - RequestError(#[from] reqwest::Error), + Request(#[from] reqwest::Error), #[error("i/o error")] - IoError(#[from] std::io::Error), + Io(#[from] std::io::Error), #[error("env error")] - EnvError(#[from] env::VarError), + Env(#[from] env::VarError), #[error("missing home dir error")] - MissingHomeDirError(), + MissingHomeDir(), #[error("invalid header error")] - InvalidHeaderError(#[from] InvalidHeaderValue), + InvalidHeader(#[from] InvalidHeaderValue), #[error("unknown error")] Unknown, } @@ -61,9 +61,9 @@ impl Authentication { match dirs::home_dir() { Some(path) => { - fs::read_to_string(path.join(".screenly")).map_err(AuthenticationError::IoError) + fs::read_to_string(path.join(".screenly")).map_err(AuthenticationError::Io) } - None => Err(AuthenticationError::NoCredentialsError), + None => Err(AuthenticationError::NoCredentials), } } @@ -80,7 +80,7 @@ impl Authentication { fs::write(home.join(".screenly"), token)?; Ok(()) } - None => Err(AuthenticationError::MissingHomeDirError()), + None => Err(AuthenticationError::MissingHomeDir()), } } @@ -96,7 +96,7 @@ impl Authentication { .send()?; match res.status().as_u16() { - 401 => Err(AuthenticationError::WrongCredentialsError), + 401 => Err(AuthenticationError::WrongCredentials), 404 => Ok(()), _ => Err(AuthenticationError::Unknown), } @@ -115,7 +115,7 @@ impl Authentication { reqwest::blocking::Client::builder() .default_headers(default_headers) .build() - .map_err(AuthenticationError::RequestError) + .map_err(AuthenticationError::Request) } } @@ -130,8 +130,7 @@ mod tests { use simple_logger::SimpleLogger; use tempdir::TempDir; - use crate::authentication::Config; - use crate::Authentication; + use super::*; #[test] fn test_verify_and_store_token_when_token_is_valid() { @@ -184,7 +183,7 @@ mod tests { } #[test] - fn test_read_token_when_token_is_overriden_with_env_variable_correct_token_is_returned() { + fn test_read_token_when_token_is_overridden_with_env_variable_correct_token_is_returned() { let tmp_dir = TempDir::new("test").unwrap(); let _lock = lock_test(); let _token = set_env(OsString::from("API_TOKEN"), "env_token"); diff --git a/src/commands.rs b/src/commands.rs index 01a903a4..c8de4329 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -10,6 +10,7 @@ use thiserror::Error; use prettytable::{row, Table}; use reqwest::header::{HeaderMap, InvalidHeaderValue}; +use serde_json::json; pub enum OutputType { HumanReadable, @@ -23,17 +24,17 @@ pub trait Formatter { #[derive(Error, Debug)] pub enum CommandError { #[error("auth error")] - AuthenticationError(#[from] AuthenticationError), + Authentication(#[from] AuthenticationError), #[error("request error")] - RequestError(#[from] reqwest::Error), + Request(#[from] reqwest::Error), #[error("parse error")] - ParseError(#[from] serde_json::Error), + Parse(#[from] serde_json::Error), #[error("unknown error #[0]")] WrongResponseStatus(u16), #[error("Required field is missing in the response")] MissingField, #[error("I/O error #[0]")] - IoError(#[from] std::io::Error), + Io(#[from] std::io::Error), #[error("Invalid header value")] InvalidHeaderValue(#[from] InvalidHeaderValue), } @@ -187,6 +188,31 @@ fn delete(authentication: &Authentication, endpoint: &str) -> anyhow::Result<(), Ok(()) } +fn patch( + authentication: &Authentication, + endpoint: &str, + payload: &serde_json::Value, +) -> anyhow::Result<(), CommandError> { + let url = format!("{}/{}", &authentication.config.url, endpoint); + let mut headers = HeaderMap::new(); + headers.insert("Prefer", "return=representation".parse()?); + + let response = authentication + .build_client()? + .patch(url) + .json(&payload) + .headers(headers) + .send()?; + + if response.status().as_u16() != 200 { + return Err(CommandError::WrongResponseStatus( + response.status().as_u16(), + )); + } + + Ok(()) +} + impl ScreenCommand { pub fn new(authentication: Authentication) -> Self { Self { authentication } @@ -256,7 +282,7 @@ impl AssetCommand { &self, url: &str, headers: &HeaderMap, - payload: &HashMap<&str, &String>, + payload: &HashMap<&str, &str>, ) -> anyhow::Result { let response = self .authentication @@ -274,7 +300,7 @@ impl AssetCommand { Ok(Assets::new(serde_json::from_str(&response.text()?)?)) } - pub fn add(&self, path: String, title: String) -> anyhow::Result { + pub fn add(&self, path: &str, title: &str) -> anyhow::Result { let url = format!("{}/v4/assets", &self.authentication.config.url); let mut headers = HeaderMap::new(); @@ -282,8 +308,8 @@ impl AssetCommand { if path.starts_with("http://") || path.starts_with("https://") { let mut payload = HashMap::new(); - payload.insert("title", &title); - payload.insert("source_url", &path); + payload.insert("title", title); + payload.insert("source_url", path); return self.add_web_asset(&url, &headers, &payload); } @@ -299,7 +325,7 @@ impl AssetCommand { let part = reqwest::blocking::multipart::Part::reader(pb.wrap_read(file)).file_name("file"); let form = reqwest::blocking::multipart::Form::new() - .text("title", title) + .text("title", title.to_owned()) .part("file", part); let response = self @@ -318,6 +344,25 @@ impl AssetCommand { Ok(Assets::new(serde_json::from_str(&response.text()?)?)) } + pub fn set_web_asset_headers( + &self, + id: &str, + headers: Vec<(String, String)>, + ) -> anyhow::Result<(), CommandError> { + let endpoint = format!("v4/assets?id=eq.{}", id); + let map: HashMap<_, _> = headers.into_iter().collect(); + patch(&self.authentication, &endpoint, &json!({ "headers": map })) + } + + pub fn inject_js(&self, id: &str, js_code: &str) -> anyhow::Result<(), CommandError> { + let endpoint = format!("v4/assets?id=eq.{}", id); + patch( + &self.authentication, + &endpoint, + &json!({ "js-injection": js_code }), + ) + } + pub fn delete(&self, id: &str) -> anyhow::Result<(), CommandError> { let endpoint = format!("v4/assets?id=eq.{}", id); delete(&self.authentication, &endpoint) @@ -326,17 +371,16 @@ impl AssetCommand { #[cfg(test)] mod tests { - use crate::authentication::Config; - use crate::{Authentication, Formatter, OutputType}; + use super::*; use httpmock::{Method::GET, MockServer}; use envtestkit::lock::lock_test; use envtestkit::set_env; - use httpmock::Method::{DELETE, POST}; + use httpmock::Method::{DELETE, PATCH, POST}; use std::ffi::OsString; use std::fs; - use crate::commands::{AssetCommand, Assets, ScreenCommand, Screens}; + use crate::authentication::Config; use serde_json::{json, Value}; use tempdir::TempDir; @@ -346,23 +390,26 @@ mod tests { let _lock = lock_test(); let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); fs::write(tmp_dir.path().join(".screenly").to_str().unwrap(), "token").unwrap(); + + let screens = serde_json::from_str::("[{\"id\":\"017a5104-524b-33d8-8026-9087b59e7eb5\",\"team_id\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"created_at\":\"2021-06-28T05:07:55+00:00\",\"name\":\"Renat's integrated wired NM\",\"is_enabled\":true,\"coords\":[55.22931, 48.90429],\"last_ping\":\"2021-08-25T06:17:20.728+00:00\",\"last_ip\":null,\"local_ip\":\"192.168.1.146\",\"mac\":\"b8:27:eb:d6:83:6f\",\"last_screenshot_time\":\"2021-08-25T06:09:04.399+00:00\",\"uptime\":\"230728.38\",\"load_avg\":\"0.14\",\"signal_strength\":null,\"interface\":\"eth0\",\"debug\":false,\"location\":\"Kamsko-Ust'inskiy rayon, Russia\",\"team\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"timezone\":\"Europe/Moscow\",\"type\":\"hardware\",\"hostname\":\"srly-4shnfrdc5cd2p0p\",\"ws_open\":false,\"status\":\"Offline\",\"last_screenshot\":\"https://us-assets.screenlyapp.com/01CD1W50NR000A28F31W83B1TY/screenshots/01F98G8MJB6FC809MGGYTSWZNN/5267668e6db35498e61b83d4c702dbe8\",\"in_sync\":false,\"software_version\":\"Screenly 2 Player\",\"hardware_version\":\"Raspberry Pi 3B\",\"config\":{\"hdmi_mode\": 34, \"hdmi_boost\": 2, \"hdmi_drive\": 0, \"hdmi_group\": 0, \"verify_ssl\": true, \"audio_output\": \"hdmi\", \"hdmi_timings\": \"\", \"overscan_top\": 0, \"overscan_left\": 0, \"use_composite\": false, \"display_rotate\": 0, \"overscan_right\": 0, \"overscan_scale\": 0, \"overscan_bottom\": 0, \"disable_overscan\": 0, \"shuffle_playlist\": false, \"framebuffer_width\": 0, \"use_composite_pal\": false, \"framebuffer_height\": 0, \"hdmi_force_hotplug\": true, \"use_composite_ntsc\": false, \"hdmi_pixel_encoding\": 0, \"play_history_enabled\": false}}]").unwrap(); + let mock_server = MockServer::start(); mock_server.mock(|when, then| { when.method(GET) .path("/v4/screens") .header("Authorization", "Token token") - .header("user-agent", format!("screenly-cli {}", env!("CARGO_PKG_VERSION"))); - then - .status(200) - .body(b"[{\"id\":\"017a5104-524b-33d8-8026-9087b59e7eb5\",\"team_id\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"created_at\":\"2021-06-28T05:07:55+00:00\",\"name\":\"Renat's integrated wired NM\",\"is_enabled\":true,\"coords\":[55.22931, 48.90429],\"last_ping\":\"2021-08-25T06:17:20.728+00:00\",\"last_ip\":null,\"local_ip\":\"192.168.1.146\",\"mac\":\"b8:27:eb:d6:83:6f\",\"last_screenshot_time\":\"2021-08-25T06:09:04.399+00:00\",\"uptime\":\"230728.38\",\"load_avg\":\"0.14\",\"signal_strength\":null,\"interface\":\"eth0\",\"debug\":false,\"location\":\"Kamsko-Ust'inskiy rayon, Russia\",\"team\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"timezone\":\"Europe/Moscow\",\"type\":\"hardware\",\"hostname\":\"srly-4shnfrdc5cd2p0p\",\"ws_open\":false,\"status\":\"Offline\",\"last_screenshot\":\"https://us-assets.screenlyapp.com/01CD1W50NR000A28F31W83B1TY/screenshots/01F98G8MJB6FC809MGGYTSWZNN/5267668e6db35498e61b83d4c702dbe8\",\"in_sync\":false,\"software_version\":\"Screenly 2 Player\",\"hardware_version\":\"Raspberry Pi 3B\",\"config\":{\"hdmi_mode\": 34, \"hdmi_boost\": 2, \"hdmi_drive\": 0, \"hdmi_group\": 0, \"verify_ssl\": true, \"audio_output\": \"hdmi\", \"hdmi_timings\": \"\", \"overscan_top\": 0, \"overscan_left\": 0, \"use_composite\": false, \"display_rotate\": 0, \"overscan_right\": 0, \"overscan_scale\": 0, \"overscan_bottom\": 0, \"disable_overscan\": 0, \"shuffle_playlist\": false, \"framebuffer_width\": 0, \"use_composite_pal\": false, \"framebuffer_height\": 0, \"hdmi_force_hotplug\": true, \"use_composite_ntsc\": false, \"hdmi_pixel_encoding\": 0, \"play_history_enabled\": false}}]"); + .header( + "user-agent", + format!("screenly-cli {}", env!("CARGO_PKG_VERSION")), + ); + then.status(200).json_body(screens.clone()); }); let config = Config::new(mock_server.base_url()); let authentication = Authentication::new_with_config(config); let screen_command = ScreenCommand::new(authentication); - let expected = serde_json::from_str::("[{\"id\":\"017a5104-524b-33d8-8026-9087b59e7eb5\",\"team_id\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"created_at\":\"2021-06-28T05:07:55+00:00\",\"name\":\"Renat's integrated wired NM\",\"is_enabled\":true,\"coords\":[55.22931, 48.90429],\"last_ping\":\"2021-08-25T06:17:20.728+00:00\",\"last_ip\":null,\"local_ip\":\"192.168.1.146\",\"mac\":\"b8:27:eb:d6:83:6f\",\"last_screenshot_time\":\"2021-08-25T06:09:04.399+00:00\",\"uptime\":\"230728.38\",\"load_avg\":\"0.14\",\"signal_strength\":null,\"interface\":\"eth0\",\"debug\":false,\"location\":\"Kamsko-Ust'inskiy rayon, Russia\",\"team\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"timezone\":\"Europe/Moscow\",\"type\":\"hardware\",\"hostname\":\"srly-4shnfrdc5cd2p0p\",\"ws_open\":false,\"status\":\"Offline\",\"last_screenshot\":\"https://us-assets.screenlyapp.com/01CD1W50NR000A28F31W83B1TY/screenshots/01F98G8MJB6FC809MGGYTSWZNN/5267668e6db35498e61b83d4c702dbe8\",\"in_sync\":false,\"software_version\":\"Screenly 2 Player\",\"hardware_version\":\"Raspberry Pi 3B\",\"config\":{\"hdmi_mode\": 34, \"hdmi_boost\": 2, \"hdmi_drive\": 0, \"hdmi_group\": 0, \"verify_ssl\": true, \"audio_output\": \"hdmi\", \"hdmi_timings\": \"\", \"overscan_top\": 0, \"overscan_left\": 0, \"use_composite\": false, \"display_rotate\": 0, \"overscan_right\": 0, \"overscan_scale\": 0, \"overscan_bottom\": 0, \"disable_overscan\": 0, \"shuffle_playlist\": false, \"framebuffer_width\": 0, \"use_composite_pal\": false, \"framebuffer_height\": 0, \"hdmi_force_hotplug\": true, \"use_composite_ntsc\": false, \"hdmi_pixel_encoding\": 0, \"play_history_enabled\": false}}]").unwrap(); let v = screen_command.list().unwrap(); - assert_eq!(v.value, expected); + assert_eq!(v.value, screens); } #[test] @@ -435,27 +482,29 @@ mod tests { let _lock = lock_test(); let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); fs::write(tmp_dir.path().join(".screenly").to_str().unwrap(), "token").unwrap(); + let new_screen = serde_json::from_str::("{\"id\":\"017a5104-524b-33d8-8026-9087b59e7eb5\",\"team_id\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"created_at\":\"2021-06-28T05:07:55+00:00\",\"name\":\"Test\",\"is_enabled\":true,\"coords\":[55.22931, 48.90429],\"last_ping\":\"2021-08-25T06:17:20.728+00:00\",\"last_ip\":null,\"local_ip\":\"192.168.1.146\",\"mac\":\"b8:27:eb:d6:83:6f\",\"last_screenshot_time\":\"2021-08-25T06:09:04.399+00:00\",\"uptime\":\"230728.38\",\"load_avg\":\"0.14\",\"signal_strength\":null,\"interface\":\"eth0\",\"debug\":false,\"location\":\"Kamsko-Ust'inskiy rayon, Russia\",\"team\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"timezone\":\"Europe/Moscow\",\"type\":\"hardware\",\"hostname\":\"srly-4shnfrdc5cd2p0p\",\"ws_open\":false,\"status\":\"Offline\",\"last_screenshot\":\"https://us-assets.screenlyapp.com/01CD1W50NR000A28F31W83B1TY/screenshots/01F98G8MJB6FC809MGGYTSWZNN/5267668e6db35498e61b83d4c702dbe8\",\"in_sync\":false,\"software_version\":\"Screenly 2 Player\",\"hardware_version\":\"Raspberry Pi 3B\",\"config\":{\"hdmi_mode\": 34, \"hdmi_boost\": 2, \"hdmi_drive\": 0, \"hdmi_group\": 0, \"verify_ssl\": true, \"audio_output\": \"hdmi\", \"hdmi_timings\": \"\", \"overscan_top\": 0, \"overscan_left\": 0, \"use_composite\": false, \"display_rotate\": 0, \"overscan_right\": 0, \"overscan_scale\": 0, \"overscan_bottom\": 0, \"disable_overscan\": 0, \"shuffle_playlist\": false, \"framebuffer_width\": 0, \"use_composite_pal\": false, \"framebuffer_height\": 0, \"hdmi_force_hotplug\": true, \"use_composite_ntsc\": false, \"hdmi_pixel_encoding\": 0, \"play_history_enabled\": false}}").unwrap(); + let mock_server = MockServer::start(); mock_server.mock(|when, then| { when.method(POST) .path("/v3/screens/") .header("Authorization", "Token token") .header("content-type", "application/json") - .header("user-agent", format!("screenly-cli {}", env!("CARGO_PKG_VERSION"))) + .header( + "user-agent", + format!("screenly-cli {}", env!("CARGO_PKG_VERSION")), + ) .json_body(json!({"pin": "test-pin", "name": "test"})); - then - .status(201) - .body(b"{\"id\":\"017a5104-524b-33d8-8026-9087b59e7eb5\",\"team_id\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"created_at\":\"2021-06-28T05:07:55+00:00\",\"name\":\"Test\",\"is_enabled\":true,\"coords\":[55.22931, 48.90429],\"last_ping\":\"2021-08-25T06:17:20.728+00:00\",\"last_ip\":null,\"local_ip\":\"192.168.1.146\",\"mac\":\"b8:27:eb:d6:83:6f\",\"last_screenshot_time\":\"2021-08-25T06:09:04.399+00:00\",\"uptime\":\"230728.38\",\"load_avg\":\"0.14\",\"signal_strength\":null,\"interface\":\"eth0\",\"debug\":false,\"location\":\"Kamsko-Ust'inskiy rayon, Russia\",\"team\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"timezone\":\"Europe/Moscow\",\"type\":\"hardware\",\"hostname\":\"srly-4shnfrdc5cd2p0p\",\"ws_open\":false,\"status\":\"Offline\",\"last_screenshot\":\"https://us-assets.screenlyapp.com/01CD1W50NR000A28F31W83B1TY/screenshots/01F98G8MJB6FC809MGGYTSWZNN/5267668e6db35498e61b83d4c702dbe8\",\"in_sync\":false,\"software_version\":\"Screenly 2 Player\",\"hardware_version\":\"Raspberry Pi 3B\",\"config\":{\"hdmi_mode\": 34, \"hdmi_boost\": 2, \"hdmi_drive\": 0, \"hdmi_group\": 0, \"verify_ssl\": true, \"audio_output\": \"hdmi\", \"hdmi_timings\": \"\", \"overscan_top\": 0, \"overscan_left\": 0, \"use_composite\": false, \"display_rotate\": 0, \"overscan_right\": 0, \"overscan_scale\": 0, \"overscan_bottom\": 0, \"disable_overscan\": 0, \"shuffle_playlist\": false, \"framebuffer_width\": 0, \"use_composite_pal\": false, \"framebuffer_height\": 0, \"hdmi_force_hotplug\": true, \"use_composite_ntsc\": false, \"hdmi_pixel_encoding\": 0, \"play_history_enabled\": false}}"); + then.status(201).json_body(new_screen.clone()); }); let config = Config::new(mock_server.base_url()); let authentication = Authentication::new_with_config(config); let screen_command = ScreenCommand::new(authentication); - let expected = serde_json::from_str::("[{\"id\":\"017a5104-524b-33d8-8026-9087b59e7eb5\",\"team_id\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"created_at\":\"2021-06-28T05:07:55+00:00\",\"name\":\"Test\",\"is_enabled\":true,\"coords\":[55.22931, 48.90429],\"last_ping\":\"2021-08-25T06:17:20.728+00:00\",\"last_ip\":null,\"local_ip\":\"192.168.1.146\",\"mac\":\"b8:27:eb:d6:83:6f\",\"last_screenshot_time\":\"2021-08-25T06:09:04.399+00:00\",\"uptime\":\"230728.38\",\"load_avg\":\"0.14\",\"signal_strength\":null,\"interface\":\"eth0\",\"debug\":false,\"location\":\"Kamsko-Ust'inskiy rayon, Russia\",\"team\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"timezone\":\"Europe/Moscow\",\"type\":\"hardware\",\"hostname\":\"srly-4shnfrdc5cd2p0p\",\"ws_open\":false,\"status\":\"Offline\",\"last_screenshot\":\"https://us-assets.screenlyapp.com/01CD1W50NR000A28F31W83B1TY/screenshots/01F98G8MJB6FC809MGGYTSWZNN/5267668e6db35498e61b83d4c702dbe8\",\"in_sync\":false,\"software_version\":\"Screenly 2 Player\",\"hardware_version\":\"Raspberry Pi 3B\",\"config\":{\"hdmi_mode\": 34, \"hdmi_boost\": 2, \"hdmi_drive\": 0, \"hdmi_group\": 0, \"verify_ssl\": true, \"audio_output\": \"hdmi\", \"hdmi_timings\": \"\", \"overscan_top\": 0, \"overscan_left\": 0, \"use_composite\": false, \"display_rotate\": 0, \"overscan_right\": 0, \"overscan_scale\": 0, \"overscan_bottom\": 0, \"disable_overscan\": 0, \"shuffle_playlist\": false, \"framebuffer_width\": 0, \"use_composite_pal\": false, \"framebuffer_height\": 0, \"hdmi_force_hotplug\": true, \"use_composite_ntsc\": false, \"hdmi_pixel_encoding\": 0, \"play_history_enabled\": false}}]").unwrap(); let v = screen_command .add("test-pin", Some("test".to_string())) .unwrap(); - assert_eq!(v.value, expected); + assert_eq!(v.value.as_array().unwrap()[0], new_screen); } #[test] @@ -506,10 +555,7 @@ mod tests { let authentication = Authentication::new_with_config(config); let asset_command = AssetCommand::new(authentication); let v = asset_command - .add( - tmp_dir.path().join("1.html").to_str().unwrap().to_string(), - "test".to_owned(), - ) + .add(tmp_dir.path().join("1.html").to_str().unwrap(), "test") .unwrap(); assert_eq!(v.value, new_asset); } @@ -560,9 +606,7 @@ mod tests { let config = Config::new(mock_server.base_url()); let authentication = Authentication::new_with_config(config); let asset_command = AssetCommand::new(authentication); - let v = asset_command - .add("https://google.com".to_owned(), "test".to_owned()) - .unwrap(); + let v = asset_command.add("https://google.com", "test").unwrap(); assert_eq!(v.value, new_asset); } @@ -716,20 +760,75 @@ mod tests { assert!(asset_command.delete("test-id").is_ok()); } + #[test] + fn test_inject_js_should_send_correct_request() { + let tmp_dir = TempDir::new("test").unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + fs::write(tmp_dir.path().join(".screenly").to_str().unwrap(), "token").unwrap(); + let mock_server = MockServer::start(); + mock_server.mock(|when, then| { + when.method(PATCH) + .path("/v4/assets") + .query_param("id", "eq.test-id") + .header( + "user-agent", + format!("screenly-cli {}", env!("CARGO_PKG_VERSION")), + ) + .json_body(json!({"js-injection": "console.log(1)"})) + .header("Authorization", "Token token"); + then.status(200); + }); + + let config = Config::new(mock_server.base_url()); + let authentication = Authentication::new_with_config(config); + let asset_command = AssetCommand::new(authentication); + assert!(asset_command.inject_js("test-id", "console.log(1)").is_ok()); + } + + #[test] + fn test_set_headers_should_send_correct_request() { + let tmp_dir = TempDir::new("test").unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + fs::write(tmp_dir.path().join(".screenly").to_str().unwrap(), "token").unwrap(); + let mock_server = MockServer::start(); + mock_server.mock(|when, then| { + when.method(PATCH) + .path("/v4/assets") + .query_param("id", "eq.test-id") + .header( + "user-agent", + format!("screenly-cli {}", env!("CARGO_PKG_VERSION")), + ) + .json_body(json!({"headers": {"k": "v"}})) + .header("Authorization", "Token token"); + then.status(200); + }); + + let config = Config::new(mock_server.base_url()); + let authentication = Authentication::new_with_config(config); + let asset_command = AssetCommand::new(authentication); + let headers = vec![("k".to_owned(), "v".to_owned())]; + assert!(asset_command + .set_web_asset_headers("test-id", headers) + .is_ok()); + } + #[test] fn test_format_screen_when_human_readable_output_is_set_should_return_correct_formatted_string() { let screen = Screens::new(serde_json::from_str("[{\"id\":\"017a5104-524b-33d8-8026-9087b59e7eb5\",\"team_id\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"created_at\":\"2021-06-28T05:07:55+00:00\",\"name\":\"Renat's integrated wired NM\",\"is_enabled\":true,\"coords\":[55.22931, 48.90429],\"last_ping\":\"2021-08-25T06:17:20.728+00:00\",\"last_ip\":null,\"local_ip\":\"192.168.1.146\",\"mac\":\"b8:27:eb:d6:83:6f\",\"last_screenshot_time\":\"2021-08-25T06:09:04.399+00:00\",\"uptime\":\"230728.38\",\"load_avg\":\"0.14\",\"signal_strength\":null,\"interface\":\"eth0\",\"debug\":false,\"location\":\"Kamsko-Ust'inskiy rayon, Russia\",\"team\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"timezone\":\"Europe/Moscow\",\"type\":\"hardware\",\"hostname\":\"srly-4shnfrdc5cd2p0p\",\"ws_open\":false,\"status\":\"Offline\",\"last_screenshot\":\"https://us-assets.screenlyapp.com/01CD1W50NR000A28F31W83B1TY/screenshots/01F98G8MJB6FC809MGGYTSWZNN/5267668e6db35498e61b83d4c702dbe8\",\"in_sync\":false,\"software_version\":\"Screenly 2 Player\",\"hardware_version\":\"Raspberry Pi 3B\",\"config\":{\"hdmi_mode\": 34, \"hdmi_boost\": 2, \"hdmi_drive\": 0, \"hdmi_group\": 0, \"verify_ssl\": true, \"audio_output\": \"hdmi\", \"hdmi_timings\": \"\", \"overscan_top\": 0, \"overscan_left\": 0, \"use_composite\": false, \"display_rotate\": 0, \"overscan_right\": 0, \"overscan_scale\": 0, \"overscan_bottom\": 0, \"disable_overscan\": 0, \"shuffle_playlist\": false, \"framebuffer_width\": 0, \"use_composite_pal\": false, \"framebuffer_height\": 0, \"hdmi_force_hotplug\": true, \"use_composite_ntsc\": false, \"hdmi_pixel_encoding\": 0, \"play_history_enabled\": false}}, {\"id\":\"017a5104-524b-33d8-8026-9087b59e7eb6\",\"team_id\":\"016343c2-82b8-0000-a121-e30f1035875d\",\"created_at\":\"2020-06-28T05:07:55+00:00\",\"name\":\"Not Renat's integrated wired NM\",\"is_enabled\":true,\"coords\":[55.22931, 48.90429],\"last_ping\":\"2020-08-25T06:17:20.728+00:00\",\"last_ip\":null,\"local_ip\":\"192.168.1.146\",\"mac\":\"b8:27:eb:d6:83:6f\",\"last_screenshot_time\":\"2021-08-25T06:09:04.399+00:00\",\"uptime\":\"230728.38\",\"load_avg\":\"0.14\",\"signal_strength\":null,\"interface\":\"eth0\",\"debug\":false,\"location\":\"Kamsko-Ust'inskiy rayon, Russia\",\"team\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"timezone\":\"Europe/Moscow\",\"type\":\"hardware\",\"hostname\":\"srly-4shnfrdc5cd2p0p\",\"ws_open\":false,\"status\":\"Offline\",\"last_screenshot\":\"https://us-assets.screenlyapp.com/01CD1W50NR000A28F31W83B1TY/screenshots/01F98G8MJB6FC809MGGYTSWZNN/5267668e6db35498e61b83d4c702dbe8\",\"in_sync\":false,\"software_version\":\"Screenly 2 Player\",\"hardware_version\":\"Raspberry Pi 3B\",\"config\":{\"hdmi_mode\": 34, \"hdmi_boost\": 2, \"hdmi_drive\": 0, \"hdmi_group\": 0, \"verify_ssl\": true, \"audio_output\": \"hdmi\", \"hdmi_timings\": \"\", \"overscan_top\": 0, \"overscan_left\": 0, \"use_composite\": false, \"display_rotate\": 0, \"overscan_right\": 0, \"overscan_scale\": 0, \"overscan_bottom\": 0, \"disable_overscan\": 0, \"shuffle_playlist\": false, \"framebuffer_width\": 0, \"use_composite_pal\": false, \"framebuffer_height\": 0, \"hdmi_force_hotplug\": true, \"use_composite_ntsc\": false, \"hdmi_pixel_encoding\": 0, \"play_history_enabled\": false}}]").unwrap()); println!("{}", screen.format(OutputType::HumanReadable)); - let expected_output = concat!( -"+--------------------------------------+---------------------------------+------------------+---------+-------------------------------+------------------+\n", -"| Id | Name | Hardware Version | In Sync | Last Ping | Uptime |\n", -"+--------------------------------------+---------------------------------+------------------+---------+-------------------------------+------------------+\n", -"| 017a5104-524b-33d8-8026-9087b59e7eb5 | Renat's integrated wired NM | Raspberry Pi 3B | ❌ | 2021-08-25T06:17:20.728+00:00 | 2days 16h 5m 28s |\n", -"+--------------------------------------+---------------------------------+------------------+---------+-------------------------------+------------------+\n", -"| 017a5104-524b-33d8-8026-9087b59e7eb6 | Not Renat's integrated wired NM | Raspberry Pi 3B | ❌ | 2020-08-25T06:17:20.728+00:00 | 2days 16h 5m 28s |\n", -"+--------------------------------------+---------------------------------+------------------+---------+-------------------------------+------------------+\n" -); + let expected_output = +"+--------------------------------------+---------------------------------+------------------+---------+-------------------------------+------------------+\n\ +| Id | Name | Hardware Version | In Sync | Last Ping | Uptime |\n\ ++--------------------------------------+---------------------------------+------------------+---------+-------------------------------+------------------+\n\ +| 017a5104-524b-33d8-8026-9087b59e7eb5 | Renat's integrated wired NM | Raspberry Pi 3B | ❌ | 2021-08-25T06:17:20.728+00:00 | 2days 16h 5m 28s |\n\ ++--------------------------------------+---------------------------------+------------------+---------+-------------------------------+------------------+\n\ +| 017a5104-524b-33d8-8026-9087b59e7eb6 | Not Renat's integrated wired NM | Raspberry Pi 3B | ❌ | 2020-08-25T06:17:20.728+00:00 | 2days 16h 5m 28s |\n\ ++--------------------------------------+---------------------------------+------------------+---------+-------------------------------+------------------+\n"; + assert_eq!(screen.format(OutputType::HumanReadable), expected_output); } @@ -759,13 +858,13 @@ mod tests { ])); println!("{}", asset.format(OutputType::HumanReadable)); - let expected_output = concat!( - "+--------------------------------------+------------+------+--------+\n", - "| Id | Title | Type | Status |\n", - "+--------------------------------------+------------+------+--------+\n", - "| 0184f162-585e-6334-8dae-38a80062a6c2 | test3.html | N/A | none |\n", - "+--------------------------------------+------------+------+--------+\n" - ); + let expected_output = + "+--------------------------------------+------------+------+--------+\n\ + | Id | Title | Type | Status |\n\ + +--------------------------------------+------------+------+--------+\n\ + | 0184f162-585e-6334-8dae-38a80062a6c2 | test3.html | N/A | none |\n\ + +--------------------------------------+------------+------+--------+\n"; + assert_eq!(asset.format(OutputType::HumanReadable), expected_output); } } diff --git a/src/main.rs b/src/main.rs index bcea9475..72ab30bb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,17 +4,28 @@ mod commands; extern crate prettytable; use crate::authentication::{Authentication, AuthenticationError}; -#[allow(unused_imports)] use crate::commands::{CommandError, Formatter, OutputType}; use clap::{command, Parser, Subcommand}; + +use http_auth_basic::Credentials; use log::{error, info}; use simple_logger::SimpleLogger; -use std::io; use std::io::Write; +use std::{fs, io}; +use thiserror::Error; + +#[derive(Error, Debug)] +enum ParseError { + #[error("missing \"=\" symbol")] + MissingSymbol(), +} +fn parse_key_val(s: &str) -> Result<(String, String), ParseError> { + let pos = s.find('=').ok_or(ParseError::MissingSymbol())?; + Ok((s[..pos].to_string(), s[pos + 1..].to_string())) +} #[derive(Parser)] #[command( - author, version, about, long_about = "Command line interface is intended for quick interaction with Screenly through terminal. Moreover, this CLI is built such that it can be used for automating tasks." @@ -106,9 +117,36 @@ enum AssetCommands { /// UUID of the asset to be deleted. uuid: String, }, + + /// Injects javascript code inside of the web asset. It will be executed once the asset loads during playback. + InjectJs { + /// UUID of the web asset to inject with JavaScript. + uuid: String, + + /// Path to local file or URL for remote file. + path: String, + }, + + /// Sets http headers for web asset. + SetHeaders { + /// UUID of the web asset to set http headers. + uuid: String, + + /// HTTP header in the following form "k=v". This command can be used more than once to pass multiple headers. + #[arg(long="header", action = clap::ArgAction::Append, value_parser = parse_key_val)] + headers: Vec<(String, String)>, + }, + + BasicAuth { + /// UUID of the web asset to set up basic authentication for. + uuid: String, + /// Shortcut for setting up basic authentication headers. Accepts login in password in form user=password. + #[arg(value_parser = parse_key_val)] + credentials: (String, String), + }, } -fn handle_command_execution_result( +fn handle_command_execution_result( result: anyhow::Result, json: &Option, ) { @@ -123,7 +161,7 @@ fn handle_command_execution_result( } Err(e) => { match e { - CommandError::AuthenticationError(_) => { + CommandError::Authentication(_) => { error!( "Authentication error occurred. Please use login command to authenticate." ) @@ -187,6 +225,7 @@ fn main() { .init() .unwrap(); let cli = Cli::parse(); + let authentication = Authentication::new(); match &cli.command { Commands::Login { token } => match authentication.verify_and_store_token(token) { @@ -196,7 +235,7 @@ fn main() { } Err(e) => match e { - AuthenticationError::WrongCredentialsError => { + AuthenticationError::WrongCredentials => { error!("Token verification failed."); std::process::exit(1); } @@ -271,10 +310,7 @@ fn main() { } AssetCommands::Add { path, title, json } => { let asset_command = commands::AssetCommand::new(authentication); - handle_command_execution_result( - asset_command.add(path.clone(), title.clone()), - json, - ); + handle_command_execution_result(asset_command.add(path, title), json); } AssetCommands::Delete { uuid } => { let asset_command = commands::AssetCommand::new(authentication); @@ -315,6 +351,70 @@ fn main() { } } } + AssetCommands::InjectJs { uuid, path } => { + let asset_command = commands::AssetCommand::new(authentication); + let js_code = if path.starts_with("http://") || path.starts_with("https://") { + match reqwest::blocking::get(path) { + Ok(response) => match response.status().as_u16() { + 200 => response.text().unwrap_or_default(), + status => { + error!("Failed to retrieve JS injection code. Wrong response status: {}", status); + std::process::exit(1); + } + }, + Err(e) => { + error!("Failed to retrieve JS injection code. Error: {}", e); + std::process::exit(1); + } + } + } else { + match fs::read_to_string(path) { + Ok(text) => text, + Err(e) => { + error!("Failed to read file with JS injection code. Error: {}", e); + std::process::exit(1); + } + } + }; + + match asset_command.inject_js(uuid, &js_code) { + Ok(()) => { + info!("Asset updated successfully."); + } + Err(e) => { + error!("Error occurred: {:?}", e); + std::process::exit(1); + } + } + } + AssetCommands::SetHeaders { uuid, headers } => { + let asset_command = commands::AssetCommand::new(authentication); + match asset_command.set_web_asset_headers(uuid, headers.clone()) { + Ok(()) => { + info!("Asset updated successfully."); + } + Err(e) => { + error!("Error occurred: {:?}", e); + std::process::exit(1); + } + } + } + AssetCommands::BasicAuth { uuid, credentials } => { + let asset_command = commands::AssetCommand::new(authentication); + let basic_auth = Credentials::new(&credentials.0, &credentials.1); + match asset_command.set_web_asset_headers( + uuid, + vec![("Authorization".to_owned(), basic_auth.as_http_header())], + ) { + Ok(()) => { + info!("Asset updated successfully."); + } + Err(e) => { + error!("Error occurred: {:?}", e); + std::process::exit(1); + } + } + } }, } }