upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2024-11-21 16:53:17 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2024-11-21 16:53:17 +0000
commitf79014235e85554e3661b3f2a02b8fa88bc192ff (patch)
treefceec3ff2df212148a3420af7cef81a3f818463e /src/lib
parent91b0eac4daf92b7b740267ef203a1a8ba591974b (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.rs30
-rw-r--r--src/lib/client.rs174
-rw-r--r--src/lib/git/mod.rs38
-rw-r--r--src/lib/login/existing.rs212
-rw-r--r--src/lib/login/fresh.rs595
-rw-r--r--src/lib/login/key_encryption.rs38
-rw-r--r--src/lib/login/mod.rs883
-rw-r--r--src/lib/login/user.rs155
-rw-r--r--src/lib/repo_ref.rs7
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 {
73pub struct PromptInputParms { 75pub 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)]
95pub struct PromptPasswordParms { 103pub 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
100impl PromptPasswordParms { 109impl 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
745async fn get_global_cache_database(git_repo_path: &Path) -> Result<NostrLMDB> { 747async 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
758pub async fn get_events_from_cache( 765pub 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
772pub async fn get_event_from_global_cache( 779pub 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
784pub async fn save_event_in_cache(git_repo_path: &Path, event: &nostr::Event) -> Result<bool> { 791pub 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
792pub async fn save_event_in_global_cache( 799pub 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
803pub async fn get_repo_ref_from_cache( 810pub 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
869pub async fn get_state_from_cache(git_repo_path: &Path, repo_ref: &RepoRef) -> Result<RepoState> { 880pub 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)]
880async fn create_relays_request( 904async 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(
1105async fn process_fetched_events( 1137async 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
1596pub async fn get_event_from_cache_by_id(git_repo: &Repo, event_id: &EventId) -> Result<Event> { 1630pub 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) ->
1609pub async fn send_events( 1643pub 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
863pub 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
876pub 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
887pub 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)]
864mod tests { 902mod 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 @@
1use std::{str::FromStr, sync::Arc, time::Duration};
2
3use anyhow::{bail, Context, Result};
4use nostr::nips::nip46::NostrConnectURI;
5use nostr_connect::client::NostrConnect;
6use nostr_sdk::{NostrSigner, PublicKey};
7
8use 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))]
15use crate::client::Client;
16#[cfg(test)]
17use crate::client::MockConnect;
18use 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
30pub 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
63fn 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
154async 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 @@
1use std::{str::FromStr, sync::Arc, time::Duration};
2
3use anyhow::{bail, Context, Result};
4use console::{Style, Term};
5use dialoguer::theme::{ColorfulTheme, Theme};
6use nostr::nips::{nip05, nip46::NostrConnectURI};
7use nostr_connect::client::NostrConnect;
8use nostr_sdk::{Keys, NostrSigner, PublicKey, ToBech32, Url};
9use qrcode::QrCode;
10use tokio::sync::{oneshot, Mutex};
11
12use 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))]
19use crate::client::Client;
20#[cfg(test)]
21use crate::client::MockConnect;
22use 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
31pub 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
92pub 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
190fn 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
198fn 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
216fn shorten_string(s: &str) -> String {
217 if s.len() < 15 {
218 s.to_string()
219 } else {
220 format!("{}...", &s[..15])
221 }
222}
223
224pub 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
338pub 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
356pub 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
380pub 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
434fn 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
471fn 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
520fn 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
574fn 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 @@
1use anyhow::Result; 1use anyhow::Result;
2use nostr::{prelude::*, Keys}; 2use nostr::prelude::*;
3
4pub 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
22pub fn decrypt_key(encrypted_key: &str, password: &str) -> Result<nostr::Keys> { 4pub 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 @@
1use std::{collections::HashSet, path::Path, str::FromStr, sync::Arc, time::Duration}; 1use std::{path::Path, sync::Arc};
2 2
3use anyhow::{bail, Context, Result}; 3use anyhow::Result;
4use console::Style; 4use fresh::fresh_login_or_signup;
5use dialoguer::theme::{ColorfulTheme, Theme}; 5use nostr::PublicKey;
6use nostr::{ 6use nostr_sdk::{NostrSigner, Timestamp, ToBech32};
7 nips::{nip05, nip46::NostrConnectURI},
8 PublicKey,
9};
10use nostr_connect::client::NostrConnect;
11use nostr_sdk::{
12 Alphabet, FromBech32, JsonUtil, Keys, Kind, NostrSigner, SingleLetterTag, Timestamp, ToBech32,
13 Url,
14};
15use qrcode::QrCode;
16use tokio::sync::{oneshot, Mutex};
17 7
18#[cfg(not(test))] 8#[cfg(not(test))]
19use crate::client::Client; 9use crate::client::Client;
20#[cfg(test)] 10#[cfg(test)]
21use crate::client::MockConnect; 11use crate::client::MockConnect;
22use crate::{ 12use 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
14pub mod existing;
31mod key_encryption; 15mod key_encryption;
32use key_encryption::{decrypt_key, encrypt_key}; 16use existing::load_existing_login;
33mod user; 17pub mod user;
34use user::{UserMetadata, UserRef, UserRelayRef, UserRelays}; 18use user::UserRef;
35 19pub mod fresh;
36/// handles the encrpytion and storage of key material 20
37#[allow(clippy::too_many_arguments)] 21pub async fn login_or_signup(
38pub 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
126fn print_logged_in_as(user_ref: &UserRef, offline_mode: bool) -> Result<()> { 37#[derive(Clone)]
38pub 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)]
52pub enum SignerInfoSource {
53 GitLocal,
54 GitGlobal,
55 CommandLineArguments,
56}
57
58fn 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 {
140async 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
183fn 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
222fn 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}
255fn 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
282fn 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
293async 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
310async 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
364fn 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
387async 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
546fn 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
583pub 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
607fn 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
653fn 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
704fn 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
711fn 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
756fn 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
790async 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
832pub async fn get_logged_in_user(git_repo_path: &Path) -> Result<Option<PublicKey>> { 86pub 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
847pub 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
872pub fn get_curent_user(git_repo: &Repo) -> Result<Option<PublicKey>> { 101pub 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 @@
1use std::{collections::HashSet, path::Path};
2
3use anyhow::{bail, Context, Result};
1use nostr::PublicKey; 4use nostr::PublicKey;
2use nostr_sdk::Timestamp; 5use nostr_sdk::{Alphabet, JsonUtil, Kind, SingleLetterTag, Timestamp, ToBech32};
3use serde::{self, Deserialize, Serialize}; 6use serde::{self, Deserialize, Serialize};
4 7
8#[cfg(not(test))]
9use crate::client::Client;
10#[cfg(test)]
11use crate::client::MockConnect;
12use 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)]
6pub struct UserRef { 15pub 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
50pub 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
90pub 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
115pub 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
160pub 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};
16use crate::client::Client; 16use crate::client::Client;
17use crate::{ 17use 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!(