diff --git a/src/profiles.rs b/src/profiles.rs index ac55397..bf0f43a 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use nostr_sdk::prelude::*; use serde::Serialize; use zeroize::Zeroizing; @@ -99,6 +101,74 @@ pub fn create_profile( }) } +/// Add an existing profile from a hex or bech32 secret key. +pub fn import_profile( + vault: &mut Vault, + label: String, + secret: &str, + key: Option<&VaultKey>, + settings: &Settings, +) -> Result { + if vault.is_encrypted() && key.is_none() { + return Err(AppError::vault_locked()); + } + + let secret_key = SecretKey::from_bech32(secret.trim()) + .or_else(|_| parse_secret_key(secret.trim())) + .map_err(|e| AppError::invalid_secret(format!("Could not import that secret key: {e}")))?; + let keys = Keys::new(secret_key); + let public_key = keys + .public_key() + .to_bech32() + .map_err(|e| AppError::internal(format!("Could not encode the public key: {e}")))?; + if vault + .profiles + .iter() + .any(|profile| profile.public_key == public_key) + { + return Err(AppError::config("This account is already added.")); + } + + let secret_hex = Zeroizing::new(hex::encode(keys.secret_key().to_secret_bytes())); + let stored_secret = match &vault.crypto { + Some(_) => crate::crypto::encrypt_secret(key.expect("guarded above"), &secret_hex)?, + None => secret_hex.to_string(), + }; + let created_at = unix_timestamp()?; + let metadata = fetch_profile_metadata(&public_key, &relays::enabled_urls(settings)); + let is_active = vault.active_profile.is_none(); + if is_active { + vault.active_profile = Some(public_key.clone()); + } + vault.profiles.push(StoredProfile { + label: label.clone(), + public_key: public_key.clone(), + secret_key: stored_secret, + created_at, + picture: metadata.as_ref().and_then(|m| m.picture.clone()), + nip05: metadata.as_ref().and_then(|m| m.nip05.clone()), + }); + + let relay_urls = relays::enabled_urls(settings); + if !relay_urls.is_empty() { + publish_metadata_blocking( + &keys, + &label, + metadata.as_ref().and_then(|m| m.picture.clone()), + metadata.as_ref().and_then(|m| m.nip05.clone()), + relay_urls, + ); + } + Ok(ProfileSummary { + label, + npub: public_key, + created_at, + is_active, + picture: metadata.as_ref().and_then(|m| m.picture.clone()), + nip05: metadata.and_then(|m| m.nip05), + }) +} + /// The per-relay outcome of publishing a profile metadata event. #[derive(Debug, Clone, Serialize)] pub struct MetadataPublishReport { @@ -414,6 +484,42 @@ fn publish_metadata_blocking( .expect("metadata publish thread panicked") } +/// Best-effort lookup of the account's latest kind-0 metadata. Import must +/// still succeed when relays are unavailable, so lookup failures are ignored. +fn fetch_profile_metadata(npub: &str, relay_urls: &[String]) -> Option { + if relay_urls.is_empty() { + return None; + } + let public_key = PublicKey::from_bech32(npub).ok()?; + let relay_urls = relay_urls.to_vec(); + std::thread::spawn(move || { + tokio::runtime::Runtime::new().ok()?.block_on(async move { + let client = + relays::open_pool(Keys::generate(), &relay_urls, Some(Duration::from_secs(10))) + .await + .ok()?; + let events = client + .fetch_events( + Filter::new() + .kind(Kind::Metadata) + .author(public_key) + .limit(1), + ) + .timeout(Duration::from_secs(10)) + .await + .ok()?; + client.disconnect().await; + events + .into_iter() + .max_by_key(|event| event.created_at) + .and_then(|event| serde_json::from_str::(&event.content).ok()) + }) + }) + .join() + .ok() + .flatten() +} + async fn publish_metadata_async( keys: &Keys, label: &str,