diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..166dfc9 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,290 @@ +use anyhow::Result; +use isahc::prelude::*; +use openssl::hash::MessageDigest; +use openssl::pkey::PKey; +use openssl::rsa::Rsa; +use openssl::sign::Signer; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +const BASE: &str = "https://api.bunq.com"; + +#[derive(Serialize)] +struct Installation<'a> { + client_public_key: &'a str, +} + +#[derive(Deserialize)] +struct Token { + token: String, +} +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct InstallationResponse { + token: Token, +} + +#[derive(Serialize)] +struct DeviceServer<'a> { + description: &'a str, + secret: &'a str, + permitted_ips: &'a [&'a str], +} + +#[derive(Serialize)] +struct SessionServer<'a> { + secret: &'a str, +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct SessionServerResponse { + token: Token, + user_person: UserPerson, +} + +#[derive(Deserialize)] +struct UserPerson { + id: i64, +} + +fn sign(body: &str, key: &PKey) -> Result { + let mut signer = Signer::new(MessageDigest::sha256(), key)?; + + let sig = signer.sign_oneshot_to_vec(body.as_bytes())?; + + Ok(base64::encode(&sig)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct RawResponse { + response: Vec, + pagination: Option, +} + +struct Response { + response: T, + pagination: Option, +} + +impl RawResponse { + fn decode_retarded(self) -> Result> { + let mut map = serde_json::Map::new(); + for e in self.response { + if let serde_json::Value::Object(e) = e { + let (k, v) = e + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("malformed response"))?; + map.insert(k, v); + } + } + Ok(Response { + response: serde_json::from_value(map.into())?, + pagination: self.pagination, + }) + } +} + +fn deserialize_retarded_response(r: &str) -> Result> { + let r: RawResponse = serde_json::from_str(r)?; + r.decode_retarded() +} + +fn deserialize_normal_response(r: &str) -> Result> { + let r: RawResponse = serde_json::from_str(r)?; + Ok(Response { + response: serde_json::from_value(r.response.into())?, + pagination: r.pagination, + }) +} + +#[derive(Serialize, Deserialize, Default)] +struct AppState { + token: String, + pem_private: String, +} +#[derive(Serialize, Deserialize, Default)] +pub struct BunqConfig { + api_key: String, + state: Option, +} +pub struct BunqConfigReady { + token: String, + keypair: PKey, + user_id: i64, +} +impl BunqConfig { + pub fn load() -> Result { + Ok(confy::load("bunq-rs")?) + } + pub fn save(&self) -> Result<()> { + confy::store("bunq-rs", self)?; + Ok(()) + } + pub fn install(mut self) -> Result { + let api_key = &self.api_key; + + let keypair = if let Some(state) = &self.state { + PKey::private_key_from_pem(state.pem_private.as_bytes())? + } else { + let rsa = Rsa::generate(2048)?; + let pem_private = rsa.private_key_to_pem()?; + let pem_private = String::from_utf8(pem_private)?; + + let keypair = PKey::from_rsa(rsa)?; + + let pem_public = String::from_utf8(keypair.public_key_to_pem()?)?; + + let body = Installation { + client_public_key: &pem_public, + }; + let response = isahc::post( + format!("{}/v1/installation", BASE), + serde_json::to_string(&body)?, + )? + .text()?; + let response: InstallationResponse = deserialize_retarded_response(&response)?.response; + let token = response.token.token; + + let body = DeviceServer { + description: "awesome", + secret: &api_key, + permitted_ips: &["31.21.118.143", "*"], + }; + let body = serde_json::to_string(&body)?; + let mut response = isahc::http::Request::post(format!("{}/v1/device-server", BASE)) + .header("X-Bunq-Client-Authentication", &token) + .body(body)? + .send()?; + println!("{}", response.text()?); + + self.state = Some(AppState { pem_private, token }); + self.save()?; + + keypair + }; + let token = self.state.unwrap().token; + let body = SessionServer { secret: &api_key }; + let body = serde_json::to_string(&body)?; + let sig = sign(&body, &keypair)?; + let response = isahc::http::Request::post(format!("{}/v1/session-server", BASE)) + .header("X-Bunq-Client-Authentication", &token) + .header("X-Bunq-Client-Signature", &sig) + .body(body)? + .send()? + .text()?; + let r: SessionServerResponse = deserialize_retarded_response(&response)?.response; + Ok(BunqConfigReady { + keypair, + token: r.token.token, + user_id: r.user_person.id, + }) + } +} + +impl BunqConfigReady { + pub fn monetary_accounts(&self) -> Result> { + let response = isahc::http::Request::get(format!( + "{}/v1/user/{}/monetary-account", + BASE, self.user_id + )) + .header("X-Bunq-Client-Authentication", &self.token) + .body(())? + .send()? + .text()?; + Ok( + deserialize_normal_response::>(&response)? + .response + .into_iter() + .map(|m| m.monetary_account_bank) + .collect(), + ) + } + pub fn payments(&self, acc: &MonetaryAccountBank) -> Result> { + let next_page = |url: &str| -> Result<(_, _)> { + let response = isahc::http::Request::get(url) + .header("X-Bunq-Client-Authentication", &self.token) + .body(())? + .send()? + .text()?; + let Response { + response, + pagination, + } = deserialize_normal_response::>(&response)?; + Ok(( + response.into_iter().map(|p| p.payment).collect(), + pagination, + )) + }; + let mut url = format!( + "/v1/user/{}/monetary-account/{}/payment", + self.user_id, acc.id + ); + let mut all = Vec::new(); + loop { + let (mut payments, pag) = next_page(&format!("{}{}", BASE, url))?; + all.append(&mut payments); + if let Some(Pagination{older_url: Some(older_url),..}) = pag { + url = older_url; + } else { + break; + } + } + Ok(all) + } +} + +#[derive(Deserialize, Debug)] +pub struct LabelMonetaryAccount { + pub iban: Option, + pub display_name: String, + pub merchant_category_code: Option, +} + +#[derive(Deserialize, Debug)] +pub struct Amount { + pub value: String, + pub currency: String, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +struct PaymentPayment { + payment: Payment, +} + +#[derive(Deserialize, Debug)] +struct Pagination { + future_url: Option, + newer_url: Option, + older_url: Option, +} + +#[derive(Deserialize, Debug)] +pub struct Payment { + pub alias: LabelMonetaryAccount, + pub counterparty_alias: LabelMonetaryAccount, + pub amount: Amount, + pub balance_after_mutation: Amount, + pub created: String, + pub updated: String, + pub description: String, + pub id: i64, + pub monetary_account_id: i64, + #[serde(rename = "type")] + pub type_: String, + pub sub_type: String, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +struct MonetaryAccount { + monetary_account_bank: MonetaryAccountBank, +} +#[derive(Deserialize, Debug)] +pub struct MonetaryAccountBank { + pub id: i64, + pub description: String, +} + diff --git a/src/main.rs b/src/main.rs index 2009364..34fa874 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,297 +1,8 @@ use anyhow::Result; -use isahc::prelude::*; -use openssl::hash::MessageDigest; -use openssl::pkey::PKey; -use openssl::rsa::Rsa; -use openssl::sign::{Signer, Verifier}; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; - -const BASE: &str = "https://api.bunq.com"; - -#[derive(Serialize)] -struct Installation<'a> { - client_public_key: &'a str, -} - -#[derive(Deserialize)] -struct Token { - token: String, -} -#[derive(Deserialize)] -#[serde(rename_all = "PascalCase")] -struct InstallationResponse { - token: Token, -} - -#[derive(Serialize)] -struct DeviceServer<'a> { - description: &'a str, - secret: &'a str, - permitted_ips: &'a [&'a str], -} - -#[derive(Serialize)] -struct SessionServer<'a> { - secret: &'a str, -} - -#[derive(Deserialize)] -#[serde(rename_all = "PascalCase")] -struct SessionServerResponse { - token: Token, - user_person: UserPerson, -} - -#[derive(Deserialize)] -struct UserPerson { - id: i64, -} - -fn sign(body: &str, key: &PKey) -> Result { - let mut signer = Signer::new(MessageDigest::sha256(), key)?; - - let sig = signer.sign_oneshot_to_vec(body.as_bytes())?; - - Ok(base64::encode(&sig)) -} - -#[derive(Deserialize)] -#[serde(rename_all = "PascalCase")] -struct RawResponse { - response: Vec, - pagination: Option, -} - -struct Response { - response: T, - pagination: Option, -} - -impl RawResponse { - fn decode_retarded(self) -> Result> { - let mut map = serde_json::Map::new(); - for e in self.response { - if let serde_json::Value::Object(e) = e { - let (k, v) = e - .into_iter() - .next() - .ok_or_else(|| anyhow::anyhow!("malformed response"))?; - map.insert(k, v); - } - } - Ok(Response { - response: serde_json::from_value(map.into())?, - pagination: self.pagination, - }) - } -} - -fn deserialize_retarded_response(r: &str) -> Result> { - let r: RawResponse = serde_json::from_str(r)?; - r.decode_retarded() -} - -fn deserialize_normal_response(r: &str) -> Result> { - let r: RawResponse = serde_json::from_str(r)?; - Ok(Response { - response: serde_json::from_value(r.response.into())?, - pagination: r.pagination, - }) -} - -#[derive(Serialize, Deserialize, Default)] -struct AppState { - token: String, - pem_private: String, -} -#[derive(Serialize, Deserialize, Default)] -struct AppConfig { - api_key: String, - state: Option, -} -struct AppConfigReady { - api_key: String, - token: String, - keypair: PKey, - user_id: i64, -} -impl AppConfig { - pub fn load() -> Result { - Ok(confy::load("bunqledger")?) - } - pub fn save(&self) -> Result<()> { - confy::store("bunqledger", self)?; - Ok(()) - } - pub fn install(mut self) -> Result { - let api_key = &self.api_key; - - let keypair = if let Some(state) = &self.state { - PKey::private_key_from_pem(state.pem_private.as_bytes())? - } else { - let rsa = Rsa::generate(2048)?; - let pem_private = rsa.private_key_to_pem()?; - let pem_private = String::from_utf8(pem_private)?; - - let keypair = PKey::from_rsa(rsa)?; - - let pem_public = String::from_utf8(keypair.public_key_to_pem()?)?; - - let body = Installation { - client_public_key: &pem_public, - }; - let response = isahc::post( - format!("{}/v1/installation", BASE), - serde_json::to_string(&body)?, - )? - .text()?; - let response: InstallationResponse = deserialize_retarded_response(&response)?.response; - let token = response.token.token; - - let body = DeviceServer { - description: "awesome", - secret: &api_key, - permitted_ips: &["31.21.118.143", "*"], - }; - let body = serde_json::to_string(&body)?; - let mut response = isahc::http::Request::post(format!("{}/v1/device-server", BASE)) - .header("X-Bunq-Client-Authentication", &token) - .body(body)? - .send()?; - println!("{}", response.text()?); - - self.state = Some(AppState { pem_private, token }); - self.save()?; - - keypair - }; - let token = self.state.unwrap().token; - let body = SessionServer { secret: &api_key }; - let body = serde_json::to_string(&body)?; - let sig = sign(&body, &keypair)?; - let response = isahc::http::Request::post(format!("{}/v1/session-server", BASE)) - .header("X-Bunq-Client-Authentication", &token) - .header("X-Bunq-Client-Signature", &sig) - .body(body)? - .send()? - .text()?; - let r: SessionServerResponse = deserialize_retarded_response(&response)?.response; - Ok(AppConfigReady { - api_key: self.api_key, - keypair, - token: r.token.token, - user_id: r.user_person.id, - }) - } -} - -impl AppConfigReady { - pub fn monetary_accounts(&self) -> Result> { - let response = isahc::http::Request::get(format!( - "{}/v1/user/{}/monetary-account", - BASE, self.user_id - )) - .header("X-Bunq-Client-Authentication", &self.token) - .body(())? - .send()? - .text()?; - Ok( - deserialize_normal_response::>(&response)? - .response - .into_iter() - .map(|m| m.monetary_account_bank) - .collect(), - ) - } - pub fn payments(&self, acc: &MonetaryAccountBank) -> Result> { - let next_page = |url: &str| -> Result<(_, _)> { - let response = isahc::http::Request::get(url) - .header("X-Bunq-Client-Authentication", &self.token) - .body(())? - .send()? - .text()?; - let Response { - response, - pagination, - } = deserialize_normal_response::>(&response)?; - Ok(( - response.into_iter().map(|p| p.payment).collect(), - pagination, - )) - }; - let mut url = format!( - "/v1/user/{}/monetary-account/{}/payment", - self.user_id, acc.id - ); - let mut all = Vec::new(); - loop { - let (mut payments, pag) = next_page(&format!("{}{}", BASE, url))?; - all.append(&mut payments); - if let Some(Pagination{older_url: Some(older_url),..}) = pag { - url = older_url; - } else { - break; - } - } - Ok(all) - } -} - -#[derive(Deserialize, Debug)] -struct LabelMonetaryAccount { - iban: Option, - display_name: String, - merchant_category_code: Option, -} - -#[derive(Deserialize, Debug)] -struct Amount { - value: String, - currency: String, -} - -#[derive(Deserialize, Debug)] -#[serde(rename_all = "PascalCase")] -struct PaymentPayment { - payment: Payment, -} - -#[derive(Deserialize, Debug)] -struct Pagination { - future_url: Option, - newer_url: Option, - older_url: Option, -} - -#[derive(Deserialize, Debug)] -struct Payment { - alias: LabelMonetaryAccount, - counterparty_alias: LabelMonetaryAccount, - amount: Amount, - balance_after_mutation: Amount, - created: String, - updated: String, - description: String, - id: i64, - monetary_account_id: i64, - #[serde(rename = "type")] - type_: String, - sub_type: String, -} - -#[derive(Deserialize, Debug)] -#[serde(rename_all = "PascalCase")] -struct MonetaryAccount { - monetary_account_bank: MonetaryAccountBank, -} -#[derive(Deserialize, Debug)] -struct MonetaryAccountBank { - id: i64, - description: String, -} +use bunqledger::BunqConfig; fn main() -> Result<()> { - let cfg = AppConfig::load()?; + let cfg = BunqConfig::load()?; let cfg = cfg.install()?; let accs = cfg.monetary_accounts()?;