diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2024-11-21 16:53:17 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2024-11-21 16:53:17 +0000 |
| commit | f79014235e85554e3661b3f2a02b8fa88bc192ff (patch) | |
| tree | fceec3ff2df212148a3420af7cef81a3f818463e /src/lib | |
| parent | 91b0eac4daf92b7b740267ef203a1a8ba591974b (diff) | |
feat(login): overhaul login experience
* simplify login menu, making it more accessable to newcomers and
easier to select remote signer options
* enable `ngit login` to work from anywhere (not just a git repo)
* assume fresh login details saved to global git config but fallback
to local repository
* maintain local repository login via `ngit login --local`
* maintain login via CLI arguments eg `ngit send --nsec nsec123`
* nudge users to remember nsec when pasting in ncryptsec for a
better UX, whilst maintaining the option to be prompted for
password everytime
* create placeholder menu items for help menu and create account
Diffstat (limited to 'src/lib')
| -rw-r--r-- | src/lib/cli_interactor.rs | 30 | ||||
| -rw-r--r-- | src/lib/client.rs | 174 | ||||
| -rw-r--r-- | src/lib/git/mod.rs | 38 | ||||
| -rw-r--r-- | src/lib/login/existing.rs | 212 | ||||
| -rw-r--r-- | src/lib/login/fresh.rs | 595 | ||||
| -rw-r--r-- | src/lib/login/key_encryption.rs | 38 | ||||
| -rw-r--r-- | src/lib/login/mod.rs | 883 | ||||
| -rw-r--r-- | src/lib/login/user.rs | 155 | ||||
| -rw-r--r-- | src/lib/repo_ref.rs | 7 |
9 files changed, 1204 insertions, 928 deletions
diff --git a/src/lib/cli_interactor.rs b/src/lib/cli_interactor.rs index dcaccf1..7d67961 100644 --- a/src/lib/cli_interactor.rs +++ b/src/lib/cli_interactor.rs | |||
| @@ -24,11 +24,13 @@ impl InteractorPrompt for Interactor { | |||
| 24 | if !parms.default.is_empty() { | 24 | if !parms.default.is_empty() { |
| 25 | input.default(parms.default); | 25 | input.default(parms.default); |
| 26 | } | 26 | } |
| 27 | input.report(parms.report); | ||
| 27 | Ok(input.interact_text()?) | 28 | Ok(input.interact_text()?) |
| 28 | } | 29 | } |
| 29 | fn password(&self, parms: PromptPasswordParms) -> Result<String> { | 30 | fn password(&self, parms: PromptPasswordParms) -> Result<String> { |
| 30 | let mut p = Password::with_theme(&self.theme); | 31 | let mut p = Password::with_theme(&self.theme); |
| 31 | p.with_prompt(parms.prompt); | 32 | p.with_prompt(parms.prompt); |
| 33 | p.report(parms.report); | ||
| 32 | if parms.confirm { | 34 | if parms.confirm { |
| 33 | p.with_confirmation("confirm password", "passwords didnt match..."); | 35 | p.with_confirmation("confirm password", "passwords didnt match..."); |
| 34 | } | 36 | } |
| @@ -44,10 +46,10 @@ impl InteractorPrompt for Interactor { | |||
| 44 | } | 46 | } |
| 45 | fn choice(&self, parms: PromptChoiceParms) -> Result<usize> { | 47 | fn choice(&self, parms: PromptChoiceParms) -> Result<usize> { |
| 46 | let mut choice = dialoguer::Select::with_theme(&self.theme); | 48 | let mut choice = dialoguer::Select::with_theme(&self.theme); |
| 47 | choice | 49 | if !parms.prompt.is_empty() { |
| 48 | .with_prompt(parms.prompt) | 50 | choice.with_prompt(parms.prompt); |
| 49 | .report(parms.report) | 51 | } |
| 50 | .items(&parms.choices); | 52 | choice.report(parms.report).items(&parms.choices); |
| 51 | if let Some(default) = parms.default { | 53 | if let Some(default) = parms.default { |
| 52 | if std::env::var("NGITTEST").is_err() { | 54 | if std::env::var("NGITTEST").is_err() { |
| 53 | choice.default(default); | 55 | choice.default(default); |
| @@ -73,6 +75,7 @@ impl InteractorPrompt for Interactor { | |||
| 73 | pub struct PromptInputParms { | 75 | pub struct PromptInputParms { |
| 74 | pub prompt: String, | 76 | pub prompt: String, |
| 75 | pub default: String, | 77 | pub default: String, |
| 78 | pub report: bool, | ||
| 76 | pub optional: bool, | 79 | pub optional: bool, |
| 77 | } | 80 | } |
| 78 | 81 | ||
| @@ -89,12 +92,18 @@ impl PromptInputParms { | |||
| 89 | self.optional = true; | 92 | self.optional = true; |
| 90 | self | 93 | self |
| 91 | } | 94 | } |
| 95 | |||
| 96 | pub fn dont_report(mut self) -> Self { | ||
| 97 | self.report = false; | ||
| 98 | self | ||
| 99 | } | ||
| 92 | } | 100 | } |
| 93 | 101 | ||
| 94 | #[derive(Default)] | 102 | #[derive(Default)] |
| 95 | pub struct PromptPasswordParms { | 103 | pub struct PromptPasswordParms { |
| 96 | pub prompt: String, | 104 | pub prompt: String, |
| 97 | pub confirm: bool, | 105 | pub confirm: bool, |
| 106 | pub report: bool, | ||
| 98 | } | 107 | } |
| 99 | 108 | ||
| 100 | impl PromptPasswordParms { | 109 | impl PromptPasswordParms { |
| @@ -106,6 +115,10 @@ impl PromptPasswordParms { | |||
| 106 | self.confirm = true; | 115 | self.confirm = true; |
| 107 | self | 116 | self |
| 108 | } | 117 | } |
| 118 | pub fn dont_report(mut self) -> Self { | ||
| 119 | self.report = false; | ||
| 120 | self | ||
| 121 | } | ||
| 109 | } | 122 | } |
| 110 | 123 | ||
| 111 | #[derive(Default)] | 124 | #[derive(Default)] |
| @@ -140,10 +153,11 @@ impl PromptChoiceParms { | |||
| 140 | self | 153 | self |
| 141 | } | 154 | } |
| 142 | 155 | ||
| 143 | // pub fn dont_report(mut self) -> Self { | 156 | pub fn dont_report(mut self) -> Self { |
| 144 | // self.report = false; | 157 | self.report = false; |
| 145 | // self | 158 | self |
| 146 | // } | 159 | } |
| 160 | |||
| 147 | pub fn with_choices(mut self, choices: Vec<String>) -> Self { | 161 | pub fn with_choices(mut self, choices: Vec<String>) -> Self { |
| 148 | self.choices = choices; | 162 | self.choices = choices; |
| 149 | self | 163 | self |
diff --git a/src/lib/client.rs b/src/lib/client.rs index 676fff8..a8b48f4 100644 --- a/src/lib/client.rs +++ b/src/lib/client.rs | |||
| @@ -43,7 +43,7 @@ use crate::{ | |||
| 43 | git_events::{ | 43 | git_events::{ |
| 44 | event_is_cover_letter, event_is_patch_set_root, event_is_revision_root, status_kinds, | 44 | event_is_cover_letter, event_is_patch_set_root, event_is_revision_root, status_kinds, |
| 45 | }, | 45 | }, |
| 46 | login::{get_logged_in_user, get_user_ref_from_cache}, | 46 | login::{get_likely_logged_in_user, user::get_user_ref_from_cache}, |
| 47 | repo_ref::RepoRef, | 47 | repo_ref::RepoRef, |
| 48 | repo_state::RepoState, | 48 | repo_state::RepoState, |
| 49 | }; | 49 | }; |
| @@ -69,9 +69,9 @@ pub trait Connect { | |||
| 69 | fn get_more_fallback_relays(&self) -> &Vec<String>; | 69 | fn get_more_fallback_relays(&self) -> &Vec<String>; |
| 70 | fn get_blaster_relays(&self) -> &Vec<String>; | 70 | fn get_blaster_relays(&self) -> &Vec<String>; |
| 71 | fn get_fallback_signer_relays(&self) -> &Vec<String>; | 71 | fn get_fallback_signer_relays(&self) -> &Vec<String>; |
| 72 | async fn send_event_to( | 72 | async fn send_event_to<'a>( |
| 73 | &self, | 73 | &self, |
| 74 | git_repo_path: &Path, | 74 | git_repo_path: Option<&'a Path>, |
| 75 | url: &str, | 75 | url: &str, |
| 76 | event: nostr::event::Event, | 76 | event: nostr::event::Event, |
| 77 | ) -> Result<nostr::EventId>; | 77 | ) -> Result<nostr::EventId>; |
| @@ -86,15 +86,15 @@ pub trait Connect { | |||
| 86 | filters: Vec<nostr::Filter>, | 86 | filters: Vec<nostr::Filter>, |
| 87 | progress_reporter: MultiProgress, | 87 | progress_reporter: MultiProgress, |
| 88 | ) -> Result<(Vec<Result<Vec<nostr::Event>>>, MultiProgress)>; | 88 | ) -> Result<(Vec<Result<Vec<nostr::Event>>>, MultiProgress)>; |
| 89 | async fn fetch_all( | 89 | async fn fetch_all<'a>( |
| 90 | &self, | 90 | &self, |
| 91 | git_repo_path: &Path, | 91 | git_repo_path: Option<&'a Path>, |
| 92 | repo_coordinates: &HashSet<Coordinate>, | 92 | repo_coordinates: &HashSet<Coordinate>, |
| 93 | user_profiles: &HashSet<PublicKey>, | 93 | user_profiles: &HashSet<PublicKey>, |
| 94 | ) -> Result<(Vec<Result<FetchReport>>, MultiProgress)>; | 94 | ) -> Result<(Vec<Result<FetchReport>>, MultiProgress)>; |
| 95 | async fn fetch_all_from_relay( | 95 | async fn fetch_all_from_relay<'a>( |
| 96 | &self, | 96 | &self, |
| 97 | git_repo_path: &Path, | 97 | git_repo_path: Option<&'a Path>, |
| 98 | request: FetchRequest, | 98 | request: FetchRequest, |
| 99 | pb: &Option<ProgressBar>, | 99 | pb: &Option<ProgressBar>, |
| 100 | ) -> Result<FetchReport>; | 100 | ) -> Result<FetchReport>; |
| @@ -214,9 +214,9 @@ impl Connect for Client { | |||
| 214 | &self.fallback_signer_relays | 214 | &self.fallback_signer_relays |
| 215 | } | 215 | } |
| 216 | 216 | ||
| 217 | async fn send_event_to( | 217 | async fn send_event_to<'a>( |
| 218 | &self, | 218 | &self, |
| 219 | git_repo_path: &Path, | 219 | git_repo_path: Option<&'a Path>, |
| 220 | url: &str, | 220 | url: &str, |
| 221 | event: Event, | 221 | event: Event, |
| 222 | ) -> Result<nostr::EventId> { | 222 | ) -> Result<nostr::EventId> { |
| @@ -228,7 +228,9 @@ impl Connect for Client { | |||
| 228 | .await? | 228 | .await? |
| 229 | .send_event(event.clone()) | 229 | .send_event(event.clone()) |
| 230 | .await?; | 230 | .await?; |
| 231 | save_event_in_cache(git_repo_path, &event).await?; | 231 | if let Some(git_repo_path) = git_repo_path { |
| 232 | save_event_in_local_cache(git_repo_path, &event).await?; | ||
| 233 | } | ||
| 232 | if event.kind.eq(&Kind::GitRepoAnnouncement) { | 234 | if event.kind.eq(&Kind::GitRepoAnnouncement) { |
| 233 | save_event_in_global_cache(git_repo_path, &event).await?; | 235 | save_event_in_global_cache(git_repo_path, &event).await?; |
| 234 | } | 236 | } |
| @@ -324,9 +326,9 @@ impl Connect for Client { | |||
| 324 | } | 326 | } |
| 325 | 327 | ||
| 326 | #[allow(clippy::too_many_lines)] | 328 | #[allow(clippy::too_many_lines)] |
| 327 | async fn fetch_all( | 329 | async fn fetch_all<'a>( |
| 328 | &self, | 330 | &self, |
| 329 | git_repo_path: &Path, | 331 | git_repo_path: Option<&'a Path>, |
| 330 | repo_coordinates: &HashSet<Coordinate>, | 332 | repo_coordinates: &HashSet<Coordinate>, |
| 331 | user_profiles: &HashSet<PublicKey>, | 333 | user_profiles: &HashSet<PublicKey>, |
| 332 | ) -> Result<(Vec<Result<FetchReport>>, MultiProgress)> { | 334 | ) -> Result<(Vec<Result<FetchReport>>, MultiProgress)> { |
| @@ -496,9 +498,9 @@ impl Connect for Client { | |||
| 496 | Ok((relay_reports, progress_reporter)) | 498 | Ok((relay_reports, progress_reporter)) |
| 497 | } | 499 | } |
| 498 | 500 | ||
| 499 | async fn fetch_all_from_relay( | 501 | async fn fetch_all_from_relay<'a>( |
| 500 | &self, | 502 | &self, |
| 501 | git_repo_path: &Path, | 503 | git_repo_path: Option<&'a Path>, |
| 502 | request: FetchRequest, | 504 | request: FetchRequest, |
| 503 | pb: &Option<ProgressBar>, | 505 | pb: &Option<ProgressBar>, |
| 504 | ) -> Result<FetchReport> { | 506 | ) -> Result<FetchReport> { |
| @@ -742,20 +744,25 @@ async fn get_local_cache_database(git_repo_path: &Path) -> Result<NostrLMDB> { | |||
| 742 | .context("cannot open or create nostr cache database at .git/nostr-cache.lmdb") | 744 | .context("cannot open or create nostr cache database at .git/nostr-cache.lmdb") |
| 743 | } | 745 | } |
| 744 | 746 | ||
| 745 | async fn get_global_cache_database(git_repo_path: &Path) -> Result<NostrLMDB> { | 747 | async fn get_global_cache_database(git_repo_path: Option<&Path>) -> Result<NostrLMDB> { |
| 746 | NostrLMDB::open(if std::env::var("NGITTEST").is_err() { | 748 | let path = if std::env::var("NGITTEST").is_ok() { |
| 749 | if let Some(git_repo_path) = git_repo_path { | ||
| 750 | git_repo_path.join(".git/test-global-cache.lmdb") | ||
| 751 | } else { | ||
| 752 | bail!("git_repo must be supplied to get_global_cache_database during integration tests") | ||
| 753 | } | ||
| 754 | } else { | ||
| 747 | create_dir_all(get_dirs()?.cache_dir()).context(format!( | 755 | create_dir_all(get_dirs()?.cache_dir()).context(format!( |
| 748 | "cannot create cache directory in: {:?}", | 756 | "cannot create cache directory in: {:?}", |
| 749 | get_dirs()?.cache_dir() | 757 | get_dirs()?.cache_dir() |
| 750 | ))?; | 758 | ))?; |
| 751 | get_dirs()?.cache_dir().join("nostr-cache.lmdb") | 759 | get_dirs()?.cache_dir().join("nostr-cache.lmdb") |
| 752 | } else { | 760 | }; |
| 753 | git_repo_path.join(".git/test-global-cache.lmdb") | 761 | |
| 754 | }) | 762 | NostrLMDB::open(path).context("cannot open ngit global nostr cache database") |
| 755 | .context("cannot open ngit global nostr cache database") | ||
| 756 | } | 763 | } |
| 757 | 764 | ||
| 758 | pub async fn get_events_from_cache( | 765 | pub async fn get_events_from_local_cache( |
| 759 | git_repo_path: &Path, | 766 | git_repo_path: &Path, |
| 760 | filters: Vec<nostr::Filter>, | 767 | filters: Vec<nostr::Filter>, |
| 761 | ) -> Result<Vec<nostr::Event>> { | 768 | ) -> Result<Vec<nostr::Event>> { |
| @@ -770,7 +777,7 @@ pub async fn get_events_from_cache( | |||
| 770 | } | 777 | } |
| 771 | 778 | ||
| 772 | pub async fn get_event_from_global_cache( | 779 | pub async fn get_event_from_global_cache( |
| 773 | git_repo_path: &Path, | 780 | git_repo_path: Option<&Path>, |
| 774 | filters: Vec<nostr::Filter>, | 781 | filters: Vec<nostr::Filter>, |
| 775 | ) -> Result<Vec<nostr::Event>> { | 782 | ) -> Result<Vec<nostr::Event>> { |
| 776 | Ok(get_global_cache_database(git_repo_path) | 783 | Ok(get_global_cache_database(git_repo_path) |
| @@ -781,7 +788,7 @@ pub async fn get_event_from_global_cache( | |||
| 781 | .to_vec()) | 788 | .to_vec()) |
| 782 | } | 789 | } |
| 783 | 790 | ||
| 784 | pub async fn save_event_in_cache(git_repo_path: &Path, event: &nostr::Event) -> Result<bool> { | 791 | pub async fn save_event_in_local_cache(git_repo_path: &Path, event: &nostr::Event) -> Result<bool> { |
| 785 | get_local_cache_database(git_repo_path) | 792 | get_local_cache_database(git_repo_path) |
| 786 | .await? | 793 | .await? |
| 787 | .save_event(event) | 794 | .save_event(event) |
| @@ -790,7 +797,7 @@ pub async fn save_event_in_cache(git_repo_path: &Path, event: &nostr::Event) -> | |||
| 790 | } | 797 | } |
| 791 | 798 | ||
| 792 | pub async fn save_event_in_global_cache( | 799 | pub async fn save_event_in_global_cache( |
| 793 | git_repo_path: &Path, | 800 | git_repo_path: Option<&Path>, |
| 794 | event: &nostr::Event, | 801 | event: &nostr::Event, |
| 795 | ) -> Result<bool> { | 802 | ) -> Result<bool> { |
| 796 | get_global_cache_database(git_repo_path) | 803 | get_global_cache_database(git_repo_path) |
| @@ -801,7 +808,7 @@ pub async fn save_event_in_global_cache( | |||
| 801 | } | 808 | } |
| 802 | 809 | ||
| 803 | pub async fn get_repo_ref_from_cache( | 810 | pub async fn get_repo_ref_from_cache( |
| 804 | git_repo_path: &Path, | 811 | git_repo_path: Option<&Path>, |
| 805 | repo_coordinates: &HashSet<Coordinate>, | 812 | repo_coordinates: &HashSet<Coordinate>, |
| 806 | ) -> Result<RepoRef> { | 813 | ) -> Result<RepoRef> { |
| 807 | let mut maintainers = HashSet::new(); | 814 | let mut maintainers = HashSet::new(); |
| @@ -817,7 +824,11 @@ pub async fn get_repo_ref_from_cache( | |||
| 817 | 824 | ||
| 818 | let events = [ | 825 | let events = [ |
| 819 | get_event_from_global_cache(git_repo_path, vec![repo_events_filter.clone()]).await?, | 826 | get_event_from_global_cache(git_repo_path, vec![repo_events_filter.clone()]).await?, |
| 820 | get_events_from_cache(git_repo_path, vec![repo_events_filter]).await?, | 827 | if let Some(git_repo_path) = git_repo_path { |
| 828 | get_events_from_local_cache(git_repo_path, vec![repo_events_filter]).await? | ||
| 829 | } else { | ||
| 830 | vec![] | ||
| 831 | }, | ||
| 821 | ] | 832 | ] |
| 822 | .concat(); | 833 | .concat(); |
| 823 | for e in events { | 834 | for e in events { |
| @@ -866,19 +877,32 @@ pub async fn get_repo_ref_from_cache( | |||
| 866 | }) | 877 | }) |
| 867 | } | 878 | } |
| 868 | 879 | ||
| 869 | pub async fn get_state_from_cache(git_repo_path: &Path, repo_ref: &RepoRef) -> Result<RepoState> { | 880 | pub async fn get_state_from_cache( |
| 870 | RepoState::try_from( | 881 | git_repo_path: Option<&Path>, |
| 871 | get_events_from_cache( | 882 | repo_ref: &RepoRef, |
| 872 | git_repo_path, | 883 | ) -> Result<RepoState> { |
| 873 | vec![get_filter_state_events(&repo_ref.coordinates())], | 884 | if let Some(git_repo_path) = git_repo_path { |
| 885 | RepoState::try_from( | ||
| 886 | get_events_from_local_cache( | ||
| 887 | git_repo_path, | ||
| 888 | vec![get_filter_state_events(&repo_ref.coordinates())], | ||
| 889 | ) | ||
| 890 | .await?, | ||
| 874 | ) | 891 | ) |
| 875 | .await?, | 892 | } else { |
| 876 | ) | 893 | RepoState::try_from( |
| 894 | get_event_from_global_cache( | ||
| 895 | git_repo_path, | ||
| 896 | vec![get_filter_state_events(&repo_ref.coordinates())], | ||
| 897 | ) | ||
| 898 | .await?, | ||
| 899 | ) | ||
| 900 | } | ||
| 877 | } | 901 | } |
| 878 | 902 | ||
| 879 | #[allow(clippy::too_many_lines)] | 903 | #[allow(clippy::too_many_lines)] |
| 880 | async fn create_relays_request( | 904 | async fn create_relays_request( |
| 881 | git_repo_path: &Path, | 905 | git_repo_path: Option<&Path>, |
| 882 | repo_coordinates: &HashSet<Coordinate>, | 906 | repo_coordinates: &HashSet<Coordinate>, |
| 883 | user_profiles: &HashSet<PublicKey>, | 907 | user_profiles: &HashSet<PublicKey>, |
| 884 | fallback_relays: HashSet<Url>, | 908 | fallback_relays: HashSet<Url>, |
| @@ -926,25 +950,27 @@ async fn create_relays_request( | |||
| 926 | } | 950 | } |
| 927 | } | 951 | } |
| 928 | 952 | ||
| 929 | for event in &get_events_from_cache( | 953 | if let Some(git_repo_path) = git_repo_path { |
| 930 | git_repo_path, | 954 | for event in &get_events_from_local_cache( |
| 931 | vec![ | 955 | git_repo_path, |
| 932 | nostr::Filter::default() | 956 | vec![ |
| 933 | .kinds(vec![Kind::GitPatch]) | 957 | nostr::Filter::default() |
| 934 | .custom_tag( | 958 | .kinds(vec![Kind::GitPatch]) |
| 935 | SingleLetterTag::lowercase(nostr_sdk::Alphabet::A), | 959 | .custom_tag( |
| 936 | repo_coordinates_without_relays | 960 | SingleLetterTag::lowercase(nostr_sdk::Alphabet::A), |
| 937 | .iter() | 961 | repo_coordinates_without_relays |
| 938 | .map(std::string::ToString::to_string) | 962 | .iter() |
| 939 | .collect::<Vec<String>>(), | 963 | .map(std::string::ToString::to_string) |
| 940 | ), | 964 | .collect::<Vec<String>>(), |
| 941 | ], | 965 | ), |
| 942 | ) | 966 | ], |
| 943 | .await? | 967 | ) |
| 944 | { | 968 | .await? |
| 945 | if event_is_patch_set_root(event) || event_is_revision_root(event) { | 969 | { |
| 946 | proposals.insert(event.id); | 970 | if event_is_patch_set_root(event) || event_is_revision_root(event) { |
| 947 | contributors.insert(event.pubkey); | 971 | proposals.insert(event.id); |
| 972 | contributors.insert(event.pubkey); | ||
| 973 | } | ||
| 948 | } | 974 | } |
| 949 | } | 975 | } |
| 950 | 976 | ||
| @@ -958,7 +984,9 @@ async fn create_relays_request( | |||
| 958 | .iter() | 984 | .iter() |
| 959 | .find(|e| e.kind == Kind::Metadata && e.pubkey.eq(c)) | 985 | .find(|e| e.kind == Kind::Metadata && e.pubkey.eq(c)) |
| 960 | { | 986 | { |
| 961 | save_event_in_cache(git_repo_path, event).await?; | 987 | if let Some(git_repo_path) = git_repo_path { |
| 988 | save_event_in_local_cache(git_repo_path, event).await?; | ||
| 989 | } | ||
| 962 | } else { | 990 | } else { |
| 963 | missing_contributor_profiles.insert(c.to_owned()); | 991 | missing_contributor_profiles.insert(c.to_owned()); |
| 964 | } | 992 | } |
| @@ -967,8 +995,10 @@ async fn create_relays_request( | |||
| 967 | 995 | ||
| 968 | let profiles_to_fetch_from_user_relays = { | 996 | let profiles_to_fetch_from_user_relays = { |
| 969 | let mut user_profiles = user_profiles.clone(); | 997 | let mut user_profiles = user_profiles.clone(); |
| 970 | if let Ok(Some(current_user)) = get_logged_in_user(git_repo_path).await { | 998 | if let Some(git_repo_path) = git_repo_path { |
| 971 | user_profiles.insert(current_user); | 999 | if let Ok(Some(current_user)) = get_likely_logged_in_user(git_repo_path).await { |
| 1000 | user_profiles.insert(current_user); | ||
| 1001 | } | ||
| 972 | } | 1002 | } |
| 973 | let mut map: HashMap<PublicKey, (Timestamp, Timestamp)> = HashMap::new(); | 1003 | let mut map: HashMap<PublicKey, (Timestamp, Timestamp)> = HashMap::new(); |
| 974 | for public_key in &user_profiles { | 1004 | for public_key in &user_profiles { |
| @@ -1022,12 +1052,14 @@ async fn create_relays_request( | |||
| 1022 | .copied() | 1052 | .copied() |
| 1023 | .collect(), | 1053 | .collect(), |
| 1024 | ) { | 1054 | ) { |
| 1025 | for (id, _) in get_local_cache_database(git_repo_path) | 1055 | if let Some(git_repo_path) = git_repo_path { |
| 1026 | .await? | 1056 | for (id, _) in get_local_cache_database(git_repo_path) |
| 1027 | .negentropy_items(filter) | 1057 | .await? |
| 1028 | .await? | 1058 | .negentropy_items(filter) |
| 1029 | { | 1059 | .await? |
| 1030 | existing_events.insert(id); | 1060 | { |
| 1061 | existing_events.insert(id); | ||
| 1062 | } | ||
| 1031 | } | 1063 | } |
| 1032 | } | 1064 | } |
| 1033 | existing_events | 1065 | existing_events |
| @@ -1105,7 +1137,7 @@ async fn create_relays_request( | |||
| 1105 | async fn process_fetched_events( | 1137 | async fn process_fetched_events( |
| 1106 | events: Vec<nostr::Event>, | 1138 | events: Vec<nostr::Event>, |
| 1107 | request: &FetchRequest, | 1139 | request: &FetchRequest, |
| 1108 | git_repo_path: &Path, | 1140 | git_repo_path: Option<&Path>, |
| 1109 | fresh_coordinates: &mut HashSet<Coordinate>, | 1141 | fresh_coordinates: &mut HashSet<Coordinate>, |
| 1110 | fresh_proposal_roots: &mut HashSet<EventId>, | 1142 | fresh_proposal_roots: &mut HashSet<EventId>, |
| 1111 | fresh_profiles: &mut HashSet<PublicKey>, | 1143 | fresh_profiles: &mut HashSet<PublicKey>, |
| @@ -1113,7 +1145,9 @@ async fn process_fetched_events( | |||
| 1113 | ) -> Result<()> { | 1145 | ) -> Result<()> { |
| 1114 | for event in &events { | 1146 | for event in &events { |
| 1115 | if !request.existing_events.contains(&event.id) { | 1147 | if !request.existing_events.contains(&event.id) { |
| 1116 | save_event_in_cache(git_repo_path, event).await?; | 1148 | if let Some(git_repo_path) = git_repo_path { |
| 1149 | save_event_in_local_cache(git_repo_path, event).await?; | ||
| 1150 | } | ||
| 1117 | if event.kind.eq(&Kind::GitRepoAnnouncement) { | 1151 | if event.kind.eq(&Kind::GitRepoAnnouncement) { |
| 1118 | save_event_in_global_cache(git_repo_path, event).await?; | 1152 | save_event_in_global_cache(git_repo_path, event).await?; |
| 1119 | let new_coordinate = !request | 1153 | let new_coordinate = !request |
| @@ -1488,7 +1522,7 @@ pub async fn fetching_with_report( | |||
| 1488 | let term = console::Term::stderr(); | 1522 | let term = console::Term::stderr(); |
| 1489 | term.write_line("fetching updates...")?; | 1523 | term.write_line("fetching updates...")?; |
| 1490 | let (relay_reports, progress_reporter) = client | 1524 | let (relay_reports, progress_reporter) = client |
| 1491 | .fetch_all(git_repo_path, repo_coordinates, &HashSet::new()) | 1525 | .fetch_all(Some(git_repo_path), repo_coordinates, &HashSet::new()) |
| 1492 | .await?; | 1526 | .await?; |
| 1493 | if !relay_reports.iter().any(std::result::Result::is_err) { | 1527 | if !relay_reports.iter().any(std::result::Result::is_err) { |
| 1494 | let _ = progress_reporter.clear(); | 1528 | let _ = progress_reporter.clear(); |
| @@ -1506,7 +1540,7 @@ pub async fn get_proposals_and_revisions_from_cache( | |||
| 1506 | git_repo_path: &Path, | 1540 | git_repo_path: &Path, |
| 1507 | repo_coordinates: HashSet<Coordinate>, | 1541 | repo_coordinates: HashSet<Coordinate>, |
| 1508 | ) -> Result<Vec<nostr::Event>> { | 1542 | ) -> Result<Vec<nostr::Event>> { |
| 1509 | let mut proposals = get_events_from_cache( | 1543 | let mut proposals = get_events_from_local_cache( |
| 1510 | git_repo_path, | 1544 | git_repo_path, |
| 1511 | vec![ | 1545 | vec![ |
| 1512 | nostr::Filter::default() | 1546 | nostr::Filter::default() |
| @@ -1535,7 +1569,7 @@ pub async fn get_all_proposal_patch_events_from_cache( | |||
| 1535 | repo_ref: &RepoRef, | 1569 | repo_ref: &RepoRef, |
| 1536 | proposal_id: &nostr::EventId, | 1570 | proposal_id: &nostr::EventId, |
| 1537 | ) -> Result<Vec<nostr::Event>> { | 1571 | ) -> Result<Vec<nostr::Event>> { |
| 1538 | let mut commit_events = get_events_from_cache( | 1572 | let mut commit_events = get_events_from_local_cache( |
| 1539 | git_repo_path, | 1573 | git_repo_path, |
| 1540 | vec![ | 1574 | vec![ |
| 1541 | nostr::Filter::default() | 1575 | nostr::Filter::default() |
| @@ -1571,7 +1605,7 @@ pub async fn get_all_proposal_patch_events_from_cache( | |||
| 1571 | .collect(); | 1605 | .collect(); |
| 1572 | 1606 | ||
| 1573 | if !revision_roots.is_empty() { | 1607 | if !revision_roots.is_empty() { |
| 1574 | for event in get_events_from_cache( | 1608 | for event in get_events_from_local_cache( |
| 1575 | git_repo_path, | 1609 | git_repo_path, |
| 1576 | vec![ | 1610 | vec![ |
| 1577 | nostr::Filter::default() | 1611 | nostr::Filter::default() |
| @@ -1594,7 +1628,7 @@ pub async fn get_all_proposal_patch_events_from_cache( | |||
| 1594 | } | 1628 | } |
| 1595 | 1629 | ||
| 1596 | pub async fn get_event_from_cache_by_id(git_repo: &Repo, event_id: &EventId) -> Result<Event> { | 1630 | pub async fn get_event_from_cache_by_id(git_repo: &Repo, event_id: &EventId) -> Result<Event> { |
| 1597 | Ok(get_events_from_cache( | 1631 | Ok(get_events_from_local_cache( |
| 1598 | git_repo.get_path()?, | 1632 | git_repo.get_path()?, |
| 1599 | vec![nostr::Filter::default().id(*event_id)], | 1633 | vec![nostr::Filter::default().id(*event_id)], |
| 1600 | ) | 1634 | ) |
| @@ -1609,7 +1643,7 @@ pub async fn get_event_from_cache_by_id(git_repo: &Repo, event_id: &EventId) -> | |||
| 1609 | pub async fn send_events( | 1643 | pub async fn send_events( |
| 1610 | #[cfg(test)] client: &crate::client::MockConnect, | 1644 | #[cfg(test)] client: &crate::client::MockConnect, |
| 1611 | #[cfg(not(test))] client: &Client, | 1645 | #[cfg(not(test))] client: &Client, |
| 1612 | git_repo_path: &Path, | 1646 | git_repo_path: Option<&Path>, |
| 1613 | events: Vec<nostr::Event>, | 1647 | events: Vec<nostr::Event>, |
| 1614 | my_write_relays: Vec<String>, | 1648 | my_write_relays: Vec<String>, |
| 1615 | repo_read_relays: Vec<String>, | 1649 | repo_read_relays: Vec<String>, |
diff --git a/src/lib/git/mod.rs b/src/lib/git/mod.rs index 5d14ce3..45ac58c 100644 --- a/src/lib/git/mod.rs +++ b/src/lib/git/mod.rs | |||
| @@ -860,6 +860,44 @@ fn extract_sig_from_patch_tags<'a>(tags: &'a Tags, tag_name: &str) -> Result<git | |||
| 860 | .context("failed to create git signature") | 860 | .context("failed to create git signature") |
| 861 | } | 861 | } |
| 862 | 862 | ||
| 863 | pub fn get_git_config_item(git_repo: &Option<&Repo>, item: &str) -> Result<Option<String>> { | ||
| 864 | if let Some(git_repo) = git_repo { | ||
| 865 | git_repo.get_git_config_item(item, Some(false)) | ||
| 866 | } else { | ||
| 867 | Ok( | ||
| 868 | match git2::Config::open_default()?.open_global()?.get_entry(item) { | ||
| 869 | Ok(item) => item.value().map(|v| v.to_string()), | ||
| 870 | Err(_) => None, | ||
| 871 | }, | ||
| 872 | ) | ||
| 873 | } | ||
| 874 | } | ||
| 875 | |||
| 876 | pub fn save_git_config_item(git_repo: &Option<&Repo>, item: &str, value: &str) -> Result<()> { | ||
| 877 | if let Some(git_repo) = git_repo { | ||
| 878 | git_repo.save_git_config_item(item, value, false) | ||
| 879 | } else { | ||
| 880 | git2::Config::open_default()? | ||
| 881 | .open_global()? | ||
| 882 | .set_str(item, value) | ||
| 883 | .context(format!("cannot set global git config item {}", item)) | ||
| 884 | } | ||
| 885 | } | ||
| 886 | |||
| 887 | pub fn remove_git_config_item(git_repo: &Option<&Repo>, item: &str) -> Result<bool> { | ||
| 888 | if let Some(git_repo) = git_repo { | ||
| 889 | git_repo.remove_git_config_item(item, false) | ||
| 890 | } else if get_git_config_item(&None, item)?.is_none() { | ||
| 891 | Ok(false) | ||
| 892 | } else { | ||
| 893 | git2::Config::open_default()? | ||
| 894 | .open_global()? | ||
| 895 | .remove(item) | ||
| 896 | .context(format!("cannot remove existing git config item {}", item))?; | ||
| 897 | Ok(true) | ||
| 898 | } | ||
| 899 | } | ||
| 900 | |||
| 863 | #[cfg(test)] | 901 | #[cfg(test)] |
| 864 | mod tests { | 902 | mod tests { |
| 865 | use std::fs; | 903 | use std::fs; |
diff --git a/src/lib/login/existing.rs b/src/lib/login/existing.rs new file mode 100644 index 0000000..e388a34 --- /dev/null +++ b/src/lib/login/existing.rs | |||
| @@ -0,0 +1,212 @@ | |||
| 1 | use std::{str::FromStr, sync::Arc, time::Duration}; | ||
| 2 | |||
| 3 | use anyhow::{bail, Context, Result}; | ||
| 4 | use nostr::nips::nip46::NostrConnectURI; | ||
| 5 | use nostr_connect::client::NostrConnect; | ||
| 6 | use nostr_sdk::{NostrSigner, PublicKey}; | ||
| 7 | |||
| 8 | use super::{ | ||
| 9 | key_encryption::decrypt_key, | ||
| 10 | print_logged_in_as, | ||
| 11 | user::{get_user_details, UserRef}, | ||
| 12 | SignerInfo, SignerInfoSource, | ||
| 13 | }; | ||
| 14 | #[cfg(not(test))] | ||
| 15 | use crate::client::Client; | ||
| 16 | #[cfg(test)] | ||
| 17 | use crate::client::MockConnect; | ||
| 18 | use crate::{ | ||
| 19 | cli_interactor::{Interactor, InteractorPrompt, PromptPasswordParms}, | ||
| 20 | client::fetch_public_key, | ||
| 21 | git::{get_git_config_item, Repo, RepoActions}, | ||
| 22 | }; | ||
| 23 | |||
| 24 | /// load signer from git config and UserProfile from cache or relays | ||
| 25 | /// | ||
| 26 | /// # Parameters | ||
| 27 | /// - `client`: include client to fetch profiles from relays that are missing | ||
| 28 | /// from cache | ||
| 29 | /// - `silent`: do not print outcome in termianl | ||
| 30 | pub async fn load_existing_login( | ||
| 31 | git_repo: &Option<&Repo>, | ||
| 32 | signer_info: &Option<SignerInfo>, | ||
| 33 | password: &Option<String>, | ||
| 34 | source: &Option<SignerInfoSource>, | ||
| 35 | #[cfg(test)] client: Option<&MockConnect>, | ||
| 36 | #[cfg(not(test))] client: Option<&Client>, | ||
| 37 | silent: bool, | ||
| 38 | prompt_for_password: bool, | ||
| 39 | ) -> Result<(Arc<dyn NostrSigner>, UserRef, SignerInfoSource)> { | ||
| 40 | let (signer_info, source) = get_signer_info(git_repo, signer_info, password, source)?; | ||
| 41 | |||
| 42 | let (signer, public_key) = get_signer(&signer_info, prompt_for_password).await?; | ||
| 43 | |||
| 44 | let user_ref = get_user_details( | ||
| 45 | &public_key, | ||
| 46 | client, | ||
| 47 | if let Some(git_repo) = git_repo { | ||
| 48 | Some(git_repo.get_path()?) | ||
| 49 | } else { | ||
| 50 | None | ||
| 51 | }, | ||
| 52 | silent, | ||
| 53 | ) | ||
| 54 | .await?; | ||
| 55 | |||
| 56 | if !silent { | ||
| 57 | print_logged_in_as(&user_ref, client.is_none(), &source)?; | ||
| 58 | } | ||
| 59 | Ok((signer, user_ref, source)) | ||
| 60 | } | ||
| 61 | |||
| 62 | /// priority order: cli arguments, local git config, global git config | ||
| 63 | fn get_signer_info( | ||
| 64 | git_repo: &Option<&Repo>, | ||
| 65 | signer_info: &Option<SignerInfo>, | ||
| 66 | password: &Option<String>, | ||
| 67 | source: &Option<SignerInfoSource>, | ||
| 68 | ) -> Result<(SignerInfo, SignerInfoSource)> { | ||
| 69 | Ok(match source { | ||
| 70 | None => { | ||
| 71 | let mut result = None; | ||
| 72 | for source in &[ | ||
| 73 | SignerInfoSource::CommandLineArguments, | ||
| 74 | SignerInfoSource::GitLocal, | ||
| 75 | SignerInfoSource::GitGlobal, | ||
| 76 | ] { | ||
| 77 | if let Ok(res) = | ||
| 78 | get_signer_info(git_repo, signer_info, password, &Some(source.clone())) | ||
| 79 | { | ||
| 80 | result = Some(res); | ||
| 81 | break; | ||
| 82 | } | ||
| 83 | } | ||
| 84 | result.context("cannot get or find signer info in cli arguments, local git config or global git config")? | ||
| 85 | } | ||
| 86 | Some(SignerInfoSource::CommandLineArguments) => { | ||
| 87 | if let Some(signer_info) = signer_info { | ||
| 88 | (signer_info.clone(), SignerInfoSource::CommandLineArguments) | ||
| 89 | } else { | ||
| 90 | bail!("cannot get signer from cli signer arguments because none were specified") | ||
| 91 | } | ||
| 92 | } | ||
| 93 | Some(SignerInfoSource::GitLocal) => { | ||
| 94 | let git_repo = | ||
| 95 | git_repo.context("failed to get local git config as no git_repo supplied")?; | ||
| 96 | if let Ok(nsec) = get_git_config_item(&Some(git_repo), "nostr.nsec") | ||
| 97 | .context("failed get local git config")? | ||
| 98 | .context("git local config item nostr.nsec doesn't exist") | ||
| 99 | { | ||
| 100 | ( | ||
| 101 | SignerInfo::Nsec { | ||
| 102 | nsec: nsec.to_string(), | ||
| 103 | password: password.clone(), | ||
| 104 | npub: get_git_config_item(&Some(git_repo), "nostr.npub") | ||
| 105 | .context("failed get local git config")?, | ||
| 106 | }, | ||
| 107 | SignerInfoSource::GitLocal, | ||
| 108 | ) | ||
| 109 | } else if let Ok(bunker_uri) = get_git_config_item(&Some(git_repo), "nostr.bunker-uri") | ||
| 110 | .context("failed get local git config")? | ||
| 111 | .context("git local config item nostr.bunker-uri doesn't exist") | ||
| 112 | { | ||
| 113 | (SignerInfo::Bunker { | ||
| 114 | bunker_uri, bunker_app_key: get_git_config_item(&Some(git_repo), "nostr.bunker-app-key") | ||
| 115 | .context("failed get local git config")? | ||
| 116 | .context("git local config item nostr.bunker-uri exists but nostr.bunker-app-key doesn't")?, | ||
| 117 | npub: get_git_config_item(&Some(git_repo), "nostr.npub") | ||
| 118 | .context("failed get local git config")?, | ||
| 119 | }, SignerInfoSource::GitLocal) | ||
| 120 | } else { | ||
| 121 | bail!("no signer info in local git config") | ||
| 122 | } | ||
| 123 | } | ||
| 124 | Some(SignerInfoSource::GitGlobal) => { | ||
| 125 | if let Some(nsec) = get_git_config_item(&None, "nostr.nsec") | ||
| 126 | .context("failed to get global git config")? | ||
| 127 | { | ||
| 128 | ( | ||
| 129 | SignerInfo::Nsec { | ||
| 130 | nsec: nsec.to_string(), | ||
| 131 | password: password.clone(), | ||
| 132 | npub: get_git_config_item(&None, "nostr.npub") | ||
| 133 | .context("failed to get global git config")?, | ||
| 134 | }, | ||
| 135 | SignerInfoSource::GitGlobal, | ||
| 136 | ) | ||
| 137 | } else if let Some(bunker_uri) = get_git_config_item(&None, "nostr.bunker-uri") | ||
| 138 | .context("failed to get global git config")? | ||
| 139 | { | ||
| 140 | (SignerInfo::Bunker { | ||
| 141 | bunker_uri, bunker_app_key: get_git_config_item(&None, "nostr.bunker-app-key") | ||
| 142 | .context("failed get local git config")? | ||
| 143 | .context("git global config item nostr.bunker-uri exists but nostr.bunker-app-key doesn't")?, | ||
| 144 | npub: get_git_config_item(&None, "nostr.npub") | ||
| 145 | .context("failed get global git config")?, | ||
| 146 | }, SignerInfoSource::GitGlobal) | ||
| 147 | } else { | ||
| 148 | bail!("no signer info in global git config") | ||
| 149 | } | ||
| 150 | } | ||
| 151 | }) | ||
| 152 | } | ||
| 153 | |||
| 154 | async fn get_signer( | ||
| 155 | signer_info: &SignerInfo, | ||
| 156 | prompt_for_ncryptsec_password: bool, | ||
| 157 | ) -> Result<(Arc<dyn NostrSigner>, PublicKey)> { | ||
| 158 | match signer_info { | ||
| 159 | SignerInfo::Nsec { | ||
| 160 | nsec, | ||
| 161 | password, | ||
| 162 | npub: _, | ||
| 163 | } => { | ||
| 164 | let keys = if nsec.contains("ncryptsec") { | ||
| 165 | // TODO get user details from npub | ||
| 166 | // TODO add retry loop | ||
| 167 | // TODO in retry loop give option to login again | ||
| 168 | let password = if let Some(password) = password { | ||
| 169 | password.clone() | ||
| 170 | } else { | ||
| 171 | if !prompt_for_ncryptsec_password { | ||
| 172 | bail!("cannot login without prompts a nsec is encrypted with a password"); | ||
| 173 | } | ||
| 174 | Interactor::default() | ||
| 175 | .password(PromptPasswordParms::default().with_prompt("password")) | ||
| 176 | .context("failed to get password input from interactor.password")? | ||
| 177 | }; | ||
| 178 | decrypt_key(nsec, password.clone().as_str()) | ||
| 179 | .context("failed to decrypt key with provided password") | ||
| 180 | .context("failed to decrypt ncryptsec supplied as nsec with password")? | ||
| 181 | } else { | ||
| 182 | nostr::Keys::from_str(nsec).context("invalid nsec parameter")? | ||
| 183 | }; | ||
| 184 | let public_key = keys.public_key(); | ||
| 185 | Ok((Arc::new(keys), public_key)) | ||
| 186 | } | ||
| 187 | SignerInfo::Bunker { | ||
| 188 | bunker_uri, | ||
| 189 | bunker_app_key, | ||
| 190 | npub, | ||
| 191 | } => { | ||
| 192 | let term = console::Term::stderr(); | ||
| 193 | term.write_line("connecting to remote signer...")?; | ||
| 194 | let uri = NostrConnectURI::parse(bunker_uri)?; | ||
| 195 | let signer: Arc<dyn NostrSigner> = Arc::new(NostrConnect::new( | ||
| 196 | uri, | ||
| 197 | nostr::Keys::from_str(bunker_app_key).context("invalid app key")?, | ||
| 198 | Duration::from_secs(10 * 60), | ||
| 199 | None, | ||
| 200 | )?); | ||
| 201 | term.clear_last_lines(1)?; | ||
| 202 | let public_key = if let Some(pubic_key) = | ||
| 203 | npub.clone().and_then(|npub| PublicKey::parse(npub).ok()) | ||
| 204 | { | ||
| 205 | pubic_key | ||
| 206 | } else { | ||
| 207 | fetch_public_key(&signer).await? | ||
| 208 | }; | ||
| 209 | Ok((signer, public_key)) | ||
| 210 | } | ||
| 211 | } | ||
| 212 | } | ||
diff --git a/src/lib/login/fresh.rs b/src/lib/login/fresh.rs new file mode 100644 index 0000000..3e88f68 --- /dev/null +++ b/src/lib/login/fresh.rs | |||
| @@ -0,0 +1,595 @@ | |||
| 1 | use std::{str::FromStr, sync::Arc, time::Duration}; | ||
| 2 | |||
| 3 | use anyhow::{bail, Context, Result}; | ||
| 4 | use console::{Style, Term}; | ||
| 5 | use dialoguer::theme::{ColorfulTheme, Theme}; | ||
| 6 | use nostr::nips::{nip05, nip46::NostrConnectURI}; | ||
| 7 | use nostr_connect::client::NostrConnect; | ||
| 8 | use nostr_sdk::{Keys, NostrSigner, PublicKey, ToBech32, Url}; | ||
| 9 | use qrcode::QrCode; | ||
| 10 | use tokio::sync::{oneshot, Mutex}; | ||
| 11 | |||
| 12 | use super::{ | ||
| 13 | key_encryption::decrypt_key, | ||
| 14 | print_logged_in_as, | ||
| 15 | user::{get_user_details, UserRef}, | ||
| 16 | SignerInfo, SignerInfoSource, | ||
| 17 | }; | ||
| 18 | #[cfg(not(test))] | ||
| 19 | use crate::client::Client; | ||
| 20 | #[cfg(test)] | ||
| 21 | use crate::client::MockConnect; | ||
| 22 | use crate::{ | ||
| 23 | cli_interactor::{ | ||
| 24 | Interactor, InteractorPrompt, Printer, PromptChoiceParms, PromptConfirmParms, | ||
| 25 | PromptInputParms, PromptPasswordParms, | ||
| 26 | }, | ||
| 27 | client::Connect, | ||
| 28 | git::{remove_git_config_item, save_git_config_item, Repo, RepoActions}, | ||
| 29 | }; | ||
| 30 | |||
| 31 | pub async fn fresh_login_or_signup( | ||
| 32 | git_repo: &Option<&Repo>, | ||
| 33 | #[cfg(test)] client: Option<&MockConnect>, | ||
| 34 | #[cfg(not(test))] client: Option<&Client>, | ||
| 35 | save_local: bool, | ||
| 36 | ) -> Result<(Arc<dyn NostrSigner>, UserRef, SignerInfoSource)> { | ||
| 37 | let (signer, public_key, signer_info, source) = loop { | ||
| 38 | match Interactor::default().choice( | ||
| 39 | PromptChoiceParms::default() | ||
| 40 | .with_prompt("login to nostr") | ||
| 41 | .with_default(0) | ||
| 42 | .with_choices(vec![ | ||
| 43 | "secret key (nsec / ncryptsec)".to_string(), | ||
| 44 | "nostr connect (remote signer)".to_string(), | ||
| 45 | "create account".to_string(), | ||
| 46 | "help".to_string(), | ||
| 47 | ]) | ||
| 48 | .dont_report(), | ||
| 49 | )? { | ||
| 50 | 0 => match get_fresh_nsec_signer().await { | ||
| 51 | Ok(Some(res)) => break res, | ||
| 52 | Ok(None) => continue, | ||
| 53 | Err(e) => { | ||
| 54 | eprintln!("error getting fresh signer from nsec: {e}"); | ||
| 55 | continue; | ||
| 56 | } | ||
| 57 | }, | ||
| 58 | 1 => match get_fresh_nip46_signer(client).await { | ||
| 59 | Ok(Some(res)) => break res, | ||
| 60 | Ok(None) => continue, | ||
| 61 | Err(e) => { | ||
| 62 | eprintln!("error getting fresh nip46 signer: {e}"); | ||
| 63 | continue; | ||
| 64 | } | ||
| 65 | }, | ||
| 66 | 2 => { | ||
| 67 | eprintln!("TODO create account..."); | ||
| 68 | continue; | ||
| 69 | } | ||
| 70 | _ => { | ||
| 71 | display_login_help_content(); | ||
| 72 | continue; | ||
| 73 | } | ||
| 74 | } | ||
| 75 | }; | ||
| 76 | let _ = save_to_git_config(git_repo, &signer_info, !save_local); | ||
| 77 | let user_ref = get_user_details( | ||
| 78 | &public_key, | ||
| 79 | client, | ||
| 80 | if let Some(git_repo) = git_repo { | ||
| 81 | Some(git_repo.get_path()?) | ||
| 82 | } else { | ||
| 83 | None | ||
| 84 | }, | ||
| 85 | false, | ||
| 86 | ) | ||
| 87 | .await?; | ||
| 88 | print_logged_in_as(&user_ref, client.is_none(), &source)?; | ||
| 89 | Ok((signer, user_ref, source)) | ||
| 90 | } | ||
| 91 | |||
| 92 | pub async fn get_fresh_nsec_signer() -> Result< | ||
| 93 | Option<( | ||
| 94 | Arc<dyn NostrSigner>, | ||
| 95 | PublicKey, | ||
| 96 | SignerInfo, | ||
| 97 | SignerInfoSource, | ||
| 98 | )>, | ||
| 99 | > { | ||
| 100 | loop { | ||
| 101 | let input = Interactor::default() | ||
| 102 | .input( | ||
| 103 | PromptInputParms::default() | ||
| 104 | .with_prompt("nsec") | ||
| 105 | .optional() | ||
| 106 | .dont_report(), | ||
| 107 | ) | ||
| 108 | .context("failed to get nsec input from interactor")?; | ||
| 109 | let (keys, signer_info) = if input.contains("ncryptsec") { | ||
| 110 | let password = Interactor::default() | ||
| 111 | .password( | ||
| 112 | PromptPasswordParms::default() | ||
| 113 | .with_prompt("password") | ||
| 114 | .dont_report(), | ||
| 115 | ) | ||
| 116 | .context("failed to get password input from interactor.password")?; | ||
| 117 | let keys = if let Ok(keys) = decrypt_key(&input, password.clone().as_str()) | ||
| 118 | .context("failed to decrypt ncryptsec with provided password") | ||
| 119 | { | ||
| 120 | keys | ||
| 121 | } else { | ||
| 122 | show_prompt_error( | ||
| 123 | "invalid ncryptsec and password combination", | ||
| 124 | &shorten_string(&input), | ||
| 125 | ); | ||
| 126 | match Interactor::default().choice( | ||
| 127 | PromptChoiceParms::default() | ||
| 128 | .with_default(0) | ||
| 129 | .with_choices(vec!["try again with nsec".to_string(), "back".to_string()]) | ||
| 130 | .dont_report(), | ||
| 131 | )? { | ||
| 132 | 0 => continue, | ||
| 133 | _ => break Ok(None), | ||
| 134 | } | ||
| 135 | }; | ||
| 136 | let npub = Some(keys.public_key().to_bech32()?); | ||
| 137 | let signer_info = if Interactor::default() | ||
| 138 | .confirm(PromptConfirmParms::default().with_prompt("remember details?"))? | ||
| 139 | || !Interactor::default().confirm(PromptConfirmParms::default().with_prompt( | ||
| 140 | "you will be prompted for password to decrypt your ncryptsec at every git push. are you sure?", | ||
| 141 | ))? { | ||
| 142 | SignerInfo::Nsec { | ||
| 143 | nsec: keys.secret_key().to_bech32()?, | ||
| 144 | password: None, | ||
| 145 | npub, | ||
| 146 | } | ||
| 147 | } else { | ||
| 148 | show_prompt_success("nsec", &shorten_string(&input)); | ||
| 149 | SignerInfo::Nsec { | ||
| 150 | nsec: input, | ||
| 151 | password: Some(password), | ||
| 152 | npub, | ||
| 153 | } | ||
| 154 | }; | ||
| 155 | (keys, signer_info) | ||
| 156 | } else if let Ok(keys) = nostr::Keys::from_str(&input) { | ||
| 157 | let nsec = keys.secret_key().to_bech32()?; | ||
| 158 | show_prompt_success("nsec", &shorten_string(&nsec)); | ||
| 159 | let signer_info = SignerInfo::Nsec { | ||
| 160 | nsec, | ||
| 161 | password: None, | ||
| 162 | npub: Some(keys.public_key().to_bech32()?), | ||
| 163 | }; | ||
| 164 | (keys, signer_info) | ||
| 165 | } else { | ||
| 166 | show_prompt_error("invalid nsec", &shorten_string(&input)); | ||
| 167 | match Interactor::default().choice( | ||
| 168 | PromptChoiceParms::default() | ||
| 169 | .with_default(0) | ||
| 170 | .with_choices(vec!["try again with nsec".to_string(), "back".to_string()]) | ||
| 171 | .dont_report(), | ||
| 172 | )? { | ||
| 173 | 0 => continue, | ||
| 174 | _ => break Ok(None), | ||
| 175 | } | ||
| 176 | }; | ||
| 177 | |||
| 178 | let public_key = keys.public_key(); | ||
| 179 | |||
| 180 | break Ok(Some(( | ||
| 181 | Arc::new(keys), | ||
| 182 | public_key, | ||
| 183 | signer_info, | ||
| 184 | // TODO factor in source | ||
| 185 | SignerInfoSource::GitGlobal, | ||
| 186 | ))); | ||
| 187 | } | ||
| 188 | } | ||
| 189 | |||
| 190 | fn show_prompt_success(label: &str, value: &str) { | ||
| 191 | eprintln!("{}", { | ||
| 192 | let mut s = String::new(); | ||
| 193 | let _ = ColorfulTheme::default().format_input_prompt_selection(&mut s, label, value); | ||
| 194 | s | ||
| 195 | }); | ||
| 196 | } | ||
| 197 | |||
| 198 | fn show_prompt_error(label: &str, value: &str) { | ||
| 199 | eprintln!("{}", { | ||
| 200 | let mut s = String::new(); | ||
| 201 | let _ = ColorfulTheme::default().format_error( | ||
| 202 | &mut s, | ||
| 203 | &format!( | ||
| 204 | "{label}: {}", | ||
| 205 | if value.is_empty() { | ||
| 206 | "empty".to_string() | ||
| 207 | } else { | ||
| 208 | shorten_string(&format!("\"{}\"", &value)) | ||
| 209 | } | ||
| 210 | ), | ||
| 211 | ); | ||
| 212 | s | ||
| 213 | }); | ||
| 214 | } | ||
| 215 | |||
| 216 | fn shorten_string(s: &str) -> String { | ||
| 217 | if s.len() < 15 { | ||
| 218 | s.to_string() | ||
| 219 | } else { | ||
| 220 | format!("{}...", &s[..15]) | ||
| 221 | } | ||
| 222 | } | ||
| 223 | |||
| 224 | pub async fn get_fresh_nip46_signer( | ||
| 225 | #[cfg(test)] client: Option<&MockConnect>, | ||
| 226 | #[cfg(not(test))] client: Option<&Client>, | ||
| 227 | ) -> Result< | ||
| 228 | Option<( | ||
| 229 | Arc<dyn NostrSigner>, | ||
| 230 | PublicKey, | ||
| 231 | SignerInfo, | ||
| 232 | SignerInfoSource, | ||
| 233 | )>, | ||
| 234 | > { | ||
| 235 | let (app_key, nostr_connect_url) = generate_nostr_connect_app(client)?; | ||
| 236 | let printer = Arc::new(Mutex::new(Printer::default())); | ||
| 237 | let signer_choice = Interactor::default().choice( | ||
| 238 | PromptChoiceParms::default() | ||
| 239 | .with_prompt("login to nostr with remote signer") | ||
| 240 | .with_default(0) | ||
| 241 | .with_choices(vec![ | ||
| 242 | "show QR code to scan in signer app".to_string(), | ||
| 243 | "show nostrconnect:// url to paste into signer".to_string(), | ||
| 244 | "use NIP-05 address to connect to signer".to_string(), | ||
| 245 | "paste in bunker:// url from signer app".to_string(), | ||
| 246 | "back".to_string(), | ||
| 247 | ]) | ||
| 248 | .dont_report(), | ||
| 249 | )?; | ||
| 250 | let url = match signer_choice { | ||
| 251 | 0 | 1 => nostr_connect_url, | ||
| 252 | 2 => { | ||
| 253 | let mut error = None; | ||
| 254 | loop { | ||
| 255 | let input = Interactor::default() | ||
| 256 | .input( | ||
| 257 | PromptInputParms::default().with_prompt(if let Some(error) = error { | ||
| 258 | format!("error: {}. try again with NIP-05 address", error) | ||
| 259 | } else { | ||
| 260 | "NIP-05 address".to_string() | ||
| 261 | }), | ||
| 262 | ) | ||
| 263 | .context("failed to get NIP-05 address input from interactor")?; | ||
| 264 | match fetch_nip46_uri_from_nip05(&input).await { | ||
| 265 | Ok(url) => break url, | ||
| 266 | Err(e) => error = Some(e), | ||
| 267 | } | ||
| 268 | } | ||
| 269 | } | ||
| 270 | 3 => { | ||
| 271 | let mut error = None; | ||
| 272 | loop { | ||
| 273 | let input = Interactor::default() | ||
| 274 | .input( | ||
| 275 | PromptInputParms::default().with_prompt(if let Some(error) = error { | ||
| 276 | format!("error: {}. try again with bunker url", error) | ||
| 277 | } else { | ||
| 278 | "bunker url".to_string() | ||
| 279 | }), | ||
| 280 | ) | ||
| 281 | .context("failed to get bunker url input from interactor")?; | ||
| 282 | match NostrConnectURI::parse(&input) { | ||
| 283 | Ok(url) => break url, | ||
| 284 | Err(e) => error = Some(e), | ||
| 285 | } | ||
| 286 | } | ||
| 287 | } | ||
| 288 | _ => return Ok(None), | ||
| 289 | }; | ||
| 290 | |||
| 291 | { | ||
| 292 | let printer_clone = Arc::clone(&printer); | ||
| 293 | let mut printer_locked = printer_clone.lock().await; | ||
| 294 | match signer_choice { | ||
| 295 | 0 => { | ||
| 296 | printer_locked | ||
| 297 | .println("login to nostr with remote signer via nostr connect".to_string()); | ||
| 298 | printer_locked.println("scan QR code in signer app (eg Amber):".to_string()); | ||
| 299 | printer_locked.printlns(generate_qr(&url.to_string())?); | ||
| 300 | printer_locked | ||
| 301 | .println("scan QR code in signer app or press any key to abort...".to_string()); | ||
| 302 | } | ||
| 303 | 1 => { | ||
| 304 | printer_locked | ||
| 305 | .println("login to nostr with remote signer via nostr connect".to_string()); | ||
| 306 | printer_locked.println("".to_string()); | ||
| 307 | printer_locked.println_with_custom_formatting( | ||
| 308 | format!("{}", Style::new().bold().apply_to(url.to_string()),), | ||
| 309 | url.to_string(), | ||
| 310 | ); | ||
| 311 | printer_locked.println("".to_string()); | ||
| 312 | printer_locked | ||
| 313 | .println("paste url into signer app or press any key to abort...".to_string()); | ||
| 314 | } | ||
| 315 | _ => { | ||
| 316 | printer_locked.println( | ||
| 317 | "add / approve in your signer or press any key to abort... ".to_string(), | ||
| 318 | ); | ||
| 319 | } | ||
| 320 | } | ||
| 321 | } | ||
| 322 | |||
| 323 | let (signer, user_public_key, bunker_url) = | ||
| 324 | listen_for_remote_signer(&app_key, &url, printer).await?; | ||
| 325 | let signer_info = SignerInfo::Bunker { | ||
| 326 | bunker_uri: bunker_url.to_string(), | ||
| 327 | bunker_app_key: app_key.secret_key().to_secret_hex(), | ||
| 328 | npub: Some(user_public_key.to_bech32()?), | ||
| 329 | }; | ||
| 330 | Ok(Some(( | ||
| 331 | signer, | ||
| 332 | user_public_key, | ||
| 333 | signer_info, | ||
| 334 | SignerInfoSource::GitGlobal, | ||
| 335 | ))) | ||
| 336 | } | ||
| 337 | |||
| 338 | pub fn generate_nostr_connect_app( | ||
| 339 | #[cfg(test)] client: Option<&MockConnect>, | ||
| 340 | #[cfg(not(test))] client: Option<&Client>, | ||
| 341 | ) -> Result<(Keys, NostrConnectURI)> { | ||
| 342 | let app_key = Keys::generate(); | ||
| 343 | let relays = if let Some(client) = client { | ||
| 344 | client | ||
| 345 | .get_fallback_signer_relays() | ||
| 346 | .iter() | ||
| 347 | .flat_map(|s| Url::parse(s)) | ||
| 348 | .collect::<Vec<Url>>() | ||
| 349 | } else { | ||
| 350 | vec![] | ||
| 351 | }; | ||
| 352 | let nostr_connect_url = NostrConnectURI::client(app_key.public_key(), relays.clone(), "ngit"); | ||
| 353 | Ok((app_key, nostr_connect_url)) | ||
| 354 | } | ||
| 355 | |||
| 356 | pub async fn fetch_nip46_uri_from_nip05(nip05: &str) -> Result<NostrConnectURI> { | ||
| 357 | let term = console::Term::stderr(); | ||
| 358 | term.write_line("contacting login service provider...")?; | ||
| 359 | let res = nip05::profile(&nip05, None).await; | ||
| 360 | term.clear_last_lines(1)?; | ||
| 361 | match res { | ||
| 362 | Ok(profile) => { | ||
| 363 | if profile.nip46.is_empty() { | ||
| 364 | eprintln!("nip05 provider isn't configured for remote login"); | ||
| 365 | bail!("nip05 provider isn't configured for remote login") | ||
| 366 | } | ||
| 367 | Ok(NostrConnectURI::Bunker { | ||
| 368 | remote_signer_public_key: profile.public_key, | ||
| 369 | relays: profile.nip46, | ||
| 370 | secret: None, | ||
| 371 | }) | ||
| 372 | } | ||
| 373 | Err(error) => { | ||
| 374 | eprintln!("error contacting login service provider: {error}"); | ||
| 375 | Err(error).context("error contacting login service provider") | ||
| 376 | } | ||
| 377 | } | ||
| 378 | } | ||
| 379 | |||
| 380 | pub async fn listen_for_remote_signer( | ||
| 381 | app_key: &Keys, | ||
| 382 | nostr_connect_url: &NostrConnectURI, | ||
| 383 | printer: Arc<Mutex<Printer>>, | ||
| 384 | ) -> Result<(Arc<dyn NostrSigner>, PublicKey, NostrConnectURI)> { | ||
| 385 | let (tx, rx) = oneshot::channel(); | ||
| 386 | let printer_clone = Arc::clone(&printer); | ||
| 387 | let app_key = app_key.clone(); | ||
| 388 | let nostr_connect_url_clone = nostr_connect_url.clone(); | ||
| 389 | let qr_listener = tokio::spawn(async move { | ||
| 390 | if let Ok(nostr_connect) = NostrConnect::new( | ||
| 391 | nostr_connect_url_clone, | ||
| 392 | app_key, | ||
| 393 | Duration::from_secs(10 * 60), | ||
| 394 | None, | ||
| 395 | ) { | ||
| 396 | let signer: Arc<dyn NostrSigner> = Arc::new(nostr_connect); | ||
| 397 | if let Ok(pub_key) = signer.get_public_key().await { | ||
| 398 | let mut printer_locked = printer_clone.lock().await; | ||
| 399 | printer_locked.clear_all(); | ||
| 400 | |||
| 401 | printer_locked.println_with_custom_formatting( | ||
| 402 | format!( | ||
| 403 | "{}", | ||
| 404 | Style::new().bold().apply_to("connected to remote signer"), | ||
| 405 | ), | ||
| 406 | "connected to remote signer".to_string(), | ||
| 407 | ); | ||
| 408 | printer_locked.println("press any key to continue...".to_string()); | ||
| 409 | let _ = tx.send(Some((signer, pub_key))); | ||
| 410 | } else { | ||
| 411 | let _ = tx.send(None); | ||
| 412 | } | ||
| 413 | } | ||
| 414 | }); | ||
| 415 | let _ = console::Term::stderr().read_char(); | ||
| 416 | qr_listener.abort(); | ||
| 417 | let printer_clone = Arc::clone(&printer); | ||
| 418 | let mut printer = printer_clone.lock().await; | ||
| 419 | printer.clear_all(); | ||
| 420 | |||
| 421 | if let Some((signer, public_key)) = rx.await? { | ||
| 422 | let bunker_url = NostrConnectURI::Bunker { | ||
| 423 | // TODO the remote signer pubkey may not be the user pubkey | ||
| 424 | remote_signer_public_key: public_key, | ||
| 425 | relays: nostr_connect_url.relays(), | ||
| 426 | secret: nostr_connect_url.secret(), | ||
| 427 | }; | ||
| 428 | Ok((signer, public_key, bunker_url)) | ||
| 429 | } else { | ||
| 430 | bail!("failed to get signer") | ||
| 431 | } | ||
| 432 | } | ||
| 433 | |||
| 434 | fn generate_qr(data: &str) -> Result<Vec<String>> { | ||
| 435 | let mut lines = vec![]; | ||
| 436 | let qr = | ||
| 437 | QrCode::new(data.as_bytes()).context("failed to create QR of nostrconnect login url")?; | ||
| 438 | let colors = qr.to_colors(); | ||
| 439 | let rows: Vec<&[qrcode::Color]> = colors.chunks(qr.width()).collect(); | ||
| 440 | for (row, data) in rows.iter().enumerate() { | ||
| 441 | let odd = row % 2 != 0; | ||
| 442 | if odd { | ||
| 443 | continue; | ||
| 444 | } | ||
| 445 | let mut line = String::new(); | ||
| 446 | for (col, color) in data.iter().enumerate() { | ||
| 447 | let top = color; | ||
| 448 | let mut bottom = qrcode::Color::Light; | ||
| 449 | if let Some(next_row_data) = rows.get(row + 1) { | ||
| 450 | if let Some(color) = next_row_data.get(col) { | ||
| 451 | bottom = *color; | ||
| 452 | } | ||
| 453 | } | ||
| 454 | line.push(if *top == qrcode::Color::Dark { | ||
| 455 | if bottom == qrcode::Color::Dark { | ||
| 456 | '█' | ||
| 457 | } else { | ||
| 458 | '▀' | ||
| 459 | } | ||
| 460 | } else if bottom == qrcode::Color::Dark { | ||
| 461 | '▄' | ||
| 462 | } else { | ||
| 463 | ' ' | ||
| 464 | }); | ||
| 465 | } | ||
| 466 | lines.push(line); | ||
| 467 | } | ||
| 468 | Ok(lines) | ||
| 469 | } | ||
| 470 | |||
| 471 | fn save_to_git_config( | ||
| 472 | git_repo: &Option<&Repo>, | ||
| 473 | signer_info: &SignerInfo, | ||
| 474 | global: bool, | ||
| 475 | ) -> Result<()> { | ||
| 476 | if let Err(error) = silently_save_to_git_config(git_repo, signer_info, global).context(format!( | ||
| 477 | "failed to save login details to {} git config", | ||
| 478 | if global { "global" } else { "local" } | ||
| 479 | )) { | ||
| 480 | eprintln!("Error: {:?}", error); | ||
| 481 | match signer_info { | ||
| 482 | SignerInfo::Nsec { | ||
| 483 | nsec, | ||
| 484 | password: _, | ||
| 485 | npub: _, | ||
| 486 | } => { | ||
| 487 | if nsec.contains("ncryptsec") { | ||
| 488 | eprintln!("consider manually setting git config nostr.nsec to: {nsec}"); | ||
| 489 | } else { | ||
| 490 | eprintln!("consider manually setting git config nostr.nsec"); | ||
| 491 | } | ||
| 492 | } | ||
| 493 | SignerInfo::Bunker { | ||
| 494 | bunker_uri, | ||
| 495 | bunker_app_key, | ||
| 496 | npub: _, | ||
| 497 | } => { | ||
| 498 | eprintln!("consider manually setting git config as follows:"); | ||
| 499 | eprintln!("nostr.bunker-uri: {bunker_uri}"); | ||
| 500 | eprintln!("nostr.bunker-app-key: {bunker_app_key}"); | ||
| 501 | } | ||
| 502 | } | ||
| 503 | if global { | ||
| 504 | save_to_git_config(git_repo, signer_info, false)? | ||
| 505 | } | ||
| 506 | Err(error) | ||
| 507 | } else { | ||
| 508 | eprintln!( | ||
| 509 | "{}", | ||
| 510 | if global { | ||
| 511 | "saved login details to global git config" | ||
| 512 | } else { | ||
| 513 | "saved login details to local git config. you are only logged in to this local repository." | ||
| 514 | } | ||
| 515 | ); | ||
| 516 | Ok(()) | ||
| 517 | } | ||
| 518 | } | ||
| 519 | |||
| 520 | fn silently_save_to_git_config( | ||
| 521 | git_repo: &Option<&Repo>, | ||
| 522 | signer_info: &SignerInfo, | ||
| 523 | global: bool, | ||
| 524 | ) -> Result<()> { | ||
| 525 | if global { | ||
| 526 | // remove local login otherwise it will override global next time ngit is called | ||
| 527 | if let Some(git_repo) = git_repo { | ||
| 528 | git_repo.remove_git_config_item("nostr.npub", false)?; | ||
| 529 | git_repo.remove_git_config_item("nostr.nsec", false)?; | ||
| 530 | git_repo.remove_git_config_item("nostr.bunker-uri", false)?; | ||
| 531 | git_repo.remove_git_config_item("nostr.bunker-app-key", false)?; | ||
| 532 | } | ||
| 533 | } | ||
| 534 | |||
| 535 | let git_repo = if global { | ||
| 536 | &None | ||
| 537 | } else if git_repo.is_none() { | ||
| 538 | bail!("cannot update local git config wihout git_repo object") | ||
| 539 | } else { | ||
| 540 | git_repo | ||
| 541 | }; | ||
| 542 | |||
| 543 | let npub_to_save; | ||
| 544 | match signer_info { | ||
| 545 | SignerInfo::Nsec { | ||
| 546 | nsec, | ||
| 547 | password: _, | ||
| 548 | npub, | ||
| 549 | } => { | ||
| 550 | npub_to_save = npub; | ||
| 551 | save_git_config_item(git_repo, "nostr.nsec", nsec)?; | ||
| 552 | remove_git_config_item(git_repo, "nostr.bunker-uri")?; | ||
| 553 | remove_git_config_item(git_repo, "nostr.bunker-app-key")?; | ||
| 554 | } | ||
| 555 | SignerInfo::Bunker { | ||
| 556 | bunker_uri, | ||
| 557 | bunker_app_key, | ||
| 558 | npub, | ||
| 559 | } => { | ||
| 560 | npub_to_save = npub; | ||
| 561 | remove_git_config_item(git_repo, "nostr.nsec")?; | ||
| 562 | save_git_config_item(git_repo, "nostr.bunker-uri", bunker_uri)?; | ||
| 563 | save_git_config_item(git_repo, "nostr.bunker-app-key", bunker_app_key)?; | ||
| 564 | } | ||
| 565 | } | ||
| 566 | if let Some(npub) = npub_to_save { | ||
| 567 | save_git_config_item(git_repo, "nostr.npub", npub)?; | ||
| 568 | } else { | ||
| 569 | remove_git_config_item(git_repo, "nostr.npub")?; | ||
| 570 | } | ||
| 571 | Ok(()) | ||
| 572 | } | ||
| 573 | |||
| 574 | fn display_login_help_content() { | ||
| 575 | let mut printer = Printer::default(); | ||
| 576 | let title_style = Style::new().bold().fg(console::Color::Yellow); | ||
| 577 | printer.println("|==============================|".to_owned()); | ||
| 578 | // printer.println("| |".to_owned()); | ||
| 579 | printer.println_with_custom_formatting( | ||
| 580 | format!( | ||
| 581 | "| {} |", | ||
| 582 | title_style.apply_to("nostr login / sign up help") | ||
| 583 | ), | ||
| 584 | "| nostr login / sign up help |".to_string(), | ||
| 585 | ); | ||
| 586 | // printer.println("| |".to_owned()); | ||
| 587 | printer.println("|==============================|".to_owned()); | ||
| 588 | printer.printlns(vec![ | ||
| 589 | "".to_string(), | ||
| 590 | "login / sign up help content should go here...".to_string(), | ||
| 591 | "press any key to see the login / signup menu again...".to_string(), | ||
| 592 | ]); | ||
| 593 | let _ = Term::stdout().read_char(); | ||
| 594 | printer.clear_all(); | ||
| 595 | } | ||
diff --git a/src/lib/login/key_encryption.rs b/src/lib/login/key_encryption.rs index b50b507..efb38d1 100644 --- a/src/lib/login/key_encryption.rs +++ b/src/lib/login/key_encryption.rs | |||
| @@ -1,23 +1,5 @@ | |||
| 1 | use anyhow::Result; | 1 | use anyhow::Result; |
| 2 | use nostr::{prelude::*, Keys}; | 2 | use nostr::prelude::*; |
| 3 | |||
| 4 | pub fn encrypt_key(keys: &Keys, password: &str) -> Result<String> { | ||
| 5 | let log2_rounds: u8 = if password.len() > 20 { | ||
| 6 | // we have enough of entropy - no need to spend CPU time adding much more | ||
| 7 | 1 | ||
| 8 | } else { | ||
| 9 | println!("this may take a few seconds..."); | ||
| 10 | // default (scrypt::Params::RECOMMENDED_LOG_N) is 17 but 30s is too long to wait | ||
| 11 | 15 | ||
| 12 | }; | ||
| 13 | Ok(nostr::nips::nip49::EncryptedSecretKey::new( | ||
| 14 | keys.secret_key(), | ||
| 15 | password, | ||
| 16 | log2_rounds, | ||
| 17 | KeySecurity::Medium, | ||
| 18 | )? | ||
| 19 | .to_bech32()?) | ||
| 20 | } | ||
| 21 | 3 | ||
| 22 | pub fn decrypt_key(encrypted_key: &str, password: &str) -> Result<nostr::Keys> { | 4 | pub fn decrypt_key(encrypted_key: &str, password: &str) -> Result<nostr::Keys> { |
| 23 | let encrypted_key = nostr::nips::nip49::EncryptedSecretKey::from_bech32(encrypted_key)?; | 5 | let encrypted_key = nostr::nips::nip49::EncryptedSecretKey::from_bech32(encrypted_key)?; |
| @@ -34,6 +16,24 @@ mod tests { | |||
| 34 | 16 | ||
| 35 | use super::*; | 17 | use super::*; |
| 36 | 18 | ||
| 19 | pub fn encrypt_key(keys: &Keys, password: &str) -> Result<String> { | ||
| 20 | let log2_rounds: u8 = if password.len() > 20 { | ||
| 21 | // we have enough of entropy - no need to spend CPU time adding much more | ||
| 22 | 1 | ||
| 23 | } else { | ||
| 24 | println!("this may take a few seconds..."); | ||
| 25 | // default (scrypt::Params::RECOMMENDED_LOG_N) is 17 but 30s is too long to wait | ||
| 26 | 15 | ||
| 27 | }; | ||
| 28 | Ok(nostr::nips::nip49::EncryptedSecretKey::new( | ||
| 29 | keys.secret_key(), | ||
| 30 | password, | ||
| 31 | log2_rounds, | ||
| 32 | KeySecurity::Medium, | ||
| 33 | )? | ||
| 34 | .to_bech32()?) | ||
| 35 | } | ||
| 36 | |||
| 37 | #[test] | 37 | #[test] |
| 38 | fn encrypt_key_produces_string_prefixed_with() -> Result<()> { | 38 | fn encrypt_key_produces_string_prefixed_with() -> Result<()> { |
| 39 | let s = encrypt_key(&nostr::Keys::generate(), TEST_PASSWORD)?; | 39 | let s = encrypt_key(&nostr::Keys::generate(), TEST_PASSWORD)?; |
diff --git a/src/lib/login/mod.rs b/src/lib/login/mod.rs index 0e00170..b45bc1d 100644 --- a/src/lib/login/mod.rs +++ b/src/lib/login/mod.rs | |||
| @@ -1,129 +1,65 @@ | |||
| 1 | use std::{collections::HashSet, path::Path, str::FromStr, sync::Arc, time::Duration}; | 1 | use std::{path::Path, sync::Arc}; |
| 2 | 2 | ||
| 3 | use anyhow::{bail, Context, Result}; | 3 | use anyhow::Result; |
| 4 | use console::Style; | 4 | use fresh::fresh_login_or_signup; |
| 5 | use dialoguer::theme::{ColorfulTheme, Theme}; | 5 | use nostr::PublicKey; |
| 6 | use nostr::{ | 6 | use nostr_sdk::{NostrSigner, Timestamp, ToBech32}; |
| 7 | nips::{nip05, nip46::NostrConnectURI}, | ||
| 8 | PublicKey, | ||
| 9 | }; | ||
| 10 | use nostr_connect::client::NostrConnect; | ||
| 11 | use nostr_sdk::{ | ||
| 12 | Alphabet, FromBech32, JsonUtil, Keys, Kind, NostrSigner, SingleLetterTag, Timestamp, ToBech32, | ||
| 13 | Url, | ||
| 14 | }; | ||
| 15 | use qrcode::QrCode; | ||
| 16 | use tokio::sync::{oneshot, Mutex}; | ||
| 17 | 7 | ||
| 18 | #[cfg(not(test))] | 8 | #[cfg(not(test))] |
| 19 | use crate::client::Client; | 9 | use crate::client::Client; |
| 20 | #[cfg(test)] | 10 | #[cfg(test)] |
| 21 | use crate::client::MockConnect; | 11 | use crate::client::MockConnect; |
| 22 | use crate::{ | 12 | use crate::git::{Repo, RepoActions}; |
| 23 | cli_interactor::{ | ||
| 24 | Interactor, InteractorPrompt, Printer, PromptConfirmParms, PromptInputParms, | ||
| 25 | PromptPasswordParms, | ||
| 26 | }, | ||
| 27 | client::{fetch_public_key, get_event_from_global_cache, Connect}, | ||
| 28 | git::{Repo, RepoActions}, | ||
| 29 | }; | ||
| 30 | 13 | ||
| 14 | pub mod existing; | ||
| 31 | mod key_encryption; | 15 | mod key_encryption; |
| 32 | use key_encryption::{decrypt_key, encrypt_key}; | 16 | use existing::load_existing_login; |
| 33 | mod user; | 17 | pub mod user; |
| 34 | use user::{UserMetadata, UserRef, UserRelayRef, UserRelays}; | 18 | use user::UserRef; |
| 35 | 19 | pub mod fresh; | |
| 36 | /// handles the encrpytion and storage of key material | 20 | |
| 37 | #[allow(clippy::too_many_arguments)] | 21 | pub async fn login_or_signup( |
| 38 | pub async fn launch( | 22 | git_repo: &Option<&Repo>, |
| 39 | git_repo: &Repo, | 23 | signer_info: &Option<SignerInfo>, |
| 40 | bunker_uri: &Option<String>, | ||
| 41 | bunker_app_key: &Option<String>, | ||
| 42 | nsec: &Option<String>, | ||
| 43 | password: &Option<String>, | 24 | password: &Option<String>, |
| 44 | #[cfg(test)] client: Option<&MockConnect>, | 25 | #[cfg(test)] client: Option<&MockConnect>, |
| 45 | #[cfg(not(test))] client: Option<&Client>, | 26 | #[cfg(not(test))] client: Option<&Client>, |
| 46 | change_user: bool, | 27 | ) -> Result<(Arc<dyn NostrSigner>, UserRef, SignerInfoSource)> { |
| 47 | silent: bool, | 28 | let res = |
| 48 | ) -> Result<(Arc<dyn NostrSigner>, UserRef)> { | 29 | load_existing_login(git_repo, signer_info, password, &None, client, false, true).await; |
| 49 | if let Ok((signer, public_key)) = match get_signer_without_prompts( | 30 | if res.is_ok() { |
| 50 | git_repo, | 31 | res |
| 51 | bunker_uri, | ||
| 52 | bunker_app_key, | ||
| 53 | nsec, | ||
| 54 | password, | ||
| 55 | change_user, | ||
| 56 | ) | ||
| 57 | .await | ||
| 58 | { | ||
| 59 | Ok((signer, public_key)) => Ok((signer, public_key)), | ||
| 60 | Err(error) => { | ||
| 61 | if error | ||
| 62 | .to_string() | ||
| 63 | .eq("git config item nostr.nsec is an ncryptsec") | ||
| 64 | { | ||
| 65 | eprintln!( | ||
| 66 | "login as {}", | ||
| 67 | if let Ok(public_key) = PublicKey::from_bech32( | ||
| 68 | get_config_item(git_repo, "nostr.npub").unwrap_or("unknown".to_string()), | ||
| 69 | ) { | ||
| 70 | if let Ok(user_ref) = | ||
| 71 | get_user_details(&public_key, client, git_repo.get_path()?, silent) | ||
| 72 | .await | ||
| 73 | { | ||
| 74 | user_ref.metadata.name | ||
| 75 | } else { | ||
| 76 | "unknown ncryptsec".to_string() | ||
| 77 | } | ||
| 78 | } else { | ||
| 79 | "unknown ncryptsec".to_string() | ||
| 80 | } | ||
| 81 | ); | ||
| 82 | loop { | ||
| 83 | // prompt for password | ||
| 84 | let password = Interactor::default() | ||
| 85 | .password(PromptPasswordParms::default().with_prompt("password")) | ||
| 86 | .context("failed to get password input from interactor.password")?; | ||
| 87 | if let Ok(keys) = get_keys_with_password(git_repo, &password) { | ||
| 88 | break Ok((Arc::new(keys) as Arc<dyn NostrSigner>, None)); | ||
| 89 | } | ||
| 90 | eprintln!("incorrect password"); | ||
| 91 | } | ||
| 92 | } else { | ||
| 93 | if nsec.is_some() { | ||
| 94 | bail!(error); | ||
| 95 | } | ||
| 96 | Err(error) | ||
| 97 | } | ||
| 98 | } | ||
| 99 | } { | ||
| 100 | let user_ref = get_user_details( | ||
| 101 | // Note: if rust-nostr NostrConnect::new() were updated to accept user public key as | ||
| 102 | // requested then the added complexity added in this commit can be undone | ||
| 103 | &(if let Some(public_key) = public_key { | ||
| 104 | public_key | ||
| 105 | } else { | ||
| 106 | signer | ||
| 107 | .get_public_key() | ||
| 108 | .await | ||
| 109 | .context("cannot get public key from signer")? | ||
| 110 | }), | ||
| 111 | client, | ||
| 112 | git_repo.get_path()?, | ||
| 113 | silent, | ||
| 114 | ) | ||
| 115 | .await?; | ||
| 116 | |||
| 117 | if !silent { | ||
| 118 | print_logged_in_as(&user_ref, client.is_none())?; | ||
| 119 | } | ||
| 120 | Ok((signer, user_ref)) | ||
| 121 | } else { | 32 | } else { |
| 122 | fresh_login(git_repo, client, change_user).await | 33 | fresh_login_or_signup(git_repo, client, false).await |
| 123 | } | 34 | } |
| 124 | } | 35 | } |
| 125 | 36 | ||
| 126 | fn print_logged_in_as(user_ref: &UserRef, offline_mode: bool) -> Result<()> { | 37 | #[derive(Clone)] |
| 38 | pub enum SignerInfo { | ||
| 39 | Nsec { | ||
| 40 | nsec: String, | ||
| 41 | password: Option<String>, | ||
| 42 | npub: Option<String>, | ||
| 43 | }, | ||
| 44 | Bunker { | ||
| 45 | bunker_uri: String, | ||
| 46 | bunker_app_key: String, | ||
| 47 | npub: Option<String>, | ||
| 48 | }, | ||
| 49 | } | ||
| 50 | |||
| 51 | #[derive(PartialEq, Clone)] | ||
| 52 | pub enum SignerInfoSource { | ||
| 53 | GitLocal, | ||
| 54 | GitGlobal, | ||
| 55 | CommandLineArguments, | ||
| 56 | } | ||
| 57 | |||
| 58 | fn print_logged_in_as( | ||
| 59 | user_ref: &UserRef, | ||
| 60 | offline_mode: bool, | ||
| 61 | source: &SignerInfoSource, | ||
| 62 | ) -> Result<()> { | ||
| 127 | if !offline_mode && user_ref.metadata.created_at.eq(&Timestamp::from(0)) { | 63 | if !offline_mode && user_ref.metadata.created_at.eq(&Timestamp::from(0)) { |
| 128 | eprintln!("cannot find profile..."); | 64 | eprintln!("cannot find profile..."); |
| 129 | } else if !offline_mode && user_ref.metadata.name.eq(&user_ref.public_key.to_bech32()?) { | 65 | } else if !offline_mode && user_ref.metadata.name.eq(&user_ref.public_key.to_bech32()?) { |
| @@ -133,703 +69,21 @@ fn print_logged_in_as(user_ref: &UserRef, offline_mode: bool) -> Result<()> { | |||
| 133 | "cannot find your relay list. consider using another nostr client to create one to enhance your nostr experience." | 69 | "cannot find your relay list. consider using another nostr client to create one to enhance your nostr experience." |
| 134 | ); | 70 | ); |
| 135 | } | 71 | } |
| 136 | eprintln!("logged in as {}", user_ref.metadata.name); | 72 | eprintln!( |
| 137 | Ok(()) | 73 | "logged in as {}{}", |
| 138 | } | 74 | user_ref.metadata.name, |
| 139 | 75 | match source { | |
| 140 | async fn get_signer_without_prompts( | 76 | SignerInfoSource::CommandLineArguments => " via cli arguments", |
| 141 | git_repo: &Repo, | 77 | SignerInfoSource::GitLocal => " just to local repository", |
| 142 | bunker_uri: &Option<String>, | 78 | SignerInfoSource::GitGlobal => "", |
| 143 | bunker_app_key: &Option<String>, | ||
| 144 | nsec: &Option<String>, | ||
| 145 | password: &Option<String>, | ||
| 146 | save_local: bool, | ||
| 147 | ) -> Result<(Arc<dyn NostrSigner>, Option<PublicKey>)> { | ||
| 148 | if let Some(nsec) = nsec { | ||
| 149 | Ok(( | ||
| 150 | Arc::new(get_keys_from_nsec(git_repo, nsec, password, save_local)?), | ||
| 151 | None, | ||
| 152 | )) | ||
| 153 | } else if let Some(password) = password { | ||
| 154 | Ok((Arc::new(get_keys_with_password(git_repo, password)?), None)) | ||
| 155 | } else if let Some(bunker_uri) = bunker_uri { | ||
| 156 | if let Some(bunker_app_key) = bunker_app_key { | ||
| 157 | let signer = get_nip46_signer_from_uri_and_key(bunker_uri, bunker_app_key) | ||
| 158 | .await | ||
| 159 | .context("failed to connect with remote signer")?; | ||
| 160 | if save_local { | ||
| 161 | save_to_git_config( | ||
| 162 | git_repo, | ||
| 163 | &signer.get_public_key().await?.to_bech32()?, | ||
| 164 | &None, | ||
| 165 | &Some((bunker_uri.to_string(),bunker_app_key.to_string())), | ||
| 166 | false, | ||
| 167 | ) | ||
| 168 | .context("failed to save bunker details local git config nostr.bunker-uri and nostr.bunker-app-key")?; | ||
| 169 | } | ||
| 170 | Ok((signer, None)) | ||
| 171 | } else { | ||
| 172 | bail!( | ||
| 173 | "bunker-app-key parameter must be provided alongside bunker-uri. if unknown, login interactively." | ||
| 174 | ) | ||
| 175 | } | ||
| 176 | } else if !save_local { | ||
| 177 | get_signer_with_git_config_nsec_or_bunker_without_prompts(git_repo).await | ||
| 178 | } else { | ||
| 179 | bail!("user wants prompts to specify new keys") | ||
| 180 | } | ||
| 181 | } | ||
| 182 | |||
| 183 | fn get_keys_from_nsec( | ||
| 184 | git_repo: &Repo, | ||
| 185 | nsec: &String, | ||
| 186 | password: &Option<String>, | ||
| 187 | save_local: bool, | ||
| 188 | ) -> Result<nostr::Keys> { | ||
| 189 | #[allow(unused_assignments)] | ||
| 190 | let mut s = String::new(); | ||
| 191 | let keys = if nsec.contains("ncryptsec") { | ||
| 192 | s = nsec.to_string(); | ||
| 193 | decrypt_key( | ||
| 194 | nsec, | ||
| 195 | password | ||
| 196 | .clone() | ||
| 197 | .context("password must be supplied when using ncryptsec as nsec parameter")? | ||
| 198 | .as_str(), | ||
| 199 | ) | ||
| 200 | .context("failed to decrypt key with provided password") | ||
| 201 | .context("failed to decrypt ncryptsec supplied as nsec with password")? | ||
| 202 | } else { | ||
| 203 | s = nsec.to_string(); | ||
| 204 | nostr::Keys::from_str(nsec).context("invalid nsec parameter")? | ||
| 205 | }; | ||
| 206 | if save_local { | ||
| 207 | if let Some(password) = password { | ||
| 208 | s = encrypt_key(&keys, password)?; | ||
| 209 | } | ||
| 210 | save_to_git_config( | ||
| 211 | git_repo, | ||
| 212 | &keys.public_key().to_bech32()?, | ||
| 213 | &Some(s), | ||
| 214 | &None, | ||
| 215 | false, | ||
| 216 | ) | ||
| 217 | .context("failed to save encrypted nsec in local git config nostr.nsec")?; | ||
| 218 | } | ||
| 219 | Ok(keys) | ||
| 220 | } | ||
| 221 | |||
| 222 | fn save_to_git_config( | ||
| 223 | git_repo: &Repo, | ||
| 224 | npub: &str, | ||
| 225 | nsec: &Option<String>, | ||
| 226 | bunker: &Option<(String, String)>, | ||
| 227 | global: bool, | ||
| 228 | ) -> Result<()> { | ||
| 229 | if let Err(error) = silently_save_to_git_config(git_repo, npub, nsec, bunker, global) { | ||
| 230 | eprintln!( | ||
| 231 | "failed to save login details to {} git config", | ||
| 232 | if global { "global" } else { "local" } | ||
| 233 | ); | ||
| 234 | if let Some(nsec) = nsec { | ||
| 235 | if nsec.contains("ncryptsec") { | ||
| 236 | eprintln!("manually set git config nostr.nsec to: {nsec}"); | ||
| 237 | } else { | ||
| 238 | eprintln!("manually set git config nostr.nsec"); | ||
| 239 | } | ||
| 240 | } | ||
| 241 | if let Some(bunker) = bunker { | ||
| 242 | eprintln!("manually set git config as follows:"); | ||
| 243 | eprintln!("nostr.bunker-uri: {}", bunker.0); | ||
| 244 | eprintln!("nostr.bunker-app-key: {}", bunker.1); | ||
| 245 | } | ||
| 246 | Err(error) | ||
| 247 | } else { | ||
| 248 | eprintln!( | ||
| 249 | "saved login details to {} git config", | ||
| 250 | if global { "global" } else { "local" } | ||
| 251 | ); | ||
| 252 | Ok(()) | ||
| 253 | } | ||
| 254 | } | ||
| 255 | fn silently_save_to_git_config( | ||
| 256 | git_repo: &Repo, | ||
| 257 | npub: &str, | ||
| 258 | nsec: &Option<String>, | ||
| 259 | bunker: &Option<(String, String)>, | ||
| 260 | global: bool, | ||
| 261 | ) -> Result<()> { | ||
| 262 | // must do this first otherwise it might remove the global items just added | ||
| 263 | if global { | ||
| 264 | git_repo.remove_git_config_item("nostr.npub", false)?; | ||
| 265 | git_repo.remove_git_config_item("nostr.nsec", false)?; | ||
| 266 | git_repo.remove_git_config_item("nostr.bunker-uri", false)?; | ||
| 267 | git_repo.remove_git_config_item("nostr.bunker-app-key", false)?; | ||
| 268 | } | ||
| 269 | if let Some(bunker) = bunker { | ||
| 270 | git_repo.remove_git_config_item("nostr.nsec", global)?; | ||
| 271 | git_repo.save_git_config_item("nostr.bunker-uri", &bunker.0, global)?; | ||
| 272 | git_repo.save_git_config_item("nostr.bunker-app-key", &bunker.1, global)?; | ||
| 273 | } | ||
| 274 | if let Some(nsec) = nsec { | ||
| 275 | git_repo.save_git_config_item("nostr.nsec", nsec, global)?; | ||
| 276 | git_repo.remove_git_config_item("nostr.bunker-uri", global)?; | ||
| 277 | git_repo.remove_git_config_item("nostr.bunker-app-key", global)?; | ||
| 278 | } | ||
| 279 | git_repo.save_git_config_item("nostr.npub", npub, global) | ||
| 280 | } | ||
| 281 | |||
| 282 | fn get_keys_with_password(git_repo: &Repo, password: &str) -> Result<nostr::Keys> { | ||
| 283 | decrypt_key( | ||
| 284 | &git_repo | ||
| 285 | .get_git_config_item("nostr.nsec", None) | ||
| 286 | .context("failed get git config")? | ||
| 287 | .context("git config item nostr.nsec doesn't exist so cannot decrypt it")?, | ||
| 288 | password, | ||
| 289 | ) | ||
| 290 | .context("failed to decrypt stored nsec key with provided password") | ||
| 291 | } | ||
| 292 | |||
| 293 | async fn get_nip46_signer_from_uri_and_key( | ||
| 294 | uri: &str, | ||
| 295 | app_key: &str, | ||
| 296 | ) -> Result<Arc<dyn NostrSigner>> { | ||
| 297 | let term = console::Term::stderr(); | ||
| 298 | term.write_line("connecting to remote signer...")?; | ||
| 299 | let uri = NostrConnectURI::parse(uri)?; | ||
| 300 | let signer = Arc::new(NostrConnect::new( | ||
| 301 | uri, | ||
| 302 | nostr::Keys::from_str(app_key).context("invalid app key")?, | ||
| 303 | Duration::from_secs(10 * 60), | ||
| 304 | None, | ||
| 305 | )?); | ||
| 306 | term.clear_last_lines(1)?; | ||
| 307 | Ok(signer) | ||
| 308 | } | ||
| 309 | |||
| 310 | async fn get_signer_with_git_config_nsec_or_bunker_without_prompts( | ||
| 311 | git_repo: &Repo, | ||
| 312 | ) -> Result<(Arc<dyn NostrSigner>, Option<PublicKey>)> { | ||
| 313 | if let Ok(local_nsec) = &git_repo | ||
| 314 | .get_git_config_item("nostr.nsec", Some(false)) | ||
| 315 | .context("failed get local git config")? | ||
| 316 | .context("git local config item nostr.nsec doesn't exist") | ||
| 317 | { | ||
| 318 | if local_nsec.contains("ncryptsec") { | ||
| 319 | bail!("git global config item nostr.nsec is an ncryptsec") | ||
| 320 | } | ||
| 321 | Ok(( | ||
| 322 | Arc::new(nostr::Keys::from_str(local_nsec).context("invalid nsec parameter")?), | ||
| 323 | None, | ||
| 324 | )) | ||
| 325 | } else if let Ok((uri, app_key, npub)) = | ||
| 326 | get_git_config_bunker_uri_and_app_key(git_repo, Some(false)) | ||
| 327 | { | ||
| 328 | Ok(( | ||
| 329 | get_nip46_signer_from_uri_and_key(&uri, &app_key).await?, | ||
| 330 | if let Ok(pubic_key) = PublicKey::parse(npub) { | ||
| 331 | Some(pubic_key) | ||
| 332 | } else { | ||
| 333 | None | ||
| 334 | }, | ||
| 335 | )) | ||
| 336 | } else if let Ok(global_nsec) = &git_repo | ||
| 337 | .get_git_config_item("nostr.nsec", Some(true)) | ||
| 338 | .context("failed get global git config")? | ||
| 339 | .context("git global config item nostr.nsec doesn't exist") | ||
| 340 | { | ||
| 341 | if global_nsec.contains("ncryptsec") { | ||
| 342 | bail!("git global config item nostr.nsec is an ncryptsec") | ||
| 343 | } | ||
| 344 | Ok(( | ||
| 345 | Arc::new(nostr::Keys::from_str(global_nsec).context("invalid nsec parameter")?), | ||
| 346 | None, | ||
| 347 | )) | ||
| 348 | } else if let Ok((uri, app_key, npub)) = | ||
| 349 | get_git_config_bunker_uri_and_app_key(git_repo, Some(true)) | ||
| 350 | { | ||
| 351 | Ok(( | ||
| 352 | get_nip46_signer_from_uri_and_key(&uri, &app_key).await?, | ||
| 353 | if let Ok(pubic_key) = PublicKey::parse(npub) { | ||
| 354 | Some(pubic_key) | ||
| 355 | } else { | ||
| 356 | None | ||
| 357 | }, | ||
| 358 | )) | ||
| 359 | } else { | ||
| 360 | bail!("cannot get nsec or bunker from git config") | ||
| 361 | } | ||
| 362 | } | ||
| 363 | |||
| 364 | fn get_git_config_bunker_uri_and_app_key( | ||
| 365 | git_repo: &Repo, | ||
| 366 | global: Option<bool>, | ||
| 367 | ) -> Result<(String, String, String)> { | ||
| 368 | Ok(( | ||
| 369 | git_repo | ||
| 370 | .get_git_config_item("nostr.bunker-uri", global) | ||
| 371 | .context("failed get local git config")? | ||
| 372 | .context("git local config item nostr.bunker-uri doesn't exist")? | ||
| 373 | .to_string(), | ||
| 374 | git_repo | ||
| 375 | .get_git_config_item("nostr.bunker-app-key", global) | ||
| 376 | .context("failed get local git config")? | ||
| 377 | .context("git local config item nostr.bunker-app-key doesn't exist")? | ||
| 378 | .to_string(), | ||
| 379 | git_repo | ||
| 380 | .get_git_config_item("nostr.npub", global) | ||
| 381 | .context("failed get local git config")? | ||
| 382 | .context("git local config item nostr.npub doesn't exist")? | ||
| 383 | .to_string(), | ||
| 384 | )) | ||
| 385 | } | ||
| 386 | |||
| 387 | async fn fresh_login( | ||
| 388 | git_repo: &Repo, | ||
| 389 | #[cfg(test)] client: Option<&MockConnect>, | ||
| 390 | #[cfg(not(test))] client: Option<&Client>, | ||
| 391 | always_save: bool, | ||
| 392 | ) -> Result<(Arc<dyn NostrSigner>, UserRef)> { | ||
| 393 | let app_key = Keys::generate(); | ||
| 394 | let app_key_secret = app_key.secret_key().to_secret_hex(); | ||
| 395 | let relays = if let Some(client) = client { | ||
| 396 | client | ||
| 397 | .get_fallback_signer_relays() | ||
| 398 | .iter() | ||
| 399 | .flat_map(|s| Url::parse(s)) | ||
| 400 | .collect::<Vec<Url>>() | ||
| 401 | } else { | ||
| 402 | vec![] | ||
| 403 | }; | ||
| 404 | let offline = client.is_none(); | ||
| 405 | let nostr_connect_url = NostrConnectURI::client(app_key.public_key(), relays.clone(), "ngit"); | ||
| 406 | let qr = generate_qr(&nostr_connect_url.to_string())?; | ||
| 407 | |||
| 408 | let printer = Arc::new(Mutex::new(Printer::default())); | ||
| 409 | if !offline { | ||
| 410 | let printer_clone = Arc::clone(&printer); | ||
| 411 | let mut printer_locked = printer_clone.lock().await; | ||
| 412 | printer_locked.printlns(qr); | ||
| 413 | printer_locked.println(format!( | ||
| 414 | "scan QR or paste into remote signer: {nostr_connect_url}" | ||
| 415 | )); | ||
| 416 | printer_locked.println_with_custom_formatting( | ||
| 417 | { | ||
| 418 | let mut s = String::new(); | ||
| 419 | let _ = ColorfulTheme::default().format_confirm_prompt( | ||
| 420 | &mut s, | ||
| 421 | "login with nsec / bunker url / nostr address instead", | ||
| 422 | Some(true), | ||
| 423 | ); | ||
| 424 | s | ||
| 425 | }, | ||
| 426 | "? login with nsec / bunker url / nostr address instead? (y/n) › yes".to_string(), | ||
| 427 | ); | ||
| 428 | } | ||
| 429 | |||
| 430 | let (tx, rx) = oneshot::channel(); | ||
| 431 | let printer_clone = Arc::clone(&printer); | ||
| 432 | |||
| 433 | let qr_listener = tokio::spawn(async move { | ||
| 434 | if offline { | ||
| 435 | return; | ||
| 436 | } | ||
| 437 | if let Ok(nostr_connect) = NostrConnect::new( | ||
| 438 | nostr_connect_url.clone(), | ||
| 439 | app_key.clone(), | ||
| 440 | Duration::from_secs(10 * 60), | ||
| 441 | None, | ||
| 442 | ) { | ||
| 443 | let signer: Arc<dyn NostrSigner> = Arc::new(nostr_connect); | ||
| 444 | if let Ok(pub_key) = fetch_public_key(&signer).await { | ||
| 445 | let mut printer_locked = printer_clone.lock().await; | ||
| 446 | printer_locked.clear_all(); | ||
| 447 | |||
| 448 | printer_locked.println_with_custom_formatting( | ||
| 449 | format!( | ||
| 450 | "{}", | ||
| 451 | Style::new().bold().apply_to("connected to remote signer"), | ||
| 452 | ), | ||
| 453 | "connected to remote signer".to_string(), | ||
| 454 | ); | ||
| 455 | printer_locked.println("press any key to continue...".to_string()); | ||
| 456 | let _ = tx.send(Some((signer, pub_key))); | ||
| 457 | } | ||
| 458 | } | ||
| 459 | }); | ||
| 460 | if !offline { | ||
| 461 | let _ = console::Term::stderr().read_char(); | ||
| 462 | } | ||
| 463 | qr_listener.abort(); | ||
| 464 | let printer_clone = Arc::clone(&printer); | ||
| 465 | let mut printer = printer_clone.lock().await; | ||
| 466 | printer.clear_all(); | ||
| 467 | |||
| 468 | let (signer, public_key) = { | ||
| 469 | if let Ok(Some((signer, public_key))) = rx.await { | ||
| 470 | let bunker_url = NostrConnectURI::Bunker { | ||
| 471 | remote_signer_public_key: public_key, | ||
| 472 | relays: relays.clone(), | ||
| 473 | secret: None, | ||
| 474 | }; | ||
| 475 | if let Err(error) = save_bunker( | ||
| 476 | git_repo, | ||
| 477 | &public_key, | ||
| 478 | &bunker_url.to_string(), | ||
| 479 | &app_key_secret, | ||
| 480 | always_save, | ||
| 481 | ) { | ||
| 482 | eprintln!("{error}"); | ||
| 483 | } | ||
| 484 | (signer, public_key) | ||
| 485 | } else { | ||
| 486 | let mut public_key: Option<PublicKey> = None; | ||
| 487 | // prompt for nsec | ||
| 488 | let mut prompt = "login with nsec / bunker url / nostr address"; | ||
| 489 | let signer: Arc<dyn NostrSigner> = loop { | ||
| 490 | let input = Interactor::default() | ||
| 491 | .input(PromptInputParms::default().with_prompt(prompt)) | ||
| 492 | .context("failed to get nsec input from interactor")?; | ||
| 493 | if let Ok(keys) = nostr::Keys::from_str(&input) { | ||
| 494 | if let Err(error) = save_keys(git_repo, &keys, always_save) { | ||
| 495 | eprintln!("{error}"); | ||
| 496 | } | ||
| 497 | break Arc::new(keys); | ||
| 498 | } | ||
| 499 | let uri = if let Ok(uri) = NostrConnectURI::parse(&input) { | ||
| 500 | uri | ||
| 501 | } else if input.contains('@') { | ||
| 502 | if let Ok(uri) = fetch_nip46_uri_from_nip05(&input).await { | ||
| 503 | uri | ||
| 504 | } else { | ||
| 505 | prompt = "failed. try again with nostr address / bunker uri / nsec"; | ||
| 506 | continue; | ||
| 507 | } | ||
| 508 | } else { | ||
| 509 | prompt = "invalid. try again with nostr address / bunker uri / nsec"; | ||
| 510 | continue; | ||
| 511 | }; | ||
| 512 | match get_nip46_signer_from_uri_and_key(&uri.to_string(), &app_key_secret).await { | ||
| 513 | Ok(signer) => { | ||
| 514 | let pub_key = fetch_public_key(&signer).await?; | ||
| 515 | if let Err(error) = save_bunker( | ||
| 516 | git_repo, | ||
| 517 | &pub_key, | ||
| 518 | &uri.to_string(), | ||
| 519 | &app_key_secret, | ||
| 520 | always_save, | ||
| 521 | ) { | ||
| 522 | eprintln!("{error}"); | ||
| 523 | } | ||
| 524 | public_key = Some(pub_key); | ||
| 525 | break signer; | ||
| 526 | } | ||
| 527 | Err(_) => { | ||
| 528 | prompt = "failed. try again with nostr address / bunker uri / nsec"; | ||
| 529 | } | ||
| 530 | } | ||
| 531 | }; | ||
| 532 | let public_key = if let Some(public_key) = public_key { | ||
| 533 | public_key | ||
| 534 | } else { | ||
| 535 | signer.get_public_key().await? | ||
| 536 | }; | ||
| 537 | (signer, public_key) | ||
| 538 | } | ||
| 539 | }; | ||
| 540 | // lookup profile | ||
| 541 | let user_ref = get_user_details(&public_key, client, git_repo.get_path()?, false).await?; | ||
| 542 | print_logged_in_as(&user_ref, client.is_none())?; | ||
| 543 | Ok((signer, user_ref)) | ||
| 544 | } | ||
| 545 | |||
| 546 | fn generate_qr(data: &str) -> Result<Vec<String>> { | ||
| 547 | let mut lines = vec![]; | ||
| 548 | let qr = | ||
| 549 | QrCode::new(data.as_bytes()).context("failed to create QR of nostrconnect login url")?; | ||
| 550 | let colors = qr.to_colors(); | ||
| 551 | let rows: Vec<&[qrcode::Color]> = colors.chunks(qr.width()).collect(); | ||
| 552 | for (row, data) in rows.iter().enumerate() { | ||
| 553 | let odd = row % 2 != 0; | ||
| 554 | if odd { | ||
| 555 | continue; | ||
| 556 | } | ||
| 557 | let mut line = String::new(); | ||
| 558 | for (col, color) in data.iter().enumerate() { | ||
| 559 | let top = color; | ||
| 560 | let mut bottom = qrcode::Color::Light; | ||
| 561 | if let Some(next_row_data) = rows.get(row + 1) { | ||
| 562 | if let Some(color) = next_row_data.get(col) { | ||
| 563 | bottom = *color; | ||
| 564 | } | ||
| 565 | } | ||
| 566 | line.push(if *top == qrcode::Color::Dark { | ||
| 567 | if bottom == qrcode::Color::Dark { | ||
| 568 | '█' | ||
| 569 | } else { | ||
| 570 | '▀' | ||
| 571 | } | ||
| 572 | } else if bottom == qrcode::Color::Dark { | ||
| 573 | '▄' | ||
| 574 | } else { | ||
| 575 | ' ' | ||
| 576 | }); | ||
| 577 | } | ||
| 578 | lines.push(line); | ||
| 579 | } | ||
| 580 | Ok(lines) | ||
| 581 | } | ||
| 582 | |||
| 583 | pub async fn fetch_nip46_uri_from_nip05(nip05: &str) -> Result<NostrConnectURI> { | ||
| 584 | let term = console::Term::stderr(); | ||
| 585 | term.write_line("contacting login service provider...")?; | ||
| 586 | let res = nip05::profile(&nip05, None).await; | ||
| 587 | term.clear_last_lines(1)?; | ||
| 588 | match res { | ||
| 589 | Ok(profile) => { | ||
| 590 | if profile.nip46.is_empty() { | ||
| 591 | eprintln!("nip05 provider isn't configured for remote login"); | ||
| 592 | bail!("nip05 provider isn't configured for remote login") | ||
| 593 | } | ||
| 594 | Ok(NostrConnectURI::Bunker { | ||
| 595 | remote_signer_public_key: profile.public_key, | ||
| 596 | relays: profile.nip46, | ||
| 597 | secret: None, | ||
| 598 | }) | ||
| 599 | } | 79 | } |
| 600 | Err(error) => { | 80 | ); |
| 601 | eprintln!("error contacting login service provider: {error}"); | ||
| 602 | Err(error).context("error contacting login service provider") | ||
| 603 | } | ||
| 604 | } | ||
| 605 | } | ||
| 606 | |||
| 607 | fn save_bunker( | ||
| 608 | git_repo: &Repo, | ||
| 609 | public_key: &PublicKey, | ||
| 610 | uri: &str, | ||
| 611 | app_key: &str, | ||
| 612 | always_save: bool, | ||
| 613 | ) -> Result<()> { | ||
| 614 | if always_save | ||
| 615 | || Interactor::default() | ||
| 616 | .confirm(PromptConfirmParms::default().with_prompt("save login details?"))? | ||
| 617 | { | ||
| 618 | let global = !Interactor::default().confirm( | ||
| 619 | PromptConfirmParms::default() | ||
| 620 | .with_prompt("save login just for this repository?") | ||
| 621 | .with_default(false), | ||
| 622 | )?; | ||
| 623 | let npub = public_key.to_bech32()?; | ||
| 624 | if let Err(error) = save_to_git_config( | ||
| 625 | git_repo, | ||
| 626 | &npub, | ||
| 627 | &None, | ||
| 628 | &Some((uri.to_string(), app_key.to_string())), | ||
| 629 | global, | ||
| 630 | ) { | ||
| 631 | if global { | ||
| 632 | if Interactor::default().confirm( | ||
| 633 | PromptConfirmParms::default() | ||
| 634 | .with_prompt("save in repository git config?") | ||
| 635 | .with_default(true), | ||
| 636 | )? { | ||
| 637 | save_to_git_config( | ||
| 638 | git_repo, | ||
| 639 | &npub, | ||
| 640 | &None, | ||
| 641 | &Some((uri.to_string(), app_key.to_string())), | ||
| 642 | false, | ||
| 643 | )?; | ||
| 644 | } | ||
| 645 | } else { | ||
| 646 | Err(error)?; | ||
| 647 | } | ||
| 648 | }; | ||
| 649 | } | ||
| 650 | Ok(()) | ||
| 651 | } | ||
| 652 | |||
| 653 | fn save_keys(git_repo: &Repo, keys: &nostr::Keys, always_save: bool) -> Result<()> { | ||
| 654 | if always_save | ||
| 655 | || Interactor::default() | ||
| 656 | .confirm(PromptConfirmParms::default().with_prompt("save login details?"))? | ||
| 657 | { | ||
| 658 | let global = !Interactor::default().confirm( | ||
| 659 | PromptConfirmParms::default() | ||
| 660 | .with_prompt("just for this repository?") | ||
| 661 | .with_default(false), | ||
| 662 | )?; | ||
| 663 | |||
| 664 | let encrypt = Interactor::default().confirm( | ||
| 665 | PromptConfirmParms::default() | ||
| 666 | .with_prompt("require password?") | ||
| 667 | .with_default(false), | ||
| 668 | )?; | ||
| 669 | |||
| 670 | let npub = keys.public_key().to_bech32()?; | ||
| 671 | let nsec_string = if encrypt { | ||
| 672 | let password = Interactor::default() | ||
| 673 | .password( | ||
| 674 | PromptPasswordParms::default() | ||
| 675 | .with_prompt("encrypt with password") | ||
| 676 | .with_confirm(), | ||
| 677 | ) | ||
| 678 | .context("failed to get password input from interactor.password")?; | ||
| 679 | encrypt_key(keys, &password)? | ||
| 680 | } else { | ||
| 681 | keys.secret_key().to_bech32()? | ||
| 682 | }; | ||
| 683 | |||
| 684 | if let Err(error) = | ||
| 685 | save_to_git_config(git_repo, &npub, &Some(nsec_string.clone()), &None, global) | ||
| 686 | { | ||
| 687 | if global { | ||
| 688 | if Interactor::default().confirm( | ||
| 689 | PromptConfirmParms::default() | ||
| 690 | .with_prompt("save in repository git config?") | ||
| 691 | .with_default(true), | ||
| 692 | )? { | ||
| 693 | save_to_git_config(git_repo, &npub, &Some(nsec_string.clone()), &None, false)?; | ||
| 694 | } | ||
| 695 | } else { | ||
| 696 | eprintln!("{error}"); | ||
| 697 | Err(error)?; | ||
| 698 | } | ||
| 699 | }; | ||
| 700 | }; | ||
| 701 | Ok(()) | 81 | Ok(()) |
| 702 | } | 82 | } |
| 703 | 83 | ||
| 704 | fn get_config_item(git_repo: &Repo, name: &str) -> Result<String> { | ||
| 705 | git_repo | ||
| 706 | .get_git_config_item(name, None) | ||
| 707 | .context("failed get git config")? | ||
| 708 | .context(format!("git config item {name} doesn't exist")) | ||
| 709 | } | ||
| 710 | |||
| 711 | fn extract_user_metadata( | ||
| 712 | public_key: &nostr::PublicKey, | ||
| 713 | events: &[nostr::Event], | ||
| 714 | ) -> Result<UserMetadata> { | ||
| 715 | let event = events | ||
| 716 | .iter() | ||
| 717 | .filter(|e| e.kind.eq(&nostr::Kind::Metadata) && e.pubkey.eq(public_key)) | ||
| 718 | .max_by_key(|e| e.created_at); | ||
| 719 | |||
| 720 | let metadata: Option<nostr::Metadata> = if let Some(event) = event { | ||
| 721 | Some( | ||
| 722 | nostr::Metadata::from_json(event.content.clone()) | ||
| 723 | .context("metadata cannot be found in kind 0 event content")?, | ||
| 724 | ) | ||
| 725 | } else { | ||
| 726 | None | ||
| 727 | }; | ||
| 728 | |||
| 729 | Ok(UserMetadata { | ||
| 730 | name: if let Some(metadata) = metadata { | ||
| 731 | if let Some(n) = metadata.name { | ||
| 732 | n | ||
| 733 | } else if let Some(n) = metadata.custom.get("displayName") { | ||
| 734 | // strip quote marks that custom.get() adds | ||
| 735 | let binding = n.to_string(); | ||
| 736 | let mut chars = binding.chars(); | ||
| 737 | chars.next(); | ||
| 738 | chars.next_back(); | ||
| 739 | chars.as_str().to_string() | ||
| 740 | } else if let Some(n) = metadata.display_name { | ||
| 741 | n | ||
| 742 | } else { | ||
| 743 | public_key.to_bech32()? | ||
| 744 | } | ||
| 745 | } else { | ||
| 746 | public_key.to_bech32()? | ||
| 747 | }, | ||
| 748 | created_at: if let Some(event) = event { | ||
| 749 | event.created_at | ||
| 750 | } else { | ||
| 751 | Timestamp::from(0) | ||
| 752 | }, | ||
| 753 | }) | ||
| 754 | } | ||
| 755 | |||
| 756 | fn extract_user_relays(public_key: &nostr::PublicKey, events: &[nostr::Event]) -> UserRelays { | ||
| 757 | let event = events | ||
| 758 | .iter() | ||
| 759 | .filter(|e| e.kind.eq(&nostr::Kind::RelayList) && e.pubkey.eq(public_key)) | ||
| 760 | .max_by_key(|e| e.created_at); | ||
| 761 | |||
| 762 | UserRelays { | ||
| 763 | relays: if let Some(event) = event { | ||
| 764 | event | ||
| 765 | .tags | ||
| 766 | .iter() | ||
| 767 | .filter(|t| { | ||
| 768 | t.kind() | ||
| 769 | .eq(&nostr::TagKind::SingleLetter(SingleLetterTag::lowercase( | ||
| 770 | Alphabet::R, | ||
| 771 | ))) | ||
| 772 | }) | ||
| 773 | .map(|t| UserRelayRef { | ||
| 774 | url: t.as_slice()[1].clone(), | ||
| 775 | read: t.as_slice().len() == 2 || t.as_slice()[2].eq("read"), | ||
| 776 | write: t.as_slice().len() == 2 || t.as_slice()[2].eq("write"), | ||
| 777 | }) | ||
| 778 | .collect() | ||
| 779 | } else { | ||
| 780 | vec![] | ||
| 781 | }, | ||
| 782 | created_at: if let Some(event) = event { | ||
| 783 | event.created_at | ||
| 784 | } else { | ||
| 785 | Timestamp::from(0) | ||
| 786 | }, | ||
| 787 | } | ||
| 788 | } | ||
| 789 | |||
| 790 | async fn get_user_details( | ||
| 791 | public_key: &PublicKey, | ||
| 792 | #[cfg(test)] client: Option<&crate::client::MockConnect>, | ||
| 793 | #[cfg(not(test))] client: Option<&Client>, | ||
| 794 | git_repo_path: &Path, | ||
| 795 | cache_only: bool, | ||
| 796 | ) -> Result<UserRef> { | ||
| 797 | if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await { | ||
| 798 | Ok(user_ref) | ||
| 799 | } else { | ||
| 800 | let empty = UserRef { | ||
| 801 | public_key: public_key.to_owned(), | ||
| 802 | metadata: extract_user_metadata(public_key, &[])?, | ||
| 803 | relays: extract_user_relays(public_key, &[]), | ||
| 804 | }; | ||
| 805 | if cache_only { | ||
| 806 | Ok(empty) | ||
| 807 | } else if let Some(client) = client { | ||
| 808 | let term = console::Term::stderr(); | ||
| 809 | term.write_line("searching for profile...")?; | ||
| 810 | let (_, progress_reporter) = client | ||
| 811 | .fetch_all( | ||
| 812 | git_repo_path, | ||
| 813 | &HashSet::new(), | ||
| 814 | &HashSet::from_iter(vec![*public_key]), | ||
| 815 | ) | ||
| 816 | .await?; | ||
| 817 | if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await { | ||
| 818 | progress_reporter.clear()?; | ||
| 819 | // if std::env::var("NGITTEST").is_err() {term.clear_last_lines(1)?;} | ||
| 820 | Ok(user_ref) | ||
| 821 | } else { | ||
| 822 | Ok(empty) | ||
| 823 | } | ||
| 824 | } else { | ||
| 825 | Ok(empty) | ||
| 826 | } | ||
| 827 | } | ||
| 828 | } | ||
| 829 | |||
| 830 | // None: in the edge case where the user is logged in via cli arguments rather | 84 | // None: in the edge case where the user is logged in via cli arguments rather |
| 831 | // than from git config this may be wrong. TODO: fix this | 85 | // than from git config this may be wrong. TODO: fix this |
| 832 | pub async fn get_logged_in_user(git_repo_path: &Path) -> Result<Option<PublicKey>> { | 86 | pub async fn get_likely_logged_in_user(git_repo_path: &Path) -> Result<Option<PublicKey>> { |
| 833 | let git_repo = Repo::from_path(&git_repo_path.to_path_buf())?; | 87 | let git_repo = Repo::from_path(&git_repo_path.to_path_buf())?; |
| 834 | Ok( | 88 | Ok( |
| 835 | if let Some(npub) = git_repo.get_git_config_item("nostr.npub", None)? { | 89 | if let Some(npub) = git_repo.get_git_config_item("nostr.npub", None)? { |
| @@ -844,31 +98,6 @@ pub async fn get_logged_in_user(git_repo_path: &Path) -> Result<Option<PublicKey | |||
| 844 | ) | 98 | ) |
| 845 | } | 99 | } |
| 846 | 100 | ||
| 847 | pub async fn get_user_ref_from_cache( | ||
| 848 | git_repo_path: &Path, | ||
| 849 | public_key: &PublicKey, | ||
| 850 | ) -> Result<UserRef> { | ||
| 851 | let filters = vec![ | ||
| 852 | nostr::Filter::default() | ||
| 853 | .author(*public_key) | ||
| 854 | .kind(Kind::Metadata), | ||
| 855 | nostr::Filter::default() | ||
| 856 | .author(*public_key) | ||
| 857 | .kind(Kind::RelayList), | ||
| 858 | ]; | ||
| 859 | |||
| 860 | let events = get_event_from_global_cache(git_repo_path, filters.clone()).await?; | ||
| 861 | |||
| 862 | if events.is_empty() { | ||
| 863 | bail!("no metadata and profile list in cache for selected public key"); | ||
| 864 | } | ||
| 865 | Ok(UserRef { | ||
| 866 | public_key: public_key.to_owned(), | ||
| 867 | metadata: extract_user_metadata(public_key, &events)?, | ||
| 868 | relays: extract_user_relays(public_key, &events), | ||
| 869 | }) | ||
| 870 | } | ||
| 871 | |||
| 872 | pub fn get_curent_user(git_repo: &Repo) -> Result<Option<PublicKey>> { | 101 | pub fn get_curent_user(git_repo: &Repo) -> Result<Option<PublicKey>> { |
| 873 | Ok( | 102 | Ok( |
| 874 | if let Some(npub) = git_repo.get_git_config_item("nostr.npub", None)? { | 103 | if let Some(npub) = git_repo.get_git_config_item("nostr.npub", None)? { |
diff --git a/src/lib/login/user.rs b/src/lib/login/user.rs index 46652db..4456308 100644 --- a/src/lib/login/user.rs +++ b/src/lib/login/user.rs | |||
| @@ -1,7 +1,16 @@ | |||
| 1 | use std::{collections::HashSet, path::Path}; | ||
| 2 | |||
| 3 | use anyhow::{bail, Context, Result}; | ||
| 1 | use nostr::PublicKey; | 4 | use nostr::PublicKey; |
| 2 | use nostr_sdk::Timestamp; | 5 | use nostr_sdk::{Alphabet, JsonUtil, Kind, SingleLetterTag, Timestamp, ToBech32}; |
| 3 | use serde::{self, Deserialize, Serialize}; | 6 | use serde::{self, Deserialize, Serialize}; |
| 4 | 7 | ||
| 8 | #[cfg(not(test))] | ||
| 9 | use crate::client::Client; | ||
| 10 | #[cfg(test)] | ||
| 11 | use crate::client::MockConnect; | ||
| 12 | use crate::client::{get_event_from_global_cache, Connect}; | ||
| 13 | |||
| 5 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] | 14 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] |
| 6 | pub struct UserRef { | 15 | pub struct UserRef { |
| 7 | pub public_key: PublicKey, | 16 | pub public_key: PublicKey, |
| @@ -37,3 +46,147 @@ pub struct UserRelayRef { | |||
| 37 | pub read: bool, | 46 | pub read: bool, |
| 38 | pub write: bool, | 47 | pub write: bool, |
| 39 | } | 48 | } |
| 49 | |||
| 50 | pub async fn get_user_details( | ||
| 51 | public_key: &PublicKey, | ||
| 52 | #[cfg(test)] client: Option<&MockConnect>, | ||
| 53 | #[cfg(not(test))] client: Option<&Client>, | ||
| 54 | git_repo_path: Option<&Path>, | ||
| 55 | cache_only: bool, | ||
| 56 | ) -> Result<UserRef> { | ||
| 57 | if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await { | ||
| 58 | Ok(user_ref) | ||
| 59 | } else { | ||
| 60 | let empty = UserRef { | ||
| 61 | public_key: public_key.to_owned(), | ||
| 62 | metadata: extract_user_metadata(public_key, &[])?, | ||
| 63 | relays: extract_user_relays(public_key, &[]), | ||
| 64 | }; | ||
| 65 | if cache_only { | ||
| 66 | Ok(empty) | ||
| 67 | } else if let Some(client) = client { | ||
| 68 | let term = console::Term::stderr(); | ||
| 69 | term.write_line("searching for profile...")?; | ||
| 70 | let (_, progress_reporter) = client | ||
| 71 | .fetch_all( | ||
| 72 | git_repo_path, | ||
| 73 | &HashSet::new(), | ||
| 74 | &HashSet::from_iter(vec![*public_key]), | ||
| 75 | ) | ||
| 76 | .await?; | ||
| 77 | if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await { | ||
| 78 | progress_reporter.clear()?; | ||
| 79 | // if std::env::var("NGITTEST").is_err() {term.clear_last_lines(1)?;} | ||
| 80 | Ok(user_ref) | ||
| 81 | } else { | ||
| 82 | Ok(empty) | ||
| 83 | } | ||
| 84 | } else { | ||
| 85 | Ok(empty) | ||
| 86 | } | ||
| 87 | } | ||
| 88 | } | ||
| 89 | |||
| 90 | pub async fn get_user_ref_from_cache( | ||
| 91 | git_repo_path: Option<&Path>, | ||
| 92 | public_key: &PublicKey, | ||
| 93 | ) -> Result<UserRef> { | ||
| 94 | let filters = vec![ | ||
| 95 | nostr::Filter::default() | ||
| 96 | .author(*public_key) | ||
| 97 | .kind(Kind::Metadata), | ||
| 98 | nostr::Filter::default() | ||
| 99 | .author(*public_key) | ||
| 100 | .kind(Kind::RelayList), | ||
| 101 | ]; | ||
| 102 | |||
| 103 | let events = get_event_from_global_cache(git_repo_path, filters.clone()).await?; | ||
| 104 | |||
| 105 | if events.is_empty() { | ||
| 106 | bail!("no metadata and profile list in cache for selected public key"); | ||
| 107 | } | ||
| 108 | Ok(UserRef { | ||
| 109 | public_key: public_key.to_owned(), | ||
| 110 | metadata: extract_user_metadata(public_key, &events)?, | ||
| 111 | relays: extract_user_relays(public_key, &events), | ||
| 112 | }) | ||
| 113 | } | ||
| 114 | |||
| 115 | pub fn extract_user_metadata( | ||
| 116 | public_key: &nostr::PublicKey, | ||
| 117 | events: &[nostr::Event], | ||
| 118 | ) -> Result<UserMetadata> { | ||
| 119 | let event = events | ||
| 120 | .iter() | ||
| 121 | .filter(|e| e.kind.eq(&nostr::Kind::Metadata) && e.pubkey.eq(public_key)) | ||
| 122 | .max_by_key(|e| e.created_at); | ||
| 123 | |||
| 124 | let metadata: Option<nostr::Metadata> = if let Some(event) = event { | ||
| 125 | Some( | ||
| 126 | nostr::Metadata::from_json(event.content.clone()) | ||
| 127 | .context("metadata cannot be found in kind 0 event content")?, | ||
| 128 | ) | ||
| 129 | } else { | ||
| 130 | None | ||
| 131 | }; | ||
| 132 | |||
| 133 | Ok(UserMetadata { | ||
| 134 | name: if let Some(metadata) = metadata { | ||
| 135 | if let Some(n) = metadata.name { | ||
| 136 | n | ||
| 137 | } else if let Some(n) = metadata.custom.get("displayName") { | ||
| 138 | // strip quote marks that custom.get() adds | ||
| 139 | let binding = n.to_string(); | ||
| 140 | let mut chars = binding.chars(); | ||
| 141 | chars.next(); | ||
| 142 | chars.next_back(); | ||
| 143 | chars.as_str().to_string() | ||
| 144 | } else if let Some(n) = metadata.display_name { | ||
| 145 | n | ||
| 146 | } else { | ||
| 147 | public_key.to_bech32()? | ||
| 148 | } | ||
| 149 | } else { | ||
| 150 | public_key.to_bech32()? | ||
| 151 | }, | ||
| 152 | created_at: if let Some(event) = event { | ||
| 153 | event.created_at | ||
| 154 | } else { | ||
| 155 | Timestamp::from(0) | ||
| 156 | }, | ||
| 157 | }) | ||
| 158 | } | ||
| 159 | |||
| 160 | pub fn extract_user_relays(public_key: &nostr::PublicKey, events: &[nostr::Event]) -> UserRelays { | ||
| 161 | let event = events | ||
| 162 | .iter() | ||
| 163 | .filter(|e| e.kind.eq(&nostr::Kind::RelayList) && e.pubkey.eq(public_key)) | ||
| 164 | .max_by_key(|e| e.created_at); | ||
| 165 | |||
| 166 | UserRelays { | ||
| 167 | relays: if let Some(event) = event { | ||
| 168 | event | ||
| 169 | .tags | ||
| 170 | .iter() | ||
| 171 | .filter(|t| { | ||
| 172 | t.kind() | ||
| 173 | .eq(&nostr::TagKind::SingleLetter(SingleLetterTag::lowercase( | ||
| 174 | Alphabet::R, | ||
| 175 | ))) | ||
| 176 | }) | ||
| 177 | .map(|t| UserRelayRef { | ||
| 178 | url: t.as_slice()[1].clone(), | ||
| 179 | read: t.as_slice().len() == 2 || t.as_slice()[2].eq("read"), | ||
| 180 | write: t.as_slice().len() == 2 || t.as_slice()[2].eq("write"), | ||
| 181 | }) | ||
| 182 | .collect() | ||
| 183 | } else { | ||
| 184 | vec![] | ||
| 185 | }, | ||
| 186 | created_at: if let Some(event) = event { | ||
| 187 | event.created_at | ||
| 188 | } else { | ||
| 189 | Timestamp::from(0) | ||
| 190 | }, | ||
| 191 | } | ||
| 192 | } | ||
diff --git a/src/lib/repo_ref.rs b/src/lib/repo_ref.rs index 8b48824..84de185 100644 --- a/src/lib/repo_ref.rs +++ b/src/lib/repo_ref.rs | |||
| @@ -16,7 +16,7 @@ use serde::{Deserialize, Serialize}; | |||
| 16 | use crate::client::Client; | 16 | use crate::client::Client; |
| 17 | use crate::{ | 17 | use crate::{ |
| 18 | cli_interactor::{Interactor, InteractorPrompt, PromptInputParms}, | 18 | cli_interactor::{Interactor, InteractorPrompt, PromptInputParms}, |
| 19 | client::{get_event_from_global_cache, get_events_from_cache, sign_event, Connect}, | 19 | client::{get_event_from_global_cache, get_events_from_local_cache, sign_event, Connect}, |
| 20 | git::{nostr_url::NostrUrlDecoded, Repo, RepoActions}, | 20 | git::{nostr_url::NostrUrlDecoded, Repo, RepoActions}, |
| 21 | }; | 21 | }; |
| 22 | 22 | ||
| @@ -330,10 +330,11 @@ async fn get_repo_coordinates_from_maintainers_yaml( | |||
| 330 | .reference(git_repo.get_root_commit()?.to_string()) | 330 | .reference(git_repo.get_root_commit()?.to_string()) |
| 331 | .authors(maintainers.clone()); | 331 | .authors(maintainers.clone()); |
| 332 | let mut events = | 332 | let mut events = |
| 333 | get_events_from_cache(git_repo.get_path()?, vec![filter.clone()]).await?; | 333 | get_events_from_local_cache(git_repo.get_path()?, vec![filter.clone()]).await?; |
| 334 | if events.is_empty() { | 334 | if events.is_empty() { |
| 335 | events = | 335 | events = |
| 336 | get_event_from_global_cache(git_repo.get_path()?, vec![filter.clone()]).await?; | 336 | get_event_from_global_cache(Some(git_repo.get_path()?), vec![filter.clone()]) |
| 337 | .await?; | ||
| 337 | } | 338 | } |
| 338 | if events.is_empty() { | 339 | if events.is_empty() { |
| 339 | println!( | 340 | println!( |