From b169d130ea80f6954b77a2921c50e8587eadd1ae Mon Sep 17 00:00:00 2001 From: Bryan Newbold Date: Thu, 27 Oct 2022 02:07:36 -0700 Subject: cli: progress --- adenosine-cli/Cargo.toml | 5 +- adenosine-cli/TODO | 1 + adenosine-cli/plan.txt | 6 +- adenosine-cli/src/lib.rs | 108 +++++++++++++++- adenosine-cli/src/main.rs | 322 +++++++++++++++++++++++++++++++++++++++++----- 5 files changed, 395 insertions(+), 47 deletions(-) create mode 100644 adenosine-cli/TODO (limited to 'adenosine-cli') diff --git a/adenosine-cli/Cargo.toml b/adenosine-cli/Cargo.toml index 8220e8f..971f59a 100644 --- a/adenosine-cli/Cargo.toml +++ b/adenosine-cli/Cargo.toml @@ -15,14 +15,15 @@ structopt = "*" # NOTE: could try 'rustls-tls' feature instead of default native TLS? reqwest = { version = "0.11", features = ["blocking", "json"] } serde_json = "*" -log = "*" -env_logger = "*" +base64 = "*" # uncertain about these... anyhow = "1.0" atty = "0.2" colored_json = "*" termcolor = "*" +log = "*" +env_logger = "*" [package.metadata.deb] maintainer = "Bryan Newbold " diff --git a/adenosine-cli/TODO b/adenosine-cli/TODO new file mode 100644 index 0000000..4d582e8 --- /dev/null +++ b/adenosine-cli/TODO @@ -0,0 +1 @@ +- invite codes in account registration diff --git a/adenosine-cli/plan.txt b/adenosine-cli/plan.txt index 4eb594c..668a746 100644 --- a/adenosine-cli/plan.txt +++ b/adenosine-cli/plan.txt @@ -40,9 +40,9 @@ file; '-' to read a JSON file from stdin. create-revocation-key repo - root - export ? - import [--from ] + root ? + export ? + import --did [--from ] xrpc get|post []+ => generic method diff --git a/adenosine-cli/src/lib.rs b/adenosine-cli/src/lib.rs index 704c1d9..06fade5 100644 --- a/adenosine-cli/src/lib.rs +++ b/adenosine-cli/src/lib.rs @@ -53,7 +53,7 @@ impl XrpcClient { pub fn get( &self, - nsid: String, + nsid: &str, params: Option>, ) -> Result> { let params: HashMap = params.unwrap_or(HashMap::new()); @@ -61,25 +61,119 @@ impl XrpcClient { .http_client .get(format!("{}/xrpc/{}", self.host, nsid)) .query(¶ms) - .send()? - .error_for_status()?; + .send()?; + if res.status() == 400 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Bad Request: {}", + val["message"].as_str().unwrap_or("unknown") + )); + } + let res = res.error_for_status()?; Ok(res.json()?) } + pub fn get_to_writer( + &self, + nsid: &str, + params: Option>, + output: &mut W, + ) -> Result { + let params: HashMap = params.unwrap_or(HashMap::new()); + let res = self + .http_client + .get(format!("{}/xrpc/{}", self.host, nsid)) + .query(¶ms) + .send()?; + if res.status() == 400 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Bad Request: {}", + val["message"].as_str().unwrap_or("unknown") + )); + } + let mut res = res.error_for_status()?; + Ok(res.copy_to(output)?) + } + pub fn post( &self, - nsid: String, + nsid: &str, params: Option>, body: Value, ) -> Result> { let params: HashMap = params.unwrap_or(HashMap::new()); let res = self .http_client - .get(format!("{}/xrpc/{}", self.host, nsid)) + .post(format!("{}/xrpc/{}", self.host, nsid)) .query(¶ms) .json(&body) - .send()? - .error_for_status()?; + .send()?; + if res.status() == 400 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Bad Request: {}", + val["message"].as_str().unwrap_or("unknown") + )); + } + let res = res.error_for_status()?; Ok(res.json()?) } + + pub fn post_cbor_from_reader( + &self, + nsid: &str, + params: Option>, + input: &mut R, + ) -> Result> { + let params: HashMap = params.unwrap_or(HashMap::new()); + let mut buf: Vec = Vec::new(); + input.read_to_end(&mut buf)?; + let res = self + .http_client + .post(format!("{}/xrpc/{}", self.host, nsid)) + .query(¶ms) + .header(reqwest::header::CONTENT_TYPE, "application/cbor") + .body(buf) + .send()?; + if res.status() == 400 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Bad Request: {}", + val["message"].as_str().unwrap_or("unknown") + )); + } + let res = res.error_for_status()?; + Ok(res.json()?) + } + + // reqwest::blocking::Body +} + +/// Tries to parse a DID internal identifier from a JWT (as base64-encoded token) +pub fn parse_did_from_jwt(jwt: &str) -> Result { + let second_b64 = jwt.split(".").nth(1).ok_or(anyhow!("couldn't parse JWT"))?; + let second_json: Vec = base64::decode_config(second_b64, base64::URL_SAFE)?; + let obj: Value = serde_json::from_slice(&second_json)?; + let did = obj["sub"] + .as_str() + .ok_or(anyhow!("couldn't find DID subject in JWT"))? + .to_string(); + if !did.starts_with("did:") { + return Err(anyhow!("couldn't find DID subject in JWT")); + } + Ok(did) } + +#[test] +fn test_parse_jwt() { + assert!(parse_did_from_jwt(".").is_err()); + assert_eq!( + parse_did_from_jwt("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWQ6cGxjOmV4M3NpNTI3Y2QyYW9nYnZpZGtvb296YyIsImlhdCI6MTY2NjgyOTM5M30.UvZgTqvaJICONa1wIUT1bny7u3hqVAqWhWy3qeuyZrE").unwrap(), + "did:plc:ex3si527cd2aogbvidkooozc", + ); + assert!(parse_did_from_jwt("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9").is_err()); +} + +// TODO: parse at-uri +// at://did:plc:ltk4reuh7rkoy2frnueetpb5/app.bsky.follow/3jg23pbmlhc2a diff --git a/adenosine-cli/src/main.rs b/adenosine-cli/src/main.rs index 0246fd6..eea16bc 100644 --- a/adenosine-cli/src/main.rs +++ b/adenosine-cli/src/main.rs @@ -1,12 +1,11 @@ use adenosine_cli::*; use anyhow::anyhow; -use serde_json::Value; +use serde_json::{json, Value}; use std::collections::HashMap; use colored_json::to_colored_json_auto; -use log::{self, debug, info}; +use log::{self, debug}; use std::io::Write; -use std::path::PathBuf; use structopt::StructOpt; use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor}; @@ -58,10 +57,57 @@ enum AccountCommand { password: String, }, Delete, - Login, + Login { + #[structopt(long, short)] + username: String, + + #[structopt(long, short)] + password: String, + }, Logout, Info, - CreateRevocationKey, + // TODO: CreateRevocationKey, +} + +#[derive(StructOpt)] +enum RepoCommand { + Root { + #[structopt(long)] + did: String, + }, + Export { + #[structopt(long)] + did: String, + #[structopt(long)] + from: Option, + }, + Import { + // XXX: either path or stdin + #[structopt(long)] + did: String, + }, +} + +#[derive(StructOpt)] +enum BskyCommand { + Feed { name: Option }, + Notifications, + Post { text: String }, + Repost { uri: String }, + Like { uri: String }, + // TODO: Repost { uri: String, }, + Follow { uri: String }, + // TODO: Unfollow { uri: String, }, + /* + Follows { + name: String, + }, + Followers { + name: String, + }, + */ + Profile { name: String }, + SearchUsers { query: String }, } #[derive(StructOpt)] @@ -70,6 +116,30 @@ enum Command { uri: String, }, + Ls { + uri: String, + }, + + Create { + collection: String, + body: String, + }, + Update { + uri: String, + params: String, + }, + Delete { + uri: String, + }, + + Describe { + name: String, + }, + + Resolve { + name: String, + }, + Xrpc { method: XrpcMethod, nsid: String, @@ -82,6 +152,16 @@ enum Command { cmd: AccountCommand, }, + Repo { + #[structopt(subcommand)] + cmd: RepoCommand, + }, + + Bsky { + #[structopt(subcommand)] + cmd: BskyCommand, + }, + /// Summarize connection and authentication with API Status, } @@ -136,40 +216,73 @@ fn main() -> Result<()> { Ok(()) } +fn print_result_json(result: Option) -> Result<()> { + if let Some(val) = result { + writeln!(&mut std::io::stdout(), "{}", to_colored_json_auto(&val)?)? + }; + Ok(()) +} + fn run(opt: Opt) -> Result<()> { - let xrpc_client = XrpcClient::new(opt.atp_host, opt.auth_token)?; + let xrpc_client = XrpcClient::new(opt.atp_host.clone(), opt.auth_token.clone())?; + let mut params: HashMap = HashMap::new(); + let jwt_did: Option = if let Some(ref token) = opt.auth_token { + Some(parse_did_from_jwt(token)?) + } else { + None + }; - match opt.cmd { + let result = match opt.cmd { + Command::Status => { + // XXX + println!("Configuration"); + println!(" ATP_HOST: {}", opt.atp_host); + if opt.auth_token.is_some() { + println!(" ATP_AUTH_TOKEN: "); + } else { + println!(" ATP_AUTH_TOKEN:"); + } + // TODO: parse JWT? + // TODO: connection, auth check + // TODO: account username, did, etc + None + } + Command::Describe { name } => { + params.insert("user".to_string(), name); + xrpc_client.get("com.atproto.repoDescribe", Some(params))? + } + Command::Resolve { name } => { + let mut params: HashMap = HashMap::new(); + params.insert("name".to_string(), name); + xrpc_client.get("com.atproto.resolveName", Some(params))? + } + Command::Get { uri } => { + println!("GET: {}", uri); + None + } + Command::Ls { uri } => { + unimplemented!() + } + Command::Create { collection, body } => { + unimplemented!() + } + Command::Update { uri, params } => { + unimplemented!() + } + Command::Delete { uri } => { + unimplemented!() + } Command::Xrpc { method, nsid, params, } => { let body: Value = ().into(); - let res = match method { + match method { // XXX: parse params - XrpcMethod::Get => xrpc_client.get(nsid, None)?, - XrpcMethod::Post => xrpc_client.post(nsid, None, body)?, - }; - if let Some(val) = res { - writeln!(&mut std::io::stdout(), "{}", to_colored_json_auto(&val)?)? - }; - } - Command::Get { uri } => { - println!("GET: {}", uri); - /* - let result = specifier.get_from_api(&mut api_client, expand, hide)?; - if toml { - writeln!(&mut std::io::stdout(), "{}", result.to_toml_string()?)? - } else { - // "if json" - writeln!( - &mut std::io::stdout(), - "{}", - to_colored_json_auto(&result.to_json_value()?)? - )? + XrpcMethod::Get => xrpc_client.get(&nsid, None)?, + XrpcMethod::Post => xrpc_client.post(&nsid, None, body)?, } - */ } Command::Account { cmd: @@ -178,15 +291,154 @@ fn run(opt: Opt) -> Result<()> { username, password, }, + } => xrpc_client.post( + "com.atproto.createAccount", + None, + json!({ + "email": email, + "username": username, + "password": password, + }), + )?, + Command::Account { + cmd: AccountCommand::Login { username, password }, + } => xrpc_client.post( + "com.atproto.createSession", + None, + json!({ + "username": username, + "password": password, + }), + )?, + Command::Account { + cmd: AccountCommand::Logout, + } => xrpc_client.post("com.atproto.deleteSession", None, json!({}))?, + Command::Account { + cmd: AccountCommand::Delete, + } => xrpc_client.post("com.atproto.deleteAccount", None, json!({}))?, + Command::Account { + cmd: AccountCommand::Info, + } => xrpc_client.get("com.atproto.getAccount", None)?, + Command::Repo { + cmd: RepoCommand::Root { did }, + } => { + params.insert("did".to_string(), did); + xrpc_client.get("com.atproto.syncGetRoot", Some(params))? + } + Command::Repo { + cmd: RepoCommand::Export { did, from }, + } => { + params.insert("did".to_string(), did); + if let Some(from) = from { + params.insert("from".to_string(), from); + }; + xrpc_client.get_to_writer( + "com.atproto.syncGetRepo", + Some(params), + &mut std::io::stdout(), + )?; + None + } + Command::Repo { + cmd: RepoCommand::Import { did }, + } => { + params.insert("did".to_string(), did); + xrpc_client.post_cbor_from_reader( + "com.atproto.syncUpdateRepo", + Some(params), + &mut std::io::stdin(), + )? + } + Command::Bsky { + cmd: BskyCommand::Feed { name }, } => { - println!( - "REGISTER: email={} username={} password={}", - email, username, password + if let Some(name) = name { + params.insert("author".to_string(), name); + xrpc_client.get("app.bsky.getAuthorFeed", Some(params))? + } else { + xrpc_client.get("app.bsky.getHomeFeed", None)? + } + } + Command::Bsky { + cmd: BskyCommand::Notifications, + } => xrpc_client.get("app.bsky.getNotifications", None)?, + Command::Bsky { + cmd: BskyCommand::Post { text }, + } => { + params.insert( + "did".to_string(), + jwt_did.ok_or(anyhow!("need auth token"))?, ); + params.insert("collection".to_string(), "app.bsky.post".to_string()); + xrpc_client.post( + "com.atproto.repoCreateRecord", + Some(params), + json!({ + "text": text, + }), + )? } - _ => { - unimplemented!("some command"); + Command::Bsky { + cmd: BskyCommand::Repost { uri }, + } => { + params.insert( + "did".to_string(), + jwt_did.ok_or(anyhow!("need auth token"))?, + ); + params.insert("collection".to_string(), "app.bsky.repost".to_string()); + xrpc_client.post( + "com.atproto.repoCreateRecord", + Some(params), + json!({ + "subject": uri, + }), + )? } - } + Command::Bsky { + cmd: BskyCommand::Like { uri }, + } => { + params.insert( + "did".to_string(), + jwt_did.ok_or(anyhow!("need auth token"))?, + ); + params.insert("collection".to_string(), "app.bsky.like".to_string()); + xrpc_client.post( + "com.atproto.repoCreateRecord", + Some(params), + json!({ + "subject": uri, + }), + )? + } + Command::Bsky { + cmd: BskyCommand::Follow { uri }, + } => { + params.insert( + "did".to_string(), + jwt_did.ok_or(anyhow!("need auth token"))?, + ); + params.insert("collection".to_string(), "app.bsky.follow".to_string()); + xrpc_client.post( + "com.atproto.repoCreateRecord", + Some(params), + json!({ + "subject": uri, + }), + )? + } + Command::Bsky { + cmd: BskyCommand::Profile { name }, + } => { + params.insert("name".to_string(), name); + xrpc_client.get("app.bsky.getProfile", Some(params))? + } + Command::Bsky { + cmd: BskyCommand::SearchUsers { query }, + } => { + params.insert("term".to_string(), query); + xrpc_client.get("app.bsky.getUsersSearch", Some(params))? + } + }; + print_result_json(result)?; Ok(()) } -- cgit v1.2.3