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);
+ }
+ }
+ }
},
}
}