From e237328ec611a5891586530c1d3cb26c16c1093b Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Sun, 1 Oct 2023 00:00:00 +0100 Subject: feat(login) fetch user relays and metadata get user relay list and metadata events from relays when keys are used and last fetch attempt was more than an hour ago uses user's write relays if known, otherwise uses fallback relays to achieve this a method for intergration testing event fetching from relays was added --- src/client.rs | 72 ++++- src/config.rs | 62 +++- src/key_handling/users.rs | 683 ++++++++++++++++++++++++++++++++++++++++- src/login.rs | 153 +++++---- src/main.rs | 4 +- src/sub_commands/login.rs | 30 +- src/sub_commands/prs/create.rs | 3 +- 7 files changed, 923 insertions(+), 84 deletions(-) (limited to 'src') diff --git a/src/client.rs b/src/client.rs index e0e0494..5ddf742 100644 --- a/src/client.rs +++ b/src/client.rs @@ -10,25 +10,32 @@ // which is currently in nightly. alternatively we can use nightly as it looks // certain that the implementation is going to make it to stable but we don't // want to inadvertlty use other features of nightly that might be removed. -use anyhow::Result; +use anyhow::{Context, Result}; use async_trait::async_trait; +use futures::future::join_all; #[cfg(test)] use mockall::*; use nostr::Event; pub struct Client { client: nostr_sdk::Client, - pub fallback_relays: Vec, + fallback_relays: Vec, } -#[async_trait] #[cfg_attr(test, automock)] +#[async_trait] pub trait Connect { fn default() -> Self; fn new(opts: Params) -> Self; async fn connect(&self) -> Result<()>; async fn disconnect(&self) -> Result<()>; + fn get_fallback_relays(&self) -> &Vec; async fn send_event_to(&self, url: &str, event: nostr::event::Event) -> Result; + async fn get_events( + &self, + relays: Vec, + filters: Vec, + ) -> Result>; } #[async_trait] @@ -37,7 +44,7 @@ impl Connect for Client { Client { client: nostr_sdk::Client::new(&nostr::Keys::generate()), fallback_relays: vec![ - "ws://localhost:8080".to_string(), + "ws://localhost:8051".to_string(), "ws://localhost:8052".to_string(), ], } @@ -61,9 +68,52 @@ impl Connect for Client { Ok(()) } + fn get_fallback_relays(&self) -> &Vec { + &self.fallback_relays + } + async fn send_event_to(&self, url: &str, event: Event) -> Result { Ok(self.client.send_event_to(url, event).await?) } + + async fn get_events( + &self, + relays: Vec, + filters: Vec, + ) -> Result> { + // add relays + for relay in &relays { + self.client + .add_relay(relay.as_str(), None) + .await + .context("cannot add relay")?; + } + + let relays_map = self.client.relays().await; + + let relay_results = join_all( + relays + .clone() + .iter() + .map(|r| { + ( + relays_map.get(&nostr::Url::parse(r).unwrap()).unwrap(), + filters.clone(), + ) + }) + .map(|(relay, filters)| { + relay.get_events_of( + filters, + // 20 is nostr_sdk default + std::time::Duration::from_secs(20), + nostr_sdk::FilterOptions::ExitOnEOSE, + ) + }), + ) + .await; + + Ok(get_dedup_events(relay_results)) + } } #[derive(Default)] @@ -82,3 +132,17 @@ impl Params { self } } + +fn get_dedup_events( + relay_results: Vec, nostr_sdk::relay::Error>>, +) -> Vec { + let mut dedup_events: Vec = vec![]; + for events in relay_results.into_iter().flatten() { + for event in events { + if !dedup_events.iter().any(|e| event.id.eq(&e.id)) { + dedup_events.push(event); + } + } + } + dedup_events +} diff --git a/src/config.rs b/src/config.rs index f410934..2370e34 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,7 +4,7 @@ use anyhow::{anyhow, Context, Result}; use directories::ProjectDirs; #[cfg(test)] use mockall::*; -use nostr::secp256k1::XOnlyPublicKey; +use nostr::{secp256k1::XOnlyPublicKey, ToBech32}; use serde::{self, Deserialize, Serialize}; #[derive(Default)] @@ -71,6 +71,66 @@ pub struct MyConfig { pub struct UserRef { pub public_key: XOnlyPublicKey, pub encrypted_key: String, + pub metadata: UserMetadata, + pub relays: UserRelays, + pub last_checked: u64, +} + +impl UserRef { + pub fn new(public_key: XOnlyPublicKey, encrypted_key: String) -> Self { + Self { + public_key, + encrypted_key, + relays: UserRelays { + relays: vec![], + created_at: 0, + }, + metadata: UserMetadata { + #[allow(clippy::expect_used)] + name: public_key + .to_bech32() + .expect("public key should always produce bech32"), + // name: format!( + // "{}", + // public_key + // .to_bech32() + // .expect("public key should always produce bech32"), + // ) + // .as_str()[..10].to_string(), + created_at: 0, + }, + last_checked: 0, + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct UserMetadata { + pub name: String, + pub created_at: u64, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct UserRelays { + pub relays: Vec, + pub created_at: u64, +} + +impl UserRelays { + pub fn write(&self) -> Vec { + self.relays + .iter() + .filter(|r| r.write) + .map(|r| r.url.clone()) + .collect() + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct UserRelayRef { + pub url: String, + pub read: bool, + pub write: bool, } #[cfg(test)] diff --git a/src/key_handling/users.rs b/src/key_handling/users.rs index 1d2cc34..91519bc 100644 --- a/src/key_handling/users.rs +++ b/src/key_handling/users.rs @@ -1,11 +1,21 @@ +use std::time::SystemTime; + use anyhow::{Context, Result}; +use async_trait::async_trait; use nostr::prelude::*; use zeroize::Zeroize; use super::encryption::{EncryptDecrypt, Encryptor}; +#[cfg(not(test))] +use crate::client::Client; +#[cfg(test)] +use crate::client::MockConnect; use crate::{ cli_interactor::{Interactor, InteractorPrompt, PromptInputParms, PromptPasswordParms}, - config::{self, ConfigManagement, ConfigManager}, + client::Connect, + config::{ + self, ConfigManagement, ConfigManager, UserMetadata, UserRef, UserRelayRef, UserRelays, + }, }; #[derive(Default)] @@ -15,13 +25,29 @@ pub struct UserManager { encryptor: Encryptor, } +#[async_trait] pub trait UserManagement { fn add(&self, nsec: &Option, password: &Option) -> Result; + async fn get_user( + &self, + #[cfg(test)] client: &MockConnect, + #[cfg(not(test))] client: &Client, + public_key: &XOnlyPublicKey, + after: u64, + ) -> Result; + fn get_user_from_cache(&self, public_key: &XOnlyPublicKey) -> Result; + fn add_user_to_config( + &self, + public_key: XOnlyPublicKey, + encrypted_secret_key: Option, + overwrite: bool, + ) -> Result<()>; } #[cfg(test)] use duplicate::duplicate_item; #[cfg_attr(test, duplicate_item(UserManager; [UserManager]; [self::tests::MockUserManager]))] +#[async_trait] impl UserManagement for UserManager { fn add(&self, nsec: &Option, password: &Option) -> Result { let mut prompt = "login with nsec (or hex private key)"; @@ -66,9 +92,152 @@ impl UserManagement for UserManager { .context("failed to encrypt nsec with password.")?; pass.zeroize(); - let user_ref = config::UserRef { - public_key: keys.public_key(), - encrypted_key: encrypted_secret_key, + self.add_user_to_config(keys.public_key(), Some(encrypted_secret_key), true)?; + + Ok(keys) + } + + fn add_user_to_config( + &self, + public_key: XOnlyPublicKey, + encrypted_secret_key: Option, + overwrite: bool, + ) -> Result<()> { + let user_ref = + config::UserRef::new(public_key, encrypted_secret_key.unwrap_or(String::new())); + + let mut cfg = self.config_manager.load().context("failed to load application config to find and remove any old versions of the user's encrypted key")?; + // don't overwrite unless specified + if !overwrite + && cfg + .users + .clone() + .into_iter() + .any(|r| r.public_key.eq(&public_key)) + { + return Ok(()); + } + // if overwrite remove any duplicate entries for key before adding it to config + cfg.users = cfg + .users + .clone() + .into_iter() + .filter(|r| !r.public_key.eq(&public_key)) + .collect(); + cfg.users.push(user_ref); + self.config_manager + .save(&cfg) + .context("failed to save application configuration with new user details in") + } + + fn get_user_from_cache(&self, public_key: &XOnlyPublicKey) -> Result { + let cfg = self + .config_manager + .load() + .context("failed to load application config")?; + Ok(cfg + .users + .iter() + .find(|u| u.public_key.eq(public_key)) + .context(format!("pubkey isn't a current user: {public_key}"))? + .clone()) + } + /// get UserRef fetching most recent user relays and metadata infomation + /// from relays + async fn get_user( + &self, + #[cfg(test)] client: &MockConnect, + #[cfg(not(test))] client: &Client, + public_key: &XOnlyPublicKey, + use_cache_unless_checked_more_than_x_secs_ago: u64, + ) -> Result { + let cfg = self + .config_manager + .load() + .context("failed to load application config")?; + let user_ref = cfg + .users + .iter() + .find(|u| u.public_key.eq(public_key)) + .context(format!("pubkey isn't a current user: {public_key}"))?; + // return cache if last fetched was within X minutes + if !unix_timestamp_after_now_plus_secs( + user_ref.last_checked, + use_cache_unless_checked_more_than_x_secs_ago, + ) { + return Ok(user_ref.clone()); + } + let events: Vec = match client + .get_events( + if user_ref.relays.write().is_empty() { + client.get_fallback_relays().clone() + } else { + user_ref.relays.write() + }, + vec![ + nostr::Filter::default() + .author(public_key.to_string()) + .since(nostr::Timestamp::from(user_ref.metadata.created_at + 1)) + .kind(Kind::Metadata), + nostr::Filter::default() + .author(public_key.to_string()) + .since(nostr::Timestamp::from(user_ref.relays.created_at + 1)) + .kind(Kind::RelayList), + ], + ) + .await + { + Ok(events) => events, + Err(_) => { + // TODO error reporting + return Ok(user_ref.clone()); + } + }; + + let mut user_ref = user_ref.clone(); + + user_ref.last_checked = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .context("system time should be after the year 1970")? + .as_secs(); + + if let Some(new_metadata_event) = events + .iter() + .filter(|e| e.kind.eq(&nostr::Kind::Metadata) && e.pubkey.eq(public_key)) + .max_by_key(|e| e.created_at) + { + if new_metadata_event.created_at.as_u64() > user_ref.metadata.created_at { + let metadata = nostr::Metadata::from_json(new_metadata_event.content.clone()) + .context("metadata cannot be found in kind 0 event content")?; + user_ref.metadata = UserMetadata { + name: metadata + .name + .context("user metadata should always have name")?, + created_at: new_metadata_event.created_at.as_u64(), + }; + } + }; + + if let Some(new_relays_event) = events + .iter() + .filter(|e| e.kind.eq(&nostr::Kind::RelayList) && e.pubkey.eq(public_key)) + .max_by_key(|e| e.created_at) + { + if new_relays_event.created_at.as_u64() > user_ref.relays.created_at { + user_ref.relays = UserRelays { + relays: new_relays_event + .tags + .iter() + .filter(|t| t.kind().eq(&nostr::TagKind::R)) + .map(|t| UserRelayRef { + url: t.as_vec()[1].clone(), + read: t.as_vec().len() == 2 || t.as_vec()[2].eq("read"), + write: t.as_vec().len() == 2 || t.as_vec()[2].eq("write"), + }) + .collect(), + created_at: new_relays_event.created_at.as_u64(), + }; + } }; // remove any duplicate entries for key before adding it to config @@ -77,19 +246,27 @@ impl UserManagement for UserManager { .users .clone() .into_iter() - .filter(|r| !r.public_key.eq(&keys.public_key())) + .filter(|r| !r.public_key.eq(public_key)) .collect(); - cfg.users.push(user_ref); + cfg.users.push(user_ref.clone()); self.config_manager .save(&cfg) .context("failed to save application configuration with new user details in")?; + Ok(user_ref) + } +} - Ok(keys) +fn unix_timestamp_after_now_plus_secs(timestamp: u64, secs: u64) -> bool { + if let Ok(now) = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { + now.as_secs() > (timestamp + secs) + } else { + true } } #[cfg(test)] mod tests { + use nostr; use test_utils::*; use super::*; @@ -278,11 +455,10 @@ mod tests { m.config_manager = MockConfigManagement::default(); m.config_manager.expect_load().returning(|| { Ok(MyConfig { - users: vec![UserRef { - public_key: TEST_KEY_1_KEYS.public_key(), - // different key to TEST_KEY_1_ENCYPTED - encrypted_key: TEST_KEY_2_ENCRYPTED.into(), - }], + users: vec![UserRef::new( + TEST_KEY_1_KEYS.public_key(), + TEST_KEY_2_ENCRYPTED.into(), + )], ..MyConfig::default() }) }); @@ -308,10 +484,10 @@ mod tests { m.config_manager = MockConfigManagement::default(); m.config_manager.expect_load().returning(|| { Ok(MyConfig { - users: vec![UserRef { - public_key: TEST_KEY_2_KEYS.public_key(), - encrypted_key: TEST_KEY_2_ENCRYPTED.into(), - }], + users: vec![UserRef::new( + TEST_KEY_2_KEYS.public_key(), + TEST_KEY_2_ENCRYPTED.into(), + )], ..MyConfig::default() }) }); @@ -329,4 +505,479 @@ mod tests { } } } + + fn now_timestamp() -> u64 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs() + } + fn roughly_now(timestamp: u64) -> bool { + let now = now_timestamp(); + timestamp < now + 100 && timestamp > now - 100 + } + + mod get_user { + use anyhow::anyhow; + + use super::*; + use crate::client::MockConnect; + + fn generate_relaylist_event() -> nostr::Event { + nostr::event::EventBuilder::new( + nostr::Kind::RelayList, + "", + &[ + nostr::Tag::RelayMetadata( + "wss://fredswrite1.relay".into(), + Some(nostr::RelayMetadata::Write), + ), + nostr::Tag::RelayMetadata( + "wss://fredsread1.relay".into(), + Some(nostr::RelayMetadata::Read), + ), + nostr::Tag::RelayMetadata("wss://fredsreadwrite.relay".into(), None), + ], + ) + .to_event(&TEST_KEY_1_KEYS) + .unwrap() + } + + fn generate_relaylist_event_user_2() -> nostr::Event { + nostr::event::EventBuilder::new( + nostr::Kind::RelayList, + "", + &[ + nostr::Tag::RelayMetadata( + "wss://carolswrite1.relay".into(), + Some(nostr::RelayMetadata::Write), + ), + nostr::Tag::RelayMetadata( + "wss://carolsread1.relay".into(), + Some(nostr::RelayMetadata::Read), + ), + nostr::Tag::RelayMetadata("wss://carolsreadwrite.relay".into(), None), + ], + ) + .to_event(&TEST_KEY_2_KEYS) + .unwrap() + } + + fn fallback_relays() -> Vec { + vec!["ws://fallback1".to_string(), "ws://fallback2".to_string()].clone() + } + + fn generate_mock_client() -> MockConnect { + let mut client = ::default(); + client + .expect_get_fallback_relays() + .return_const(fallback_relays()); + client + } + + fn generate_standard_config() -> MyConfig { + MyConfig { + users: vec![UserRef { + public_key: TEST_KEY_1_KEYS.public_key(), + encrypted_key: TEST_KEY_1_ENCRYPTED.to_string(), + metadata: UserMetadata { + name: "Fred".to_string(), + created_at: 10, + }, + relays: UserRelays { + relays: vec![ + UserRelayRef { + url: "ws://existingread".to_string(), + read: true, + write: false, + }, + UserRelayRef { + url: "ws://existingreadwrite".to_string(), + read: true, + write: true, + }, + UserRelayRef { + url: "ws://existingwrite".to_string(), + read: false, + write: true, + }, + ], + created_at: 10, + }, + last_checked: now_timestamp() - (60 * 60), // 1h ago + }], + ..MyConfig::default() + } + .clone() + } + + fn expected_userrelayrefs() -> Vec { + vec![ + UserRelayRef { + url: "wss://fredswrite1.relay".into(), + read: false, + write: true, + }, + UserRelayRef { + url: "wss://fredsread1.relay".into(), + read: true, + write: false, + }, + UserRelayRef { + url: "wss://fredsreadwrite.relay".into(), + read: true, + write: true, + }, + ] + } + + mod when_within_caching_time_window { + use super::*; + + #[test] + fn returns_cached_details_without_checking_relays_or_updaing_config() -> Result<()> { + let mut m = MockUserManager::default(); + let client = generate_mock_client(); + m.config_manager + .expect_load() + .returning(|| Ok(generate_standard_config())); + let res = futures::executor::block_on(m.get_user( + &client, + &TEST_KEY_1_KEYS.public_key(), + 24 * 60 * 60, // within 24 hours + ))?; + assert_eq!(res.metadata.name, "Fred"); + assert_eq!(res.relays.relays[0].url, "ws://existingread"); + Ok(()) + } + } + + mod returns_userref_with_latest_details_from_events_on_relays { + use super::*; + + #[test] + fn name() -> Result<()> { + let mut m = MockUserManager::default(); + let mut client = generate_mock_client(); + m.config_manager + .expect_load() + .returning(|| Ok(generate_standard_config())); + m.config_manager.expect_save().returning(|_| Ok(())); + client + .expect_get_events() + .returning(|_, _| Ok(vec![generate_test_key_1_metadata_event("fred")])); + + let res = futures::executor::block_on(m.get_user( + &client, + &TEST_KEY_1_KEYS.public_key(), + 5 * 60, // 5 mins ago + ))?; + assert_eq!(res.metadata.name, "fred"); + Ok(()) + } + + #[test] + fn name_ignoring_other_users_events() -> Result<()> { + let mut m = MockUserManager::default(); + let mut client = generate_mock_client(); + m.config_manager + .expect_load() + .returning(|| Ok(generate_standard_config())); + m.config_manager.expect_save().returning(|_| Ok(())); + client.expect_get_events().returning(|_, _| { + Ok(vec![ + generate_test_key_2_metadata_event("carole"), + generate_test_key_1_metadata_event_old("fred"), + ]) + }); + + let res = futures::executor::block_on(m.get_user( + &client, + &TEST_KEY_1_KEYS.public_key(), + 5 * 60, // 5 mins ago + ))?; + assert_eq!(res.metadata.name, "fred"); + Ok(()) + } + + #[test] + fn relays() -> Result<()> { + let mut m = MockUserManager::default(); + let mut client = generate_mock_client(); + m.config_manager + .expect_load() + .returning(|| Ok(generate_standard_config())); + m.config_manager.expect_save().returning(|_| Ok(())); + client.expect_get_events().returning(|_, _| { + Ok(vec![ + generate_test_key_1_metadata_event("fred"), + generate_relaylist_event(), + ]) + }); + + let res = futures::executor::block_on(m.get_user( + &client, + &TEST_KEY_1_KEYS.public_key(), + 5 * 60, // 5 mins ago + ))?; + assert_eq!(res.relays.relays, expected_userrelayrefs(),); + Ok(()) + } + + #[test] + fn relays_ignoring_other_users_events() -> Result<()> { + let mut m = MockUserManager::default(); + let mut client = generate_mock_client(); + m.config_manager + .expect_load() + .returning(|| Ok(generate_standard_config())); + m.config_manager.expect_save().returning(|_| Ok(())); + client.expect_get_events().returning(|_, _| { + Ok(vec![ + make_event_old_or_change_user( + generate_relaylist_event(), + &TEST_KEY_1_KEYS, + 10000, + ), + generate_relaylist_event_user_2(), + ]) + }); + + let res = futures::executor::block_on(m.get_user( + &client, + &TEST_KEY_1_KEYS.public_key(), + 5 * 60, // 5 mins ago + ))?; + assert_eq!(res.relays.relays, expected_userrelayrefs(),); + Ok(()) + } + } + + mod saves_updates_to_config { + use super::*; + + #[test] + fn saves_name_to_config() -> Result<()> { + let mut m = MockUserManager::default(); + let mut client = generate_mock_client(); + m.config_manager + .expect_load() + .returning(|| Ok(generate_standard_config())); + m.config_manager + .expect_save() + .once() + .withf(|cfg| cfg.users[0].metadata.name.eq("fred")) + .returning(|_| Ok(())); + client + .expect_get_events() + .returning(|_, _| Ok(vec![generate_test_key_1_metadata_event("fred")])); + + futures::executor::block_on(m.get_user( + &client, + &TEST_KEY_1_KEYS.public_key(), + 5 * 60, // 5 mins ago + ))?; + Ok(()) + } + + #[test] + fn updates_metadata_created_at() -> Result<()> { + let mut m = MockUserManager::default(); + let mut client = generate_mock_client(); + m.config_manager + .expect_load() + .returning(|| Ok(generate_standard_config())); + m.config_manager + .expect_save() + .once() + .withf(|cfg| roughly_now(cfg.users[0].metadata.created_at)) + .returning(|_| Ok(())); + client + .expect_get_events() + .returning(|_, _| Ok(vec![generate_test_key_1_metadata_event("fred")])); + + futures::executor::block_on(m.get_user( + &client, + &TEST_KEY_1_KEYS.public_key(), + 5 * 60, // 5 mins ago + ))?; + Ok(()) + } + + #[test] + fn saves_relays_to_config() -> Result<()> { + let mut m = MockUserManager::default(); + let mut client = generate_mock_client(); + m.config_manager + .expect_load() + .returning(|| Ok(generate_standard_config())); + m.config_manager + .expect_save() + .once() + .withf(|cfg| expected_userrelayrefs().eq(&cfg.users[0].relays.relays)) + .returning(|_| Ok(())); + client + .expect_get_events() + .returning(|_, _| Ok(vec![generate_relaylist_event()])); + + futures::executor::block_on(m.get_user( + &client, + &TEST_KEY_1_KEYS.public_key(), + 5 * 60, // 5 mins ago + ))?; + Ok(()) + } + + #[test] + fn updates_relays_created_at() -> Result<()> { + let mut m = MockUserManager::default(); + let mut client = generate_mock_client(); + m.config_manager + .expect_load() + .returning(|| Ok(generate_standard_config())); + m.config_manager + .expect_save() + .once() + .withf(|cfg| roughly_now(cfg.users[0].relays.created_at)) + .returning(|_| Ok(())); + client + .expect_get_events() + .returning(|_, _| Ok(vec![generate_relaylist_event()])); + + futures::executor::block_on(m.get_user( + &client, + &TEST_KEY_1_KEYS.public_key(), + 5 * 60, // 5 mins ago + ))?; + Ok(()) + } + + #[test] + fn when_no_changes_updates_last_updated() -> Result<()> { + let mut m = MockUserManager::default(); + let mut client = generate_mock_client(); + m.config_manager + .expect_load() + .returning(|| Ok(generate_standard_config())); + m.config_manager + .expect_save() + .once() + .withf(|cfg| roughly_now(cfg.users[0].last_checked)) + .returning(|_| Ok(())); + client.expect_get_events().returning(|_, _| Ok(vec![])); + + futures::executor::block_on(m.get_user( + &client, + &TEST_KEY_1_KEYS.public_key(), + 5 * 60, // 5 mins ago + ))?; + Ok(()) + } + + #[test] + fn when_changes_updates_last_updated() -> Result<()> { + let mut m = MockUserManager::default(); + let mut client = generate_mock_client(); + m.config_manager + .expect_load() + .returning(|| Ok(generate_standard_config())); + m.config_manager + .expect_save() + .once() + .withf(|cfg| roughly_now(cfg.users[0].last_checked)) + .returning(|_| Ok(())); + client + .expect_get_events() + .returning(|_, _| Ok(vec![generate_test_key_1_metadata_event("fred")])); + + futures::executor::block_on(m.get_user( + &client, + &TEST_KEY_1_KEYS.public_key(), + 5 * 60, // 5 mins ago + ))?; + Ok(()) + } + } + + mod fetches_from_correct_relays { + use super::*; + #[test] + fn when_userref_write_relays_present_fetches_only_from_them() -> Result<()> { + let mut m = MockUserManager::default(); + let mut client = generate_mock_client(); + m.config_manager + .expect_load() + .returning(|| Ok(generate_standard_config())); + m.config_manager.expect_save().returning(|_| Ok(())); + client + .expect_get_events() + .once() + .withf(move |relays, _filters| { + vec![ + "ws://existingreadwrite".to_string(), + "ws://existingwrite".to_string(), + ] + .eq(relays) + }) + .returning(|_, _| Ok(vec![])); + + futures::executor::block_on(m.get_user( + &client, + &TEST_KEY_1_KEYS.public_key(), + 5 * 60, // 5 mins ago + ))?; + Ok(()) + } + #[test] + fn when_userref_write_relays_not_present_fetches_from_fallback_relays() -> Result<()> { + let mut m = MockUserManager::default(); + let mut client = generate_mock_client(); + m.config_manager.expect_load().returning(|| { + Ok(MyConfig { + users: vec![UserRef { + relays: UserRelays { + relays: vec![], + created_at: 0, + }, + ..generate_standard_config().users[0].clone() + }], + ..generate_standard_config() + }) + }); + m.config_manager.expect_save().returning(|_| Ok(())); + client + .expect_get_events() + .once() + .withf(move |relays, _filters| fallback_relays().eq(relays)) + .returning(|_, _| Ok(vec![])); + + futures::executor::block_on(m.get_user( + &client, + &TEST_KEY_1_KEYS.public_key(), + 5 * 60, // 5 mins ago + ))?; + Ok(()) + } + } + + #[test] + fn when_failed_to_fetch_events_returns_cached_details() -> Result<()> { + let mut m = MockUserManager::default(); + let mut client = generate_mock_client(); + m.config_manager + .expect_load() + .returning(|| Ok(generate_standard_config())); + client + .expect_get_events() + .returning(|_, _| Err(anyhow!("test error"))); + + let res = futures::executor::block_on(m.get_user( + &client, + &TEST_KEY_1_KEYS.public_key(), + 5 * 60, // 10 mins ago + ))?; + assert_eq!(res.metadata.name, "Fred"); + Ok(()) + } + } } diff --git a/src/login.rs b/src/login.rs index 12fe76e..e73373a 100644 --- a/src/login.rs +++ b/src/login.rs @@ -1,10 +1,14 @@ use anyhow::{bail, Context, Result}; -use nostr::prelude::{FromSkStr, ToBech32}; +use nostr::{prelude::FromSkStr, secp256k1::XOnlyPublicKey}; use zeroize::Zeroize; +#[cfg(not(test))] +use crate::client::Client; +#[cfg(test)] +use crate::client::MockConnect; use crate::{ cli_interactor::{Interactor, InteractorPrompt, PromptPasswordParms}, - config::{ConfigManagement, ConfigManager}, + config::{ConfigManagement, ConfigManager, UserRef}, key_handling::{ encryption::{EncryptDecrypt, Encryptor}, users::{UserManagement, UserManager}, @@ -12,77 +16,114 @@ use crate::{ }; /// handles the encrpytion and storage of key material -pub fn launch(nsec: &Option, password: &Option) -> Result { +pub async fn launch( + nsec: &Option, + password: &Option, + #[cfg(test)] client: Option<&MockConnect>, + #[cfg(not(test))] client: Option<&Client>, +) -> Result { // if nsec parameter - if let Some(nsec_unwrapped) = nsec { + let key = if let Some(nsec_unwrapped) = nsec { // get key or fail without prompts let key = nostr::Keys::from_sk_str(nsec_unwrapped).context("invalid nsec parameter")?; - println!( - "logged in as {}", - &key.public_key() - .to_bech32() - .context("public key should always produce bech32")? - ); // if password, add user to enable password login in future if password.is_some() { UserManager::default() .add(nsec, password) .context("could not store identity")?; + } else { + UserManager::default().add_user_to_config(key.public_key(), None, false)?; } - return Ok(key); - } + key + } else { + let cfg = ConfigManager + .load() + .context("failed to load application config")?; + // if encrypted nsec present + if cfg.users.last().is_some() && !cfg.users.last().unwrap().encrypted_key.is_empty() { + // unfortunately this line is unstable in rust: + // if let Some(user) = cfg.users.last() && !user.encrypted_key.is_empty() { + let user = cfg.users.last().unwrap(); + let mut pass = if let Some(p) = password.clone() { + p + } else { + println!("login as {}", &user.metadata.name); + Interactor::default() + .password(PromptPasswordParms::default().with_prompt("password")) + .context("failed to get password input from interactor.password")? + }; - // if encrypted nsec stored, attempt password - let cfg = ConfigManager - .load() - .context("failed to load application config")?; - let key = if let Some(user) = cfg.users.last() { - let mut pass = if let Some(p) = password.clone() { - p - } else { - println!( - "login as {}", - &user - .public_key - .to_bech32() - .context("public key should always produce bech32")? - ); - Interactor::default() - .password(PromptPasswordParms::default().with_prompt("password")) - .context("failed to get password input from interactor.password")? - }; + let key_result = Encryptor + .decrypt_key(&user.encrypted_key, pass.as_str()) + .context("failed to decrypt key with provided password"); + pass.zeroize(); - let key_result = Encryptor - .decrypt_key(&user.encrypted_key, pass.as_str()) - .context("failed to decrypt key with provided password"); - pass.zeroize(); + key_result.context(format!("failed to log in as {}", &user.metadata.name))? + } + // no encrypted nsec present + else { + // no nsec but password supplied + if password.is_some() { + bail!("no nsec available to decrypt with specified password"); + } + // otherwise add new user with nsec and password prompts + UserManager::default() + .add(nsec, password) + .context("failed to add user")? + } + }; - key_result.context(format!( - "failed to log in as {}", - &user - .public_key - .to_bech32() - .context("public key should always produce bech32")? - ))? + // get user details + let user_ref = if let Some(client) = client { + get_user_details(&key.public_key(), client).await? } else { - // no nsec but password supplied - if password.is_some() { - bail!("no nsec available to decrypt with specified password"); - } - // otherwise add new user with nsec and password prompts + // this will get user details with name as npub UserManager::default() - .add(nsec, password) - .context("failed to add user")? + .get_user_from_cache(&key.public_key())? + .clone() }; - println!( - "logged in as {}", - &key.public_key() - .to_bech32() - .context("public key should always produce bech32")? - ); - // fetching metdata + // print logged in + println!("logged in as {}", user_ref.metadata.name); Ok(key) } + +async fn get_user_details( + public_key: &XOnlyPublicKey, + #[cfg(test)] client: &crate::client::MockConnect, + #[cfg(not(test))] client: &Client, +) -> Result { + let term = console::Term::stdout(); + term.write_line("searching for your details...")?; + let user_manager = UserManager::default(); + let user_ref = user_manager + .get_user( + client, + public_key, + // 1 hour + 60 * 60, + ) + .await?; + term.clear_last_lines(1)?; + if user_ref.metadata.created_at.eq(&0) { + println!("cannot find your account metadata (name, etc) on relays",); + // TODO use secondary fallback list of relays. + // TODO better reporting of what relays were checked and what the user + // here is a starter: + // cannot find account details on relays: + // - purplepages.xyz + // - fallbackrelay1 + // - ... + // would you like to: + // [-] proceed anyway + // - add custom fallback relays + } else if user_ref.relays.created_at.eq(&0) { + println!( + "cannot find your relay list. consider using another nostr client to create one to enhance your nostr experience." + ); + // TODO better guidance on how to do this + } + Ok(user_ref) +} diff --git a/src/main.rs b/src/main.rs index 5c8518b..68b0ed6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,7 +41,9 @@ enum Commands { async fn main() -> Result<()> { let cli = Cli::parse(); match &cli.command { - Commands::Login(args) => sub_commands::login::launch(&cli, args), + Commands::Login(args) => { + futures::executor::block_on(sub_commands::login::launch(&cli, args)) + } Commands::Prs(args) => futures::executor::block_on(sub_commands::prs::launch(&cli, args)), } } diff --git a/src/sub_commands/login.rs b/src/sub_commands/login.rs index 5391024..b93e9bc 100644 --- a/src/sub_commands/login.rs +++ b/src/sub_commands/login.rs @@ -1,12 +1,32 @@ use anyhow::Result; use clap; -use crate::{login, Cli}; +#[cfg(not(test))] +use crate::client::Client; +#[cfg(test)] +use crate::client::MockConnect; +use crate::{client::Connect, login, Cli}; #[derive(clap::Args)] -pub struct SubCommandArgs; +pub struct SubCommandArgs { + /// don't fetch user metadata and relay list from relays + #[arg(long, action)] + offline: bool, +} + +pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { + if command_args.offline { + login::launch(&args.nsec, &args.password, None).await?; + Ok(()) + } else { + #[cfg(not(test))] + let client = Client::default(); + #[cfg(test)] + let client = ::default(); -pub fn launch(args: &Cli, _command_args: &SubCommandArgs) -> Result<()> { - let _ = login::launch(&args.nsec, &args.password)?; - Ok(()) + client.connect().await?; + login::launch(&args.nsec, &args.password, Some(&client)).await?; + client.disconnect().await?; + Ok(()) + } } diff --git a/src/sub_commands/prs/create.rs b/src/sub_commands/prs/create.rs index 3047e92..0249488 100644 --- a/src/sub_commands/prs/create.rs +++ b/src/sub_commands/prs/create.rs @@ -86,7 +86,8 @@ pub async fn launch( // create PR event - let keys = login::launch(&cli_args.nsec, &cli_args.password)?; + // TODO add client here + let keys = login::launch(&cli_args.nsec, &cli_args.password, None).await?; let events = generate_pr_and_patch_events(&title, &description, &to_branch, &git_repo, &ahead, keys)?; -- cgit v1.2.3