From deb836b3c34c3d6ee9e2620f1a798b3d0b7ede8b Mon Sep 17 00:00:00 2001 From: bryan newbold Date: Sun, 19 Feb 2023 15:05:50 -0800 Subject: create 'adenosine' crate with library code --- adenosine/src/lib.rs | 248 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 adenosine/src/lib.rs (limited to 'adenosine/src/lib.rs') diff --git a/adenosine/src/lib.rs b/adenosine/src/lib.rs new file mode 100644 index 0000000..461930f --- /dev/null +++ b/adenosine/src/lib.rs @@ -0,0 +1,248 @@ +use anyhow::anyhow; +pub use anyhow::Result; +use reqwest::header; +use serde_json::Value; +use std::collections::HashMap; +use std::str::FromStr; +use std::time::Duration; + +pub mod identifiers; +use identifiers::Nsid; + +static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum XrpcMethod { + Get, + Post, +} + +impl FromStr for XrpcMethod { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s { + "get" => Ok(XrpcMethod::Get), + "post" => Ok(XrpcMethod::Post), + _ => Err(anyhow!("unknown method: {}", s)), + } + } +} + +pub struct XrpcClient { + http_client: reqwest::blocking::Client, + host: String, +} + +impl XrpcClient { + pub fn new(host: String, auth_token: Option) -> Result { + let mut headers = header::HeaderMap::new(); + if let Some(token) = &auth_token { + let mut auth_value = header::HeaderValue::from_str(&format!("Bearer {}", token))?; + auth_value.set_sensitive(true); + headers.insert(header::AUTHORIZATION, auth_value); + }; + + let http_client = reqwest::blocking::Client::builder() + .default_headers(headers) + .user_agent(APP_USER_AGENT) + .timeout(Duration::from_secs(30)) + //.danger_accept_invalid_certs(true) + .build() + .expect("ERROR :: Could not build reqwest client"); + + Ok(XrpcClient { http_client, host }) + } + + pub fn get( + &self, + nsid: &Nsid, + params: Option>, + ) -> Result> { + log::debug!("XRPC GET endpoint={} params={:?}", nsid, params); + let params: HashMap = params.unwrap_or_default(); + let res = self + .http_client + .get(format!("{}/xrpc/{}", self.host, nsid)) + .query(¶ms) + .send()?; + // TODO: refactor this error handling stuff into single method + if res.status() == 400 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Bad Request (400): {}", + val["message"].as_str().unwrap_or("unknown") + )); + } else if res.status() == 500 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Internal Error (500): {}", + val["message"].as_str().unwrap_or("unknown") + )); + } + let res = res.error_for_status()?; + Ok(res.json()?) + } + + pub fn get_to_writer( + &self, + nsid: &Nsid, + params: Option>, + output: &mut W, + ) -> Result { + let params: HashMap = params.unwrap_or_default(); + 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 (400): {}", + val["message"].as_str().unwrap_or("unknown") + )); + } else if res.status() == 500 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Internal Error (500): {}", + val["message"].as_str().unwrap_or("unknown") + )); + } + let mut res = res.error_for_status()?; + Ok(res.copy_to(output)?) + } + + pub fn post( + &self, + nsid: &Nsid, + params: Option>, + body: Option, + ) -> Result> { + let params: HashMap = params.unwrap_or_default(); + log::debug!( + "XRPC POST endpoint={} params={:?} body={:?}", + nsid, + params, + body + ); + let mut req = self + .http_client + .post(format!("{}/xrpc/{}", self.host, nsid)) + .query(¶ms); + req = if let Some(b) = body { + req.json(&b) + } else { + req + }; + let res = req.send()?; + if res.status() == 400 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Bad Request (400): {}", + val["message"].as_str().unwrap_or("unknown") + )); + } else if res.status() == 500 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Internal Error (500): {}", + val["message"].as_str().unwrap_or("unknown") + )); + } + let res = res.error_for_status()?; + if res.content_length() == Some(0) { + Ok(None) + } else { + Ok(res.json()?) + } + } + + pub fn post_cbor_from_reader( + &self, + nsid: &Nsid, + params: Option>, + input: &mut R, + ) -> Result> { + let params: HashMap = params.unwrap_or_default(); + 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)?; + // trying to also support pulling "aud" as DID; not sure this is actually correct use of + // UCAN/JWT semantics? + let did = obj["sub"] + .as_str() + .or(obj["aud"].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()); + // JWT from atproto ("sub") + assert_eq!( + parse_did_from_jwt("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWQ6cGxjOmV4M3NpNTI3Y2QyYW9nYnZpZGtvb296YyIsImlhdCI6MTY2NjgyOTM5M30.UvZgTqvaJICONa1wIUT1bny7u3hqVAqWhWy3qeuyZrE").unwrap(), + "did:plc:ex3si527cd2aogbvidkooozc", + ); + // UCAN from adenosine-pds ("aud") + assert_eq!( + parse_did_from_jwt("eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsInVjdiI6IjAuOS4wLWNhbmFyeSJ9.eyJhdHQiOltdLCJhdWQiOiJkaWQ6cGxjOnM3b25ieWphN2MzeXJzZ3Zob2xrbHM1YiIsImV4cCI6MTY3NTM4Mzg2NywiZmN0IjpbXSwiaXNzIjoiZGlkOmtleTp6RG5hZWRHVGJkb0Frb1NlOG96a3k1WHAzMjZTVFpUSm50aDlHY2dxaTZQYjNzYjczIiwibm5jIjoiTnZURDhENWZjNXFpalIyMWJ1V2Z1ZE02dzlBM2drSy1ac3RtUW03b21pdyIsInByZiI6W119.QwZkb9R17tNhXnY_roqFYgdiIgUnSC18FYWQb3PcH6BU1R5l4W_T4XdACyczPGfM-jAnF2r2loBXDntYVS6N5A").unwrap(), + "did:plc:s7onbyja7c3yrsgvholkls5b", + ); + assert!(parse_did_from_jwt("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9").is_err()); +} + +/// Helper to generate the current timestamp as right now, UTC, formatted as an RFC 3339 string. +/// +/// Currently, bluesky PDS expects millisecond precision, so we use that. +/// +/// Returns something like "2022-11-22T09:21:15.640Z" +pub fn created_at_now() -> String { + let now = time::OffsetDateTime::now_utc(); + // remove microsecond precision, but retain millisecond precision + let ms = now.millisecond(); + let now = now.replace_microsecond(0).unwrap(); + let now = now.replace_millisecond(ms).unwrap(); + now.format(&time::format_description::well_known::Rfc3339) + .unwrap() +} + +#[test] +fn test_created_at_now() { + // eg: 2022-11-22T09:20:44.123Z + let ts = created_at_now(); + println!("{}", ts); + assert_eq!(&ts[4..5], "-"); + assert_eq!(&ts[7..8], "-"); + assert_eq!(&ts[10..11], "T"); + assert_eq!(&ts[23..24], "Z"); +} -- cgit v1.2.3