notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

nip05.rs (3335B)


      1 use std::collections::HashMap;
      2 use std::sync::mpsc::{self, Receiver, Sender};
      3 use std::time::{Duration, Instant};
      4 
      5 use enostr::Pubkey;
      6 
      7 const NIP05_TTL: Duration = Duration::from_secs(8 * 3600); // 8 hours
      8 
      9 #[derive(Debug, Clone, PartialEq)]
     10 pub enum Nip05Status {
     11     Pending,
     12     Valid,
     13     Invalid,
     14 }
     15 
     16 struct CacheEntry {
     17     status: Nip05Status,
     18     checked_at: Instant,
     19 }
     20 
     21 struct Completion {
     22     pubkey: Pubkey,
     23     status: Nip05Status,
     24 }
     25 
     26 pub struct Nip05Cache {
     27     cache: HashMap<Pubkey, CacheEntry>,
     28     tx: Sender<Completion>,
     29     rx: Receiver<Completion>,
     30 }
     31 
     32 impl Default for Nip05Cache {
     33     fn default() -> Self {
     34         Self::new()
     35     }
     36 }
     37 
     38 impl Nip05Cache {
     39     pub fn new() -> Self {
     40         let (tx, rx) = mpsc::channel();
     41         Self {
     42             cache: HashMap::new(),
     43             tx,
     44             rx,
     45         }
     46     }
     47 
     48     pub fn status(&self, pubkey: &Pubkey) -> Option<&Nip05Status> {
     49         self.cache.get(pubkey).map(|entry| &entry.status)
     50     }
     51 
     52     pub fn request_validation(&mut self, pubkey: Pubkey, nip05: &str) {
     53         if let Some(entry) = self.cache.get(&pubkey) {
     54             if entry.checked_at.elapsed() < NIP05_TTL {
     55                 return;
     56             }
     57         }
     58 
     59         self.cache.insert(
     60             pubkey,
     61             CacheEntry {
     62                 status: Nip05Status::Pending,
     63                 checked_at: Instant::now(),
     64             },
     65         );
     66 
     67         let tx = self.tx.clone();
     68         let nip05 = nip05.to_string();
     69 
     70         tokio::spawn(async move {
     71             let status = validate_nip05(&pubkey, &nip05).await;
     72             let _ = tx.send(Completion { pubkey, status });
     73         });
     74     }
     75 
     76     pub fn poll(&mut self) {
     77         while let Ok(completion) = self.rx.try_recv() {
     78             self.cache.insert(
     79                 completion.pubkey,
     80                 CacheEntry {
     81                     status: completion.status,
     82                     checked_at: Instant::now(),
     83                 },
     84             );
     85         }
     86     }
     87 }
     88 
     89 async fn validate_nip05(pubkey: &Pubkey, nip05: &str) -> Nip05Status {
     90     let Some((user, domain)) = parse_nip05(nip05) else {
     91         return Nip05Status::Invalid;
     92     };
     93 
     94     let url = format!("https://{}/.well-known/nostr.json?name={}", domain, user);
     95 
     96     let resp = match crate::media::network::http_req(&url).await {
     97         Ok(resp) => resp,
     98         Err(e) => {
     99             tracing::warn!("NIP-05 validation failed for {}: {}", nip05, e);
    100             return Nip05Status::Invalid;
    101         }
    102     };
    103 
    104     let json: serde_json::Value = match serde_json::from_slice(&resp.bytes) {
    105         Ok(v) => v,
    106         Err(e) => {
    107             tracing::warn!("NIP-05 JSON parse failed for {}: {}", nip05, e);
    108             return Nip05Status::Invalid;
    109         }
    110     };
    111 
    112     let expected_hex = pubkey.hex();
    113 
    114     let valid = json
    115         .get("names")
    116         .and_then(|names| names.get(user))
    117         .and_then(|v| v.as_str())
    118         .map(|hex| hex.eq_ignore_ascii_case(&expected_hex))
    119         .unwrap_or(false);
    120 
    121     if valid {
    122         Nip05Status::Valid
    123     } else {
    124         Nip05Status::Invalid
    125     }
    126 }
    127 
    128 fn parse_nip05(nip05: &str) -> Option<(&str, &str)> {
    129     let at_pos = nip05.find('@')?;
    130     let user = &nip05[..at_pos];
    131     let domain = &nip05[at_pos + 1..];
    132 
    133     if user.is_empty() || domain.is_empty() {
    134         return None;
    135     }
    136 
    137     Some((user, domain))
    138 }