From f79014235e85554e3661b3f2a02b8fa88bc192ff Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 21 Nov 2024 16:53:17 +0000 Subject: 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 --- src/lib/login/existing.rs | 212 ++++++++++ src/lib/login/fresh.rs | 595 +++++++++++++++++++++++++++ src/lib/login/key_encryption.rs | 38 +- src/lib/login/mod.rs | 883 +++------------------------------------- src/lib/login/user.rs | 155 ++++++- 5 files changed, 1036 insertions(+), 847 deletions(-) create mode 100644 src/lib/login/existing.rs create mode 100644 src/lib/login/fresh.rs (limited to 'src/lib/login') 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 @@ +use std::{str::FromStr, sync::Arc, time::Duration}; + +use anyhow::{bail, Context, Result}; +use nostr::nips::nip46::NostrConnectURI; +use nostr_connect::client::NostrConnect; +use nostr_sdk::{NostrSigner, PublicKey}; + +use super::{ + key_encryption::decrypt_key, + print_logged_in_as, + user::{get_user_details, UserRef}, + SignerInfo, SignerInfoSource, +}; +#[cfg(not(test))] +use crate::client::Client; +#[cfg(test)] +use crate::client::MockConnect; +use crate::{ + cli_interactor::{Interactor, InteractorPrompt, PromptPasswordParms}, + client::fetch_public_key, + git::{get_git_config_item, Repo, RepoActions}, +}; + +/// load signer from git config and UserProfile from cache or relays +/// +/// # Parameters +/// - `client`: include client to fetch profiles from relays that are missing +/// from cache +/// - `silent`: do not print outcome in termianl +pub async fn load_existing_login( + git_repo: &Option<&Repo>, + signer_info: &Option, + password: &Option, + source: &Option, + #[cfg(test)] client: Option<&MockConnect>, + #[cfg(not(test))] client: Option<&Client>, + silent: bool, + prompt_for_password: bool, +) -> Result<(Arc, UserRef, SignerInfoSource)> { + let (signer_info, source) = get_signer_info(git_repo, signer_info, password, source)?; + + let (signer, public_key) = get_signer(&signer_info, prompt_for_password).await?; + + let user_ref = get_user_details( + &public_key, + client, + if let Some(git_repo) = git_repo { + Some(git_repo.get_path()?) + } else { + None + }, + silent, + ) + .await?; + + if !silent { + print_logged_in_as(&user_ref, client.is_none(), &source)?; + } + Ok((signer, user_ref, source)) +} + +/// priority order: cli arguments, local git config, global git config +fn get_signer_info( + git_repo: &Option<&Repo>, + signer_info: &Option, + password: &Option, + source: &Option, +) -> Result<(SignerInfo, SignerInfoSource)> { + Ok(match source { + None => { + let mut result = None; + for source in &[ + SignerInfoSource::CommandLineArguments, + SignerInfoSource::GitLocal, + SignerInfoSource::GitGlobal, + ] { + if let Ok(res) = + get_signer_info(git_repo, signer_info, password, &Some(source.clone())) + { + result = Some(res); + break; + } + } + result.context("cannot get or find signer info in cli arguments, local git config or global git config")? + } + Some(SignerInfoSource::CommandLineArguments) => { + if let Some(signer_info) = signer_info { + (signer_info.clone(), SignerInfoSource::CommandLineArguments) + } else { + bail!("cannot get signer from cli signer arguments because none were specified") + } + } + Some(SignerInfoSource::GitLocal) => { + let git_repo = + git_repo.context("failed to get local git config as no git_repo supplied")?; + if let Ok(nsec) = get_git_config_item(&Some(git_repo), "nostr.nsec") + .context("failed get local git config")? + .context("git local config item nostr.nsec doesn't exist") + { + ( + SignerInfo::Nsec { + nsec: nsec.to_string(), + password: password.clone(), + npub: get_git_config_item(&Some(git_repo), "nostr.npub") + .context("failed get local git config")?, + }, + SignerInfoSource::GitLocal, + ) + } else if let Ok(bunker_uri) = get_git_config_item(&Some(git_repo), "nostr.bunker-uri") + .context("failed get local git config")? + .context("git local config item nostr.bunker-uri doesn't exist") + { + (SignerInfo::Bunker { + bunker_uri, bunker_app_key: get_git_config_item(&Some(git_repo), "nostr.bunker-app-key") + .context("failed get local git config")? + .context("git local config item nostr.bunker-uri exists but nostr.bunker-app-key doesn't")?, + npub: get_git_config_item(&Some(git_repo), "nostr.npub") + .context("failed get local git config")?, + }, SignerInfoSource::GitLocal) + } else { + bail!("no signer info in local git config") + } + } + Some(SignerInfoSource::GitGlobal) => { + if let Some(nsec) = get_git_config_item(&None, "nostr.nsec") + .context("failed to get global git config")? + { + ( + SignerInfo::Nsec { + nsec: nsec.to_string(), + password: password.clone(), + npub: get_git_config_item(&None, "nostr.npub") + .context("failed to get global git config")?, + }, + SignerInfoSource::GitGlobal, + ) + } else if let Some(bunker_uri) = get_git_config_item(&None, "nostr.bunker-uri") + .context("failed to get global git config")? + { + (SignerInfo::Bunker { + bunker_uri, bunker_app_key: get_git_config_item(&None, "nostr.bunker-app-key") + .context("failed get local git config")? + .context("git global config item nostr.bunker-uri exists but nostr.bunker-app-key doesn't")?, + npub: get_git_config_item(&None, "nostr.npub") + .context("failed get global git config")?, + }, SignerInfoSource::GitGlobal) + } else { + bail!("no signer info in global git config") + } + } + }) +} + +async fn get_signer( + signer_info: &SignerInfo, + prompt_for_ncryptsec_password: bool, +) -> Result<(Arc, PublicKey)> { + match signer_info { + SignerInfo::Nsec { + nsec, + password, + npub: _, + } => { + let keys = if nsec.contains("ncryptsec") { + // TODO get user details from npub + // TODO add retry loop + // TODO in retry loop give option to login again + let password = if let Some(password) = password { + password.clone() + } else { + if !prompt_for_ncryptsec_password { + bail!("cannot login without prompts a nsec is encrypted with a password"); + } + Interactor::default() + .password(PromptPasswordParms::default().with_prompt("password")) + .context("failed to get password input from interactor.password")? + }; + decrypt_key(nsec, password.clone().as_str()) + .context("failed to decrypt key with provided password") + .context("failed to decrypt ncryptsec supplied as nsec with password")? + } else { + nostr::Keys::from_str(nsec).context("invalid nsec parameter")? + }; + let public_key = keys.public_key(); + Ok((Arc::new(keys), public_key)) + } + SignerInfo::Bunker { + bunker_uri, + bunker_app_key, + npub, + } => { + let term = console::Term::stderr(); + term.write_line("connecting to remote signer...")?; + let uri = NostrConnectURI::parse(bunker_uri)?; + let signer: Arc = Arc::new(NostrConnect::new( + uri, + nostr::Keys::from_str(bunker_app_key).context("invalid app key")?, + Duration::from_secs(10 * 60), + None, + )?); + term.clear_last_lines(1)?; + let public_key = if let Some(pubic_key) = + npub.clone().and_then(|npub| PublicKey::parse(npub).ok()) + { + pubic_key + } else { + fetch_public_key(&signer).await? + }; + Ok((signer, public_key)) + } + } +} 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 @@ +use std::{str::FromStr, sync::Arc, time::Duration}; + +use anyhow::{bail, Context, Result}; +use console::{Style, Term}; +use dialoguer::theme::{ColorfulTheme, Theme}; +use nostr::nips::{nip05, nip46::NostrConnectURI}; +use nostr_connect::client::NostrConnect; +use nostr_sdk::{Keys, NostrSigner, PublicKey, ToBech32, Url}; +use qrcode::QrCode; +use tokio::sync::{oneshot, Mutex}; + +use super::{ + key_encryption::decrypt_key, + print_logged_in_as, + user::{get_user_details, UserRef}, + SignerInfo, SignerInfoSource, +}; +#[cfg(not(test))] +use crate::client::Client; +#[cfg(test)] +use crate::client::MockConnect; +use crate::{ + cli_interactor::{ + Interactor, InteractorPrompt, Printer, PromptChoiceParms, PromptConfirmParms, + PromptInputParms, PromptPasswordParms, + }, + client::Connect, + git::{remove_git_config_item, save_git_config_item, Repo, RepoActions}, +}; + +pub async fn fresh_login_or_signup( + git_repo: &Option<&Repo>, + #[cfg(test)] client: Option<&MockConnect>, + #[cfg(not(test))] client: Option<&Client>, + save_local: bool, +) -> Result<(Arc, UserRef, SignerInfoSource)> { + let (signer, public_key, signer_info, source) = loop { + match Interactor::default().choice( + PromptChoiceParms::default() + .with_prompt("login to nostr") + .with_default(0) + .with_choices(vec![ + "secret key (nsec / ncryptsec)".to_string(), + "nostr connect (remote signer)".to_string(), + "create account".to_string(), + "help".to_string(), + ]) + .dont_report(), + )? { + 0 => match get_fresh_nsec_signer().await { + Ok(Some(res)) => break res, + Ok(None) => continue, + Err(e) => { + eprintln!("error getting fresh signer from nsec: {e}"); + continue; + } + }, + 1 => match get_fresh_nip46_signer(client).await { + Ok(Some(res)) => break res, + Ok(None) => continue, + Err(e) => { + eprintln!("error getting fresh nip46 signer: {e}"); + continue; + } + }, + 2 => { + eprintln!("TODO create account..."); + continue; + } + _ => { + display_login_help_content(); + continue; + } + } + }; + let _ = save_to_git_config(git_repo, &signer_info, !save_local); + let user_ref = get_user_details( + &public_key, + client, + if let Some(git_repo) = git_repo { + Some(git_repo.get_path()?) + } else { + None + }, + false, + ) + .await?; + print_logged_in_as(&user_ref, client.is_none(), &source)?; + Ok((signer, user_ref, source)) +} + +pub async fn get_fresh_nsec_signer() -> Result< + Option<( + Arc, + PublicKey, + SignerInfo, + SignerInfoSource, + )>, +> { + loop { + let input = Interactor::default() + .input( + PromptInputParms::default() + .with_prompt("nsec") + .optional() + .dont_report(), + ) + .context("failed to get nsec input from interactor")?; + let (keys, signer_info) = if input.contains("ncryptsec") { + let password = Interactor::default() + .password( + PromptPasswordParms::default() + .with_prompt("password") + .dont_report(), + ) + .context("failed to get password input from interactor.password")?; + let keys = if let Ok(keys) = decrypt_key(&input, password.clone().as_str()) + .context("failed to decrypt ncryptsec with provided password") + { + keys + } else { + show_prompt_error( + "invalid ncryptsec and password combination", + &shorten_string(&input), + ); + match Interactor::default().choice( + PromptChoiceParms::default() + .with_default(0) + .with_choices(vec!["try again with nsec".to_string(), "back".to_string()]) + .dont_report(), + )? { + 0 => continue, + _ => break Ok(None), + } + }; + let npub = Some(keys.public_key().to_bech32()?); + let signer_info = if Interactor::default() + .confirm(PromptConfirmParms::default().with_prompt("remember details?"))? + || !Interactor::default().confirm(PromptConfirmParms::default().with_prompt( + "you will be prompted for password to decrypt your ncryptsec at every git push. are you sure?", + ))? { + SignerInfo::Nsec { + nsec: keys.secret_key().to_bech32()?, + password: None, + npub, + } + } else { + show_prompt_success("nsec", &shorten_string(&input)); + SignerInfo::Nsec { + nsec: input, + password: Some(password), + npub, + } + }; + (keys, signer_info) + } else if let Ok(keys) = nostr::Keys::from_str(&input) { + let nsec = keys.secret_key().to_bech32()?; + show_prompt_success("nsec", &shorten_string(&nsec)); + let signer_info = SignerInfo::Nsec { + nsec, + password: None, + npub: Some(keys.public_key().to_bech32()?), + }; + (keys, signer_info) + } else { + show_prompt_error("invalid nsec", &shorten_string(&input)); + match Interactor::default().choice( + PromptChoiceParms::default() + .with_default(0) + .with_choices(vec!["try again with nsec".to_string(), "back".to_string()]) + .dont_report(), + )? { + 0 => continue, + _ => break Ok(None), + } + }; + + let public_key = keys.public_key(); + + break Ok(Some(( + Arc::new(keys), + public_key, + signer_info, + // TODO factor in source + SignerInfoSource::GitGlobal, + ))); + } +} + +fn show_prompt_success(label: &str, value: &str) { + eprintln!("{}", { + let mut s = String::new(); + let _ = ColorfulTheme::default().format_input_prompt_selection(&mut s, label, value); + s + }); +} + +fn show_prompt_error(label: &str, value: &str) { + eprintln!("{}", { + let mut s = String::new(); + let _ = ColorfulTheme::default().format_error( + &mut s, + &format!( + "{label}: {}", + if value.is_empty() { + "empty".to_string() + } else { + shorten_string(&format!("\"{}\"", &value)) + } + ), + ); + s + }); +} + +fn shorten_string(s: &str) -> String { + if s.len() < 15 { + s.to_string() + } else { + format!("{}...", &s[..15]) + } +} + +pub async fn get_fresh_nip46_signer( + #[cfg(test)] client: Option<&MockConnect>, + #[cfg(not(test))] client: Option<&Client>, +) -> Result< + Option<( + Arc, + PublicKey, + SignerInfo, + SignerInfoSource, + )>, +> { + let (app_key, nostr_connect_url) = generate_nostr_connect_app(client)?; + let printer = Arc::new(Mutex::new(Printer::default())); + let signer_choice = Interactor::default().choice( + PromptChoiceParms::default() + .with_prompt("login to nostr with remote signer") + .with_default(0) + .with_choices(vec![ + "show QR code to scan in signer app".to_string(), + "show nostrconnect:// url to paste into signer".to_string(), + "use NIP-05 address to connect to signer".to_string(), + "paste in bunker:// url from signer app".to_string(), + "back".to_string(), + ]) + .dont_report(), + )?; + let url = match signer_choice { + 0 | 1 => nostr_connect_url, + 2 => { + let mut error = None; + loop { + let input = Interactor::default() + .input( + PromptInputParms::default().with_prompt(if let Some(error) = error { + format!("error: {}. try again with NIP-05 address", error) + } else { + "NIP-05 address".to_string() + }), + ) + .context("failed to get NIP-05 address input from interactor")?; + match fetch_nip46_uri_from_nip05(&input).await { + Ok(url) => break url, + Err(e) => error = Some(e), + } + } + } + 3 => { + let mut error = None; + loop { + let input = Interactor::default() + .input( + PromptInputParms::default().with_prompt(if let Some(error) = error { + format!("error: {}. try again with bunker url", error) + } else { + "bunker url".to_string() + }), + ) + .context("failed to get bunker url input from interactor")?; + match NostrConnectURI::parse(&input) { + Ok(url) => break url, + Err(e) => error = Some(e), + } + } + } + _ => return Ok(None), + }; + + { + let printer_clone = Arc::clone(&printer); + let mut printer_locked = printer_clone.lock().await; + match signer_choice { + 0 => { + printer_locked + .println("login to nostr with remote signer via nostr connect".to_string()); + printer_locked.println("scan QR code in signer app (eg Amber):".to_string()); + printer_locked.printlns(generate_qr(&url.to_string())?); + printer_locked + .println("scan QR code in signer app or press any key to abort...".to_string()); + } + 1 => { + printer_locked + .println("login to nostr with remote signer via nostr connect".to_string()); + printer_locked.println("".to_string()); + printer_locked.println_with_custom_formatting( + format!("{}", Style::new().bold().apply_to(url.to_string()),), + url.to_string(), + ); + printer_locked.println("".to_string()); + printer_locked + .println("paste url into signer app or press any key to abort...".to_string()); + } + _ => { + printer_locked.println( + "add / approve in your signer or press any key to abort... ".to_string(), + ); + } + } + } + + let (signer, user_public_key, bunker_url) = + listen_for_remote_signer(&app_key, &url, printer).await?; + let signer_info = SignerInfo::Bunker { + bunker_uri: bunker_url.to_string(), + bunker_app_key: app_key.secret_key().to_secret_hex(), + npub: Some(user_public_key.to_bech32()?), + }; + Ok(Some(( + signer, + user_public_key, + signer_info, + SignerInfoSource::GitGlobal, + ))) +} + +pub fn generate_nostr_connect_app( + #[cfg(test)] client: Option<&MockConnect>, + #[cfg(not(test))] client: Option<&Client>, +) -> Result<(Keys, NostrConnectURI)> { + let app_key = Keys::generate(); + let relays = if let Some(client) = client { + client + .get_fallback_signer_relays() + .iter() + .flat_map(|s| Url::parse(s)) + .collect::>() + } else { + vec![] + }; + let nostr_connect_url = NostrConnectURI::client(app_key.public_key(), relays.clone(), "ngit"); + Ok((app_key, nostr_connect_url)) +} + +pub async fn fetch_nip46_uri_from_nip05(nip05: &str) -> Result { + let term = console::Term::stderr(); + term.write_line("contacting login service provider...")?; + let res = nip05::profile(&nip05, None).await; + term.clear_last_lines(1)?; + match res { + Ok(profile) => { + if profile.nip46.is_empty() { + eprintln!("nip05 provider isn't configured for remote login"); + bail!("nip05 provider isn't configured for remote login") + } + Ok(NostrConnectURI::Bunker { + remote_signer_public_key: profile.public_key, + relays: profile.nip46, + secret: None, + }) + } + Err(error) => { + eprintln!("error contacting login service provider: {error}"); + Err(error).context("error contacting login service provider") + } + } +} + +pub async fn listen_for_remote_signer( + app_key: &Keys, + nostr_connect_url: &NostrConnectURI, + printer: Arc>, +) -> Result<(Arc, PublicKey, NostrConnectURI)> { + let (tx, rx) = oneshot::channel(); + let printer_clone = Arc::clone(&printer); + let app_key = app_key.clone(); + let nostr_connect_url_clone = nostr_connect_url.clone(); + let qr_listener = tokio::spawn(async move { + if let Ok(nostr_connect) = NostrConnect::new( + nostr_connect_url_clone, + app_key, + Duration::from_secs(10 * 60), + None, + ) { + let signer: Arc = Arc::new(nostr_connect); + if let Ok(pub_key) = signer.get_public_key().await { + let mut printer_locked = printer_clone.lock().await; + printer_locked.clear_all(); + + printer_locked.println_with_custom_formatting( + format!( + "{}", + Style::new().bold().apply_to("connected to remote signer"), + ), + "connected to remote signer".to_string(), + ); + printer_locked.println("press any key to continue...".to_string()); + let _ = tx.send(Some((signer, pub_key))); + } else { + let _ = tx.send(None); + } + } + }); + let _ = console::Term::stderr().read_char(); + qr_listener.abort(); + let printer_clone = Arc::clone(&printer); + let mut printer = printer_clone.lock().await; + printer.clear_all(); + + if let Some((signer, public_key)) = rx.await? { + let bunker_url = NostrConnectURI::Bunker { + // TODO the remote signer pubkey may not be the user pubkey + remote_signer_public_key: public_key, + relays: nostr_connect_url.relays(), + secret: nostr_connect_url.secret(), + }; + Ok((signer, public_key, bunker_url)) + } else { + bail!("failed to get signer") + } +} + +fn generate_qr(data: &str) -> Result> { + let mut lines = vec![]; + let qr = + QrCode::new(data.as_bytes()).context("failed to create QR of nostrconnect login url")?; + let colors = qr.to_colors(); + let rows: Vec<&[qrcode::Color]> = colors.chunks(qr.width()).collect(); + for (row, data) in rows.iter().enumerate() { + let odd = row % 2 != 0; + if odd { + continue; + } + let mut line = String::new(); + for (col, color) in data.iter().enumerate() { + let top = color; + let mut bottom = qrcode::Color::Light; + if let Some(next_row_data) = rows.get(row + 1) { + if let Some(color) = next_row_data.get(col) { + bottom = *color; + } + } + line.push(if *top == qrcode::Color::Dark { + if bottom == qrcode::Color::Dark { + '█' + } else { + '▀' + } + } else if bottom == qrcode::Color::Dark { + '▄' + } else { + ' ' + }); + } + lines.push(line); + } + Ok(lines) +} + +fn save_to_git_config( + git_repo: &Option<&Repo>, + signer_info: &SignerInfo, + global: bool, +) -> Result<()> { + if let Err(error) = silently_save_to_git_config(git_repo, signer_info, global).context(format!( + "failed to save login details to {} git config", + if global { "global" } else { "local" } + )) { + eprintln!("Error: {:?}", error); + match signer_info { + SignerInfo::Nsec { + nsec, + password: _, + npub: _, + } => { + if nsec.contains("ncryptsec") { + eprintln!("consider manually setting git config nostr.nsec to: {nsec}"); + } else { + eprintln!("consider manually setting git config nostr.nsec"); + } + } + SignerInfo::Bunker { + bunker_uri, + bunker_app_key, + npub: _, + } => { + eprintln!("consider manually setting git config as follows:"); + eprintln!("nostr.bunker-uri: {bunker_uri}"); + eprintln!("nostr.bunker-app-key: {bunker_app_key}"); + } + } + if global { + save_to_git_config(git_repo, signer_info, false)? + } + Err(error) + } else { + eprintln!( + "{}", + if global { + "saved login details to global git config" + } else { + "saved login details to local git config. you are only logged in to this local repository." + } + ); + Ok(()) + } +} + +fn silently_save_to_git_config( + git_repo: &Option<&Repo>, + signer_info: &SignerInfo, + global: bool, +) -> Result<()> { + if global { + // remove local login otherwise it will override global next time ngit is called + if let Some(git_repo) = git_repo { + git_repo.remove_git_config_item("nostr.npub", false)?; + git_repo.remove_git_config_item("nostr.nsec", false)?; + git_repo.remove_git_config_item("nostr.bunker-uri", false)?; + git_repo.remove_git_config_item("nostr.bunker-app-key", false)?; + } + } + + let git_repo = if global { + &None + } else if git_repo.is_none() { + bail!("cannot update local git config wihout git_repo object") + } else { + git_repo + }; + + let npub_to_save; + match signer_info { + SignerInfo::Nsec { + nsec, + password: _, + npub, + } => { + npub_to_save = npub; + save_git_config_item(git_repo, "nostr.nsec", nsec)?; + remove_git_config_item(git_repo, "nostr.bunker-uri")?; + remove_git_config_item(git_repo, "nostr.bunker-app-key")?; + } + SignerInfo::Bunker { + bunker_uri, + bunker_app_key, + npub, + } => { + npub_to_save = npub; + remove_git_config_item(git_repo, "nostr.nsec")?; + save_git_config_item(git_repo, "nostr.bunker-uri", bunker_uri)?; + save_git_config_item(git_repo, "nostr.bunker-app-key", bunker_app_key)?; + } + } + if let Some(npub) = npub_to_save { + save_git_config_item(git_repo, "nostr.npub", npub)?; + } else { + remove_git_config_item(git_repo, "nostr.npub")?; + } + Ok(()) +} + +fn display_login_help_content() { + let mut printer = Printer::default(); + let title_style = Style::new().bold().fg(console::Color::Yellow); + printer.println("|==============================|".to_owned()); + // printer.println("| |".to_owned()); + printer.println_with_custom_formatting( + format!( + "| {} |", + title_style.apply_to("nostr login / sign up help") + ), + "| nostr login / sign up help |".to_string(), + ); + // printer.println("| |".to_owned()); + printer.println("|==============================|".to_owned()); + printer.printlns(vec![ + "".to_string(), + "login / sign up help content should go here...".to_string(), + "press any key to see the login / signup menu again...".to_string(), + ]); + let _ = Term::stdout().read_char(); + printer.clear_all(); +} 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 @@ use anyhow::Result; -use nostr::{prelude::*, Keys}; - -pub fn encrypt_key(keys: &Keys, password: &str) -> Result { - let log2_rounds: u8 = if password.len() > 20 { - // we have enough of entropy - no need to spend CPU time adding much more - 1 - } else { - println!("this may take a few seconds..."); - // default (scrypt::Params::RECOMMENDED_LOG_N) is 17 but 30s is too long to wait - 15 - }; - Ok(nostr::nips::nip49::EncryptedSecretKey::new( - keys.secret_key(), - password, - log2_rounds, - KeySecurity::Medium, - )? - .to_bech32()?) -} +use nostr::prelude::*; pub fn decrypt_key(encrypted_key: &str, password: &str) -> Result { let encrypted_key = nostr::nips::nip49::EncryptedSecretKey::from_bech32(encrypted_key)?; @@ -34,6 +16,24 @@ mod tests { use super::*; + pub fn encrypt_key(keys: &Keys, password: &str) -> Result { + let log2_rounds: u8 = if password.len() > 20 { + // we have enough of entropy - no need to spend CPU time adding much more + 1 + } else { + println!("this may take a few seconds..."); + // default (scrypt::Params::RECOMMENDED_LOG_N) is 17 but 30s is too long to wait + 15 + }; + Ok(nostr::nips::nip49::EncryptedSecretKey::new( + keys.secret_key(), + password, + log2_rounds, + KeySecurity::Medium, + )? + .to_bech32()?) + } + #[test] fn encrypt_key_produces_string_prefixed_with() -> Result<()> { 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 @@ -use std::{collections::HashSet, path::Path, str::FromStr, sync::Arc, time::Duration}; +use std::{path::Path, sync::Arc}; -use anyhow::{bail, Context, Result}; -use console::Style; -use dialoguer::theme::{ColorfulTheme, Theme}; -use nostr::{ - nips::{nip05, nip46::NostrConnectURI}, - PublicKey, -}; -use nostr_connect::client::NostrConnect; -use nostr_sdk::{ - Alphabet, FromBech32, JsonUtil, Keys, Kind, NostrSigner, SingleLetterTag, Timestamp, ToBech32, - Url, -}; -use qrcode::QrCode; -use tokio::sync::{oneshot, Mutex}; +use anyhow::Result; +use fresh::fresh_login_or_signup; +use nostr::PublicKey; +use nostr_sdk::{NostrSigner, Timestamp, ToBech32}; #[cfg(not(test))] use crate::client::Client; #[cfg(test)] use crate::client::MockConnect; -use crate::{ - cli_interactor::{ - Interactor, InteractorPrompt, Printer, PromptConfirmParms, PromptInputParms, - PromptPasswordParms, - }, - client::{fetch_public_key, get_event_from_global_cache, Connect}, - git::{Repo, RepoActions}, -}; +use crate::git::{Repo, RepoActions}; +pub mod existing; mod key_encryption; -use key_encryption::{decrypt_key, encrypt_key}; -mod user; -use user::{UserMetadata, UserRef, UserRelayRef, UserRelays}; - -/// handles the encrpytion and storage of key material -#[allow(clippy::too_many_arguments)] -pub async fn launch( - git_repo: &Repo, - bunker_uri: &Option, - bunker_app_key: &Option, - nsec: &Option, +use existing::load_existing_login; +pub mod user; +use user::UserRef; +pub mod fresh; + +pub async fn login_or_signup( + git_repo: &Option<&Repo>, + signer_info: &Option, password: &Option, #[cfg(test)] client: Option<&MockConnect>, #[cfg(not(test))] client: Option<&Client>, - change_user: bool, - silent: bool, -) -> Result<(Arc, UserRef)> { - if let Ok((signer, public_key)) = match get_signer_without_prompts( - git_repo, - bunker_uri, - bunker_app_key, - nsec, - password, - change_user, - ) - .await - { - Ok((signer, public_key)) => Ok((signer, public_key)), - Err(error) => { - if error - .to_string() - .eq("git config item nostr.nsec is an ncryptsec") - { - eprintln!( - "login as {}", - if let Ok(public_key) = PublicKey::from_bech32( - get_config_item(git_repo, "nostr.npub").unwrap_or("unknown".to_string()), - ) { - if let Ok(user_ref) = - get_user_details(&public_key, client, git_repo.get_path()?, silent) - .await - { - user_ref.metadata.name - } else { - "unknown ncryptsec".to_string() - } - } else { - "unknown ncryptsec".to_string() - } - ); - loop { - // prompt for password - let password = Interactor::default() - .password(PromptPasswordParms::default().with_prompt("password")) - .context("failed to get password input from interactor.password")?; - if let Ok(keys) = get_keys_with_password(git_repo, &password) { - break Ok((Arc::new(keys) as Arc, None)); - } - eprintln!("incorrect password"); - } - } else { - if nsec.is_some() { - bail!(error); - } - Err(error) - } - } - } { - let user_ref = get_user_details( - // Note: if rust-nostr NostrConnect::new() were updated to accept user public key as - // requested then the added complexity added in this commit can be undone - &(if let Some(public_key) = public_key { - public_key - } else { - signer - .get_public_key() - .await - .context("cannot get public key from signer")? - }), - client, - git_repo.get_path()?, - silent, - ) - .await?; - - if !silent { - print_logged_in_as(&user_ref, client.is_none())?; - } - Ok((signer, user_ref)) +) -> Result<(Arc, UserRef, SignerInfoSource)> { + let res = + load_existing_login(git_repo, signer_info, password, &None, client, false, true).await; + if res.is_ok() { + res } else { - fresh_login(git_repo, client, change_user).await + fresh_login_or_signup(git_repo, client, false).await } } -fn print_logged_in_as(user_ref: &UserRef, offline_mode: bool) -> Result<()> { +#[derive(Clone)] +pub enum SignerInfo { + Nsec { + nsec: String, + password: Option, + npub: Option, + }, + Bunker { + bunker_uri: String, + bunker_app_key: String, + npub: Option, + }, +} + +#[derive(PartialEq, Clone)] +pub enum SignerInfoSource { + GitLocal, + GitGlobal, + CommandLineArguments, +} + +fn print_logged_in_as( + user_ref: &UserRef, + offline_mode: bool, + source: &SignerInfoSource, +) -> Result<()> { if !offline_mode && user_ref.metadata.created_at.eq(&Timestamp::from(0)) { eprintln!("cannot find profile..."); } 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<()> { "cannot find your relay list. consider using another nostr client to create one to enhance your nostr experience." ); } - eprintln!("logged in as {}", user_ref.metadata.name); - Ok(()) -} - -async fn get_signer_without_prompts( - git_repo: &Repo, - bunker_uri: &Option, - bunker_app_key: &Option, - nsec: &Option, - password: &Option, - save_local: bool, -) -> Result<(Arc, Option)> { - if let Some(nsec) = nsec { - Ok(( - Arc::new(get_keys_from_nsec(git_repo, nsec, password, save_local)?), - None, - )) - } else if let Some(password) = password { - Ok((Arc::new(get_keys_with_password(git_repo, password)?), None)) - } else if let Some(bunker_uri) = bunker_uri { - if let Some(bunker_app_key) = bunker_app_key { - let signer = get_nip46_signer_from_uri_and_key(bunker_uri, bunker_app_key) - .await - .context("failed to connect with remote signer")?; - if save_local { - save_to_git_config( - git_repo, - &signer.get_public_key().await?.to_bech32()?, - &None, - &Some((bunker_uri.to_string(),bunker_app_key.to_string())), - false, - ) - .context("failed to save bunker details local git config nostr.bunker-uri and nostr.bunker-app-key")?; - } - Ok((signer, None)) - } else { - bail!( - "bunker-app-key parameter must be provided alongside bunker-uri. if unknown, login interactively." - ) - } - } else if !save_local { - get_signer_with_git_config_nsec_or_bunker_without_prompts(git_repo).await - } else { - bail!("user wants prompts to specify new keys") - } -} - -fn get_keys_from_nsec( - git_repo: &Repo, - nsec: &String, - password: &Option, - save_local: bool, -) -> Result { - #[allow(unused_assignments)] - let mut s = String::new(); - let keys = if nsec.contains("ncryptsec") { - s = nsec.to_string(); - decrypt_key( - nsec, - password - .clone() - .context("password must be supplied when using ncryptsec as nsec parameter")? - .as_str(), - ) - .context("failed to decrypt key with provided password") - .context("failed to decrypt ncryptsec supplied as nsec with password")? - } else { - s = nsec.to_string(); - nostr::Keys::from_str(nsec).context("invalid nsec parameter")? - }; - if save_local { - if let Some(password) = password { - s = encrypt_key(&keys, password)?; - } - save_to_git_config( - git_repo, - &keys.public_key().to_bech32()?, - &Some(s), - &None, - false, - ) - .context("failed to save encrypted nsec in local git config nostr.nsec")?; - } - Ok(keys) -} - -fn save_to_git_config( - git_repo: &Repo, - npub: &str, - nsec: &Option, - bunker: &Option<(String, String)>, - global: bool, -) -> Result<()> { - if let Err(error) = silently_save_to_git_config(git_repo, npub, nsec, bunker, global) { - eprintln!( - "failed to save login details to {} git config", - if global { "global" } else { "local" } - ); - if let Some(nsec) = nsec { - if nsec.contains("ncryptsec") { - eprintln!("manually set git config nostr.nsec to: {nsec}"); - } else { - eprintln!("manually set git config nostr.nsec"); - } - } - if let Some(bunker) = bunker { - eprintln!("manually set git config as follows:"); - eprintln!("nostr.bunker-uri: {}", bunker.0); - eprintln!("nostr.bunker-app-key: {}", bunker.1); - } - Err(error) - } else { - eprintln!( - "saved login details to {} git config", - if global { "global" } else { "local" } - ); - Ok(()) - } -} -fn silently_save_to_git_config( - git_repo: &Repo, - npub: &str, - nsec: &Option, - bunker: &Option<(String, String)>, - global: bool, -) -> Result<()> { - // must do this first otherwise it might remove the global items just added - if global { - git_repo.remove_git_config_item("nostr.npub", false)?; - git_repo.remove_git_config_item("nostr.nsec", false)?; - git_repo.remove_git_config_item("nostr.bunker-uri", false)?; - git_repo.remove_git_config_item("nostr.bunker-app-key", false)?; - } - if let Some(bunker) = bunker { - git_repo.remove_git_config_item("nostr.nsec", global)?; - git_repo.save_git_config_item("nostr.bunker-uri", &bunker.0, global)?; - git_repo.save_git_config_item("nostr.bunker-app-key", &bunker.1, global)?; - } - if let Some(nsec) = nsec { - git_repo.save_git_config_item("nostr.nsec", nsec, global)?; - git_repo.remove_git_config_item("nostr.bunker-uri", global)?; - git_repo.remove_git_config_item("nostr.bunker-app-key", global)?; - } - git_repo.save_git_config_item("nostr.npub", npub, global) -} - -fn get_keys_with_password(git_repo: &Repo, password: &str) -> Result { - decrypt_key( - &git_repo - .get_git_config_item("nostr.nsec", None) - .context("failed get git config")? - .context("git config item nostr.nsec doesn't exist so cannot decrypt it")?, - password, - ) - .context("failed to decrypt stored nsec key with provided password") -} - -async fn get_nip46_signer_from_uri_and_key( - uri: &str, - app_key: &str, -) -> Result> { - let term = console::Term::stderr(); - term.write_line("connecting to remote signer...")?; - let uri = NostrConnectURI::parse(uri)?; - let signer = Arc::new(NostrConnect::new( - uri, - nostr::Keys::from_str(app_key).context("invalid app key")?, - Duration::from_secs(10 * 60), - None, - )?); - term.clear_last_lines(1)?; - Ok(signer) -} - -async fn get_signer_with_git_config_nsec_or_bunker_without_prompts( - git_repo: &Repo, -) -> Result<(Arc, Option)> { - if let Ok(local_nsec) = &git_repo - .get_git_config_item("nostr.nsec", Some(false)) - .context("failed get local git config")? - .context("git local config item nostr.nsec doesn't exist") - { - if local_nsec.contains("ncryptsec") { - bail!("git global config item nostr.nsec is an ncryptsec") - } - Ok(( - Arc::new(nostr::Keys::from_str(local_nsec).context("invalid nsec parameter")?), - None, - )) - } else if let Ok((uri, app_key, npub)) = - get_git_config_bunker_uri_and_app_key(git_repo, Some(false)) - { - Ok(( - get_nip46_signer_from_uri_and_key(&uri, &app_key).await?, - if let Ok(pubic_key) = PublicKey::parse(npub) { - Some(pubic_key) - } else { - None - }, - )) - } else if let Ok(global_nsec) = &git_repo - .get_git_config_item("nostr.nsec", Some(true)) - .context("failed get global git config")? - .context("git global config item nostr.nsec doesn't exist") - { - if global_nsec.contains("ncryptsec") { - bail!("git global config item nostr.nsec is an ncryptsec") - } - Ok(( - Arc::new(nostr::Keys::from_str(global_nsec).context("invalid nsec parameter")?), - None, - )) - } else if let Ok((uri, app_key, npub)) = - get_git_config_bunker_uri_and_app_key(git_repo, Some(true)) - { - Ok(( - get_nip46_signer_from_uri_and_key(&uri, &app_key).await?, - if let Ok(pubic_key) = PublicKey::parse(npub) { - Some(pubic_key) - } else { - None - }, - )) - } else { - bail!("cannot get nsec or bunker from git config") - } -} - -fn get_git_config_bunker_uri_and_app_key( - git_repo: &Repo, - global: Option, -) -> Result<(String, String, String)> { - Ok(( - git_repo - .get_git_config_item("nostr.bunker-uri", global) - .context("failed get local git config")? - .context("git local config item nostr.bunker-uri doesn't exist")? - .to_string(), - git_repo - .get_git_config_item("nostr.bunker-app-key", global) - .context("failed get local git config")? - .context("git local config item nostr.bunker-app-key doesn't exist")? - .to_string(), - git_repo - .get_git_config_item("nostr.npub", global) - .context("failed get local git config")? - .context("git local config item nostr.npub doesn't exist")? - .to_string(), - )) -} - -async fn fresh_login( - git_repo: &Repo, - #[cfg(test)] client: Option<&MockConnect>, - #[cfg(not(test))] client: Option<&Client>, - always_save: bool, -) -> Result<(Arc, UserRef)> { - let app_key = Keys::generate(); - let app_key_secret = app_key.secret_key().to_secret_hex(); - let relays = if let Some(client) = client { - client - .get_fallback_signer_relays() - .iter() - .flat_map(|s| Url::parse(s)) - .collect::>() - } else { - vec![] - }; - let offline = client.is_none(); - let nostr_connect_url = NostrConnectURI::client(app_key.public_key(), relays.clone(), "ngit"); - let qr = generate_qr(&nostr_connect_url.to_string())?; - - let printer = Arc::new(Mutex::new(Printer::default())); - if !offline { - let printer_clone = Arc::clone(&printer); - let mut printer_locked = printer_clone.lock().await; - printer_locked.printlns(qr); - printer_locked.println(format!( - "scan QR or paste into remote signer: {nostr_connect_url}" - )); - printer_locked.println_with_custom_formatting( - { - let mut s = String::new(); - let _ = ColorfulTheme::default().format_confirm_prompt( - &mut s, - "login with nsec / bunker url / nostr address instead", - Some(true), - ); - s - }, - "? login with nsec / bunker url / nostr address instead? (y/n) › yes".to_string(), - ); - } - - let (tx, rx) = oneshot::channel(); - let printer_clone = Arc::clone(&printer); - - let qr_listener = tokio::spawn(async move { - if offline { - return; - } - if let Ok(nostr_connect) = NostrConnect::new( - nostr_connect_url.clone(), - app_key.clone(), - Duration::from_secs(10 * 60), - None, - ) { - let signer: Arc = Arc::new(nostr_connect); - if let Ok(pub_key) = fetch_public_key(&signer).await { - let mut printer_locked = printer_clone.lock().await; - printer_locked.clear_all(); - - printer_locked.println_with_custom_formatting( - format!( - "{}", - Style::new().bold().apply_to("connected to remote signer"), - ), - "connected to remote signer".to_string(), - ); - printer_locked.println("press any key to continue...".to_string()); - let _ = tx.send(Some((signer, pub_key))); - } - } - }); - if !offline { - let _ = console::Term::stderr().read_char(); - } - qr_listener.abort(); - let printer_clone = Arc::clone(&printer); - let mut printer = printer_clone.lock().await; - printer.clear_all(); - - let (signer, public_key) = { - if let Ok(Some((signer, public_key))) = rx.await { - let bunker_url = NostrConnectURI::Bunker { - remote_signer_public_key: public_key, - relays: relays.clone(), - secret: None, - }; - if let Err(error) = save_bunker( - git_repo, - &public_key, - &bunker_url.to_string(), - &app_key_secret, - always_save, - ) { - eprintln!("{error}"); - } - (signer, public_key) - } else { - let mut public_key: Option = None; - // prompt for nsec - let mut prompt = "login with nsec / bunker url / nostr address"; - let signer: Arc = loop { - let input = Interactor::default() - .input(PromptInputParms::default().with_prompt(prompt)) - .context("failed to get nsec input from interactor")?; - if let Ok(keys) = nostr::Keys::from_str(&input) { - if let Err(error) = save_keys(git_repo, &keys, always_save) { - eprintln!("{error}"); - } - break Arc::new(keys); - } - let uri = if let Ok(uri) = NostrConnectURI::parse(&input) { - uri - } else if input.contains('@') { - if let Ok(uri) = fetch_nip46_uri_from_nip05(&input).await { - uri - } else { - prompt = "failed. try again with nostr address / bunker uri / nsec"; - continue; - } - } else { - prompt = "invalid. try again with nostr address / bunker uri / nsec"; - continue; - }; - match get_nip46_signer_from_uri_and_key(&uri.to_string(), &app_key_secret).await { - Ok(signer) => { - let pub_key = fetch_public_key(&signer).await?; - if let Err(error) = save_bunker( - git_repo, - &pub_key, - &uri.to_string(), - &app_key_secret, - always_save, - ) { - eprintln!("{error}"); - } - public_key = Some(pub_key); - break signer; - } - Err(_) => { - prompt = "failed. try again with nostr address / bunker uri / nsec"; - } - } - }; - let public_key = if let Some(public_key) = public_key { - public_key - } else { - signer.get_public_key().await? - }; - (signer, public_key) - } - }; - // lookup profile - let user_ref = get_user_details(&public_key, client, git_repo.get_path()?, false).await?; - print_logged_in_as(&user_ref, client.is_none())?; - Ok((signer, user_ref)) -} - -fn generate_qr(data: &str) -> Result> { - let mut lines = vec![]; - let qr = - QrCode::new(data.as_bytes()).context("failed to create QR of nostrconnect login url")?; - let colors = qr.to_colors(); - let rows: Vec<&[qrcode::Color]> = colors.chunks(qr.width()).collect(); - for (row, data) in rows.iter().enumerate() { - let odd = row % 2 != 0; - if odd { - continue; - } - let mut line = String::new(); - for (col, color) in data.iter().enumerate() { - let top = color; - let mut bottom = qrcode::Color::Light; - if let Some(next_row_data) = rows.get(row + 1) { - if let Some(color) = next_row_data.get(col) { - bottom = *color; - } - } - line.push(if *top == qrcode::Color::Dark { - if bottom == qrcode::Color::Dark { - '█' - } else { - '▀' - } - } else if bottom == qrcode::Color::Dark { - '▄' - } else { - ' ' - }); - } - lines.push(line); - } - Ok(lines) -} - -pub async fn fetch_nip46_uri_from_nip05(nip05: &str) -> Result { - let term = console::Term::stderr(); - term.write_line("contacting login service provider...")?; - let res = nip05::profile(&nip05, None).await; - term.clear_last_lines(1)?; - match res { - Ok(profile) => { - if profile.nip46.is_empty() { - eprintln!("nip05 provider isn't configured for remote login"); - bail!("nip05 provider isn't configured for remote login") - } - Ok(NostrConnectURI::Bunker { - remote_signer_public_key: profile.public_key, - relays: profile.nip46, - secret: None, - }) + eprintln!( + "logged in as {}{}", + user_ref.metadata.name, + match source { + SignerInfoSource::CommandLineArguments => " via cli arguments", + SignerInfoSource::GitLocal => " just to local repository", + SignerInfoSource::GitGlobal => "", } - Err(error) => { - eprintln!("error contacting login service provider: {error}"); - Err(error).context("error contacting login service provider") - } - } -} - -fn save_bunker( - git_repo: &Repo, - public_key: &PublicKey, - uri: &str, - app_key: &str, - always_save: bool, -) -> Result<()> { - if always_save - || Interactor::default() - .confirm(PromptConfirmParms::default().with_prompt("save login details?"))? - { - let global = !Interactor::default().confirm( - PromptConfirmParms::default() - .with_prompt("save login just for this repository?") - .with_default(false), - )?; - let npub = public_key.to_bech32()?; - if let Err(error) = save_to_git_config( - git_repo, - &npub, - &None, - &Some((uri.to_string(), app_key.to_string())), - global, - ) { - if global { - if Interactor::default().confirm( - PromptConfirmParms::default() - .with_prompt("save in repository git config?") - .with_default(true), - )? { - save_to_git_config( - git_repo, - &npub, - &None, - &Some((uri.to_string(), app_key.to_string())), - false, - )?; - } - } else { - Err(error)?; - } - }; - } - Ok(()) -} - -fn save_keys(git_repo: &Repo, keys: &nostr::Keys, always_save: bool) -> Result<()> { - if always_save - || Interactor::default() - .confirm(PromptConfirmParms::default().with_prompt("save login details?"))? - { - let global = !Interactor::default().confirm( - PromptConfirmParms::default() - .with_prompt("just for this repository?") - .with_default(false), - )?; - - let encrypt = Interactor::default().confirm( - PromptConfirmParms::default() - .with_prompt("require password?") - .with_default(false), - )?; - - let npub = keys.public_key().to_bech32()?; - let nsec_string = if encrypt { - let password = Interactor::default() - .password( - PromptPasswordParms::default() - .with_prompt("encrypt with password") - .with_confirm(), - ) - .context("failed to get password input from interactor.password")?; - encrypt_key(keys, &password)? - } else { - keys.secret_key().to_bech32()? - }; - - if let Err(error) = - save_to_git_config(git_repo, &npub, &Some(nsec_string.clone()), &None, global) - { - if global { - if Interactor::default().confirm( - PromptConfirmParms::default() - .with_prompt("save in repository git config?") - .with_default(true), - )? { - save_to_git_config(git_repo, &npub, &Some(nsec_string.clone()), &None, false)?; - } - } else { - eprintln!("{error}"); - Err(error)?; - } - }; - }; + ); Ok(()) } -fn get_config_item(git_repo: &Repo, name: &str) -> Result { - git_repo - .get_git_config_item(name, None) - .context("failed get git config")? - .context(format!("git config item {name} doesn't exist")) -} - -fn extract_user_metadata( - public_key: &nostr::PublicKey, - events: &[nostr::Event], -) -> Result { - let event = events - .iter() - .filter(|e| e.kind.eq(&nostr::Kind::Metadata) && e.pubkey.eq(public_key)) - .max_by_key(|e| e.created_at); - - let metadata: Option = if let Some(event) = event { - Some( - nostr::Metadata::from_json(event.content.clone()) - .context("metadata cannot be found in kind 0 event content")?, - ) - } else { - None - }; - - Ok(UserMetadata { - name: if let Some(metadata) = metadata { - if let Some(n) = metadata.name { - n - } else if let Some(n) = metadata.custom.get("displayName") { - // strip quote marks that custom.get() adds - let binding = n.to_string(); - let mut chars = binding.chars(); - chars.next(); - chars.next_back(); - chars.as_str().to_string() - } else if let Some(n) = metadata.display_name { - n - } else { - public_key.to_bech32()? - } - } else { - public_key.to_bech32()? - }, - created_at: if let Some(event) = event { - event.created_at - } else { - Timestamp::from(0) - }, - }) -} - -fn extract_user_relays(public_key: &nostr::PublicKey, events: &[nostr::Event]) -> UserRelays { - let event = events - .iter() - .filter(|e| e.kind.eq(&nostr::Kind::RelayList) && e.pubkey.eq(public_key)) - .max_by_key(|e| e.created_at); - - UserRelays { - relays: if let Some(event) = event { - event - .tags - .iter() - .filter(|t| { - t.kind() - .eq(&nostr::TagKind::SingleLetter(SingleLetterTag::lowercase( - Alphabet::R, - ))) - }) - .map(|t| UserRelayRef { - url: t.as_slice()[1].clone(), - read: t.as_slice().len() == 2 || t.as_slice()[2].eq("read"), - write: t.as_slice().len() == 2 || t.as_slice()[2].eq("write"), - }) - .collect() - } else { - vec![] - }, - created_at: if let Some(event) = event { - event.created_at - } else { - Timestamp::from(0) - }, - } -} - -async fn get_user_details( - public_key: &PublicKey, - #[cfg(test)] client: Option<&crate::client::MockConnect>, - #[cfg(not(test))] client: Option<&Client>, - git_repo_path: &Path, - cache_only: bool, -) -> Result { - if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await { - Ok(user_ref) - } else { - let empty = UserRef { - public_key: public_key.to_owned(), - metadata: extract_user_metadata(public_key, &[])?, - relays: extract_user_relays(public_key, &[]), - }; - if cache_only { - Ok(empty) - } else if let Some(client) = client { - let term = console::Term::stderr(); - term.write_line("searching for profile...")?; - let (_, progress_reporter) = client - .fetch_all( - git_repo_path, - &HashSet::new(), - &HashSet::from_iter(vec![*public_key]), - ) - .await?; - if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await { - progress_reporter.clear()?; - // if std::env::var("NGITTEST").is_err() {term.clear_last_lines(1)?;} - Ok(user_ref) - } else { - Ok(empty) - } - } else { - Ok(empty) - } - } -} - // None: in the edge case where the user is logged in via cli arguments rather // than from git config this may be wrong. TODO: fix this -pub async fn get_logged_in_user(git_repo_path: &Path) -> Result> { +pub async fn get_likely_logged_in_user(git_repo_path: &Path) -> Result> { let git_repo = Repo::from_path(&git_repo_path.to_path_buf())?; Ok( 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 Result { - let filters = vec![ - nostr::Filter::default() - .author(*public_key) - .kind(Kind::Metadata), - nostr::Filter::default() - .author(*public_key) - .kind(Kind::RelayList), - ]; - - let events = get_event_from_global_cache(git_repo_path, filters.clone()).await?; - - if events.is_empty() { - bail!("no metadata and profile list in cache for selected public key"); - } - Ok(UserRef { - public_key: public_key.to_owned(), - metadata: extract_user_metadata(public_key, &events)?, - relays: extract_user_relays(public_key, &events), - }) -} - pub fn get_curent_user(git_repo: &Repo) -> Result> { Ok( 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 @@ +use std::{collections::HashSet, path::Path}; + +use anyhow::{bail, Context, Result}; use nostr::PublicKey; -use nostr_sdk::Timestamp; +use nostr_sdk::{Alphabet, JsonUtil, Kind, SingleLetterTag, Timestamp, ToBech32}; use serde::{self, Deserialize, Serialize}; +#[cfg(not(test))] +use crate::client::Client; +#[cfg(test)] +use crate::client::MockConnect; +use crate::client::{get_event_from_global_cache, Connect}; + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct UserRef { pub public_key: PublicKey, @@ -37,3 +46,147 @@ pub struct UserRelayRef { pub read: bool, pub write: bool, } + +pub async fn get_user_details( + public_key: &PublicKey, + #[cfg(test)] client: Option<&MockConnect>, + #[cfg(not(test))] client: Option<&Client>, + git_repo_path: Option<&Path>, + cache_only: bool, +) -> Result { + if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await { + Ok(user_ref) + } else { + let empty = UserRef { + public_key: public_key.to_owned(), + metadata: extract_user_metadata(public_key, &[])?, + relays: extract_user_relays(public_key, &[]), + }; + if cache_only { + Ok(empty) + } else if let Some(client) = client { + let term = console::Term::stderr(); + term.write_line("searching for profile...")?; + let (_, progress_reporter) = client + .fetch_all( + git_repo_path, + &HashSet::new(), + &HashSet::from_iter(vec![*public_key]), + ) + .await?; + if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await { + progress_reporter.clear()?; + // if std::env::var("NGITTEST").is_err() {term.clear_last_lines(1)?;} + Ok(user_ref) + } else { + Ok(empty) + } + } else { + Ok(empty) + } + } +} + +pub async fn get_user_ref_from_cache( + git_repo_path: Option<&Path>, + public_key: &PublicKey, +) -> Result { + let filters = vec![ + nostr::Filter::default() + .author(*public_key) + .kind(Kind::Metadata), + nostr::Filter::default() + .author(*public_key) + .kind(Kind::RelayList), + ]; + + let events = get_event_from_global_cache(git_repo_path, filters.clone()).await?; + + if events.is_empty() { + bail!("no metadata and profile list in cache for selected public key"); + } + Ok(UserRef { + public_key: public_key.to_owned(), + metadata: extract_user_metadata(public_key, &events)?, + relays: extract_user_relays(public_key, &events), + }) +} + +pub fn extract_user_metadata( + public_key: &nostr::PublicKey, + events: &[nostr::Event], +) -> Result { + let event = events + .iter() + .filter(|e| e.kind.eq(&nostr::Kind::Metadata) && e.pubkey.eq(public_key)) + .max_by_key(|e| e.created_at); + + let metadata: Option = if let Some(event) = event { + Some( + nostr::Metadata::from_json(event.content.clone()) + .context("metadata cannot be found in kind 0 event content")?, + ) + } else { + None + }; + + Ok(UserMetadata { + name: if let Some(metadata) = metadata { + if let Some(n) = metadata.name { + n + } else if let Some(n) = metadata.custom.get("displayName") { + // strip quote marks that custom.get() adds + let binding = n.to_string(); + let mut chars = binding.chars(); + chars.next(); + chars.next_back(); + chars.as_str().to_string() + } else if let Some(n) = metadata.display_name { + n + } else { + public_key.to_bech32()? + } + } else { + public_key.to_bech32()? + }, + created_at: if let Some(event) = event { + event.created_at + } else { + Timestamp::from(0) + }, + }) +} + +pub fn extract_user_relays(public_key: &nostr::PublicKey, events: &[nostr::Event]) -> UserRelays { + let event = events + .iter() + .filter(|e| e.kind.eq(&nostr::Kind::RelayList) && e.pubkey.eq(public_key)) + .max_by_key(|e| e.created_at); + + UserRelays { + relays: if let Some(event) = event { + event + .tags + .iter() + .filter(|t| { + t.kind() + .eq(&nostr::TagKind::SingleLetter(SingleLetterTag::lowercase( + Alphabet::R, + ))) + }) + .map(|t| UserRelayRef { + url: t.as_slice()[1].clone(), + read: t.as_slice().len() == 2 || t.as_slice()[2].eq("read"), + write: t.as_slice().len() == 2 || t.as_slice()[2].eq("write"), + }) + .collect() + } else { + vec![] + }, + created_at: if let Some(event) = event { + event.created_at + } else { + Timestamp::from(0) + }, + } +} -- cgit v1.2.3