use anyhow::{anyhow, Result}; use isahc::http::StatusCode; use isahc::prelude::*; use rsa::pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey}; use rsa::RsaPrivateKey; use rsa::pkcs1v15::SigningKey; use rsa::pkcs8::{EncodePublicKey, LineEnding}; use rsa::signature::RandomizedSigner; use rsa::sha2::Sha256; 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: &RsaPrivateKey) -> Result { let signing_key = SigningKey::::new(key.clone()); let mut rng = rand::thread_rng(); let signature = signing_key.sign_with_rng(&mut rng, body.as_bytes()); Ok(base64::encode(signature.to_string())) } #[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: RsaPrivateKey, user_id: i64, } impl BunqConfig { pub fn load() -> Result { println!("Loading config file from {}", confy::get_configuration_file_path("bunq-rs", "bunq-rs")?.to_string_lossy()); Ok(confy::load("bunq-rs", "bunq-rs")?) } pub fn save(&self) -> Result<()> { println!("Storing config file in {}", confy::get_configuration_file_path("bunq-rs", None)?.to_string_lossy()); confy::store("bunq-rs", "bunq-rs", self)?; Ok(()) } pub fn install(mut self) -> Result { let api_key = &self.api_key; let keypair = if let Some(state) = &self.state { RsaPrivateKey::from_pkcs1_pem(&*state.pem_private)? } else { let mut rng = rand::thread_rng(); let bits = 2048; let keypair = RsaPrivateKey::new(&mut rng, bits).expect("failed to generate a key"); let pem_public = keypair.to_public_key().to_public_key_pem(LineEnding::CRLF)?; 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()?; let response_text = response.text()?; println!("{}", response_text); if response.status() != StatusCode::OK { return Err(anyhow!(response_text)); } self.state = Some(AppState { pem_private: keypair.to_pkcs1_pem(LineEnding::CRLF)?.to_string(), 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()?; let accounts = deserialize_normal_response::>(&response)?; Ok(accounts.response) } pub fn payments(&self, acc: &MonetaryAccount) -> Result> { self.payments_from_to(acc, None, None) } pub fn payments_from_to( &self, acc: &MonetaryAccount, from: Option, to: Option, ) -> 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 account_id = match acc { MonetaryAccount::MonetaryAccountBank(bank) => bank.id, MonetaryAccount::MonetaryAccountSavings(savings) => savings.id, }; let mut url = format!( "/v1/user/{}/monetary-account/{}/payment", self.user_id, account_id ); if let Some(to) = to { url = format!("{}?newer_id={}", url, to); } let mut all = Vec::new(); loop { let (mut payments, pag) = next_page(&format!("{}{}", BASE, url))?; all.append(&mut payments); dbg!(&pag); if let Some(Pagination { older_url: Some(older_url), .. }) = pag { if let (Some(latest), Some(from)) = (all.last(), from) { if latest.id <= from { all = all.into_iter().filter(|p| p.id >= from).collect(); break; } } 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")] pub enum MonetaryAccount { MonetaryAccountBank(MonetaryAccountBank), MonetaryAccountSavings(MonetaryAccountSavings), } #[derive(Deserialize, Debug)] pub struct MonetaryAccountBank { pub id: i64, pub description: String, } #[derive(Deserialize, Debug)] pub struct MonetaryAccountSavings { pub id: i64, pub description: String, }