nostr_types/types/
nostr_url.rs

1use super::{EncryptedPrivateKey, Id, NAddr, NEvent, Profile, PublicKey, RelayUrl, UncheckedUrl};
2use crate::Error;
3use lazy_static::lazy_static;
4
5/// A bech32 sequence representing a nostr object (or set of objects)
6// note, internally we store them as the object the sequence represents
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub enum NostrBech32 {
9    /// naddr - a NostrBech32 parameterized replaceable event coordinate
10    NAddr(NAddr),
11    /// nevent - a NostrBech32 representing an event and a set of relay URLs
12    NEvent(NEvent),
13    /// note - a NostrBech32 representing an event
14    Id(Id),
15    /// nprofile - a NostrBech32 representing a public key and a set of relay URLs
16    Profile(Profile),
17    /// npub - a NostrBech32 representing a public key
18    Pubkey(PublicKey),
19    /// nrelay - a NostrBech32 representing a set of relay URLs
20    Relay(UncheckedUrl),
21    /// ncryptsec - a NostrBech32 representing an encrypted private key
22    CryptSec(EncryptedPrivateKey),
23}
24
25impl std::fmt::Display for NostrBech32 {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
27        match self {
28            NostrBech32::NAddr(na) => write!(f, "{}", na.as_bech32_string()),
29            NostrBech32::NEvent(ep) => write!(f, "{}", ep.as_bech32_string()),
30            NostrBech32::Id(i) => write!(f, "{}", i.as_bech32_string()),
31            NostrBech32::Profile(p) => write!(f, "{}", p.as_bech32_string()),
32            NostrBech32::Pubkey(pk) => write!(f, "{}", pk.as_bech32_string()),
33            NostrBech32::Relay(url) => write!(f, "{}", Self::nrelay_as_bech32_string(url)),
34            NostrBech32::CryptSec(epk) => write!(f, "{}", epk.0),
35        }
36    }
37}
38
39impl NostrBech32 {
40    /// Create from a `PublicKey`
41    pub fn new_pubkey(pubkey: PublicKey) -> NostrBech32 {
42        NostrBech32::Pubkey(pubkey)
43    }
44
45    /// Create from a `Profile`
46    pub fn new_profile(profile: Profile) -> NostrBech32 {
47        NostrBech32::Profile(profile)
48    }
49
50    /// Create from an `Id`
51    pub fn new_id(id: Id) -> NostrBech32 {
52        NostrBech32::Id(id)
53    }
54
55    /// Create from an `NEvent`
56    pub fn new_nevent(ne: NEvent) -> NostrBech32 {
57        NostrBech32::NEvent(ne)
58    }
59
60    /// Create from an `NAddr`
61    pub fn new_naddr(na: NAddr) -> NostrBech32 {
62        NostrBech32::NAddr(na)
63    }
64
65    /// Create from an `UncheckedUrl`
66    pub fn new_relay(url: UncheckedUrl) -> NostrBech32 {
67        NostrBech32::Relay(url)
68    }
69
70    /// Create from an `EncryptedPrivateKey`
71    pub fn new_cryptsec(epk: EncryptedPrivateKey) -> NostrBech32 {
72        NostrBech32::CryptSec(epk)
73    }
74
75    /// Try to convert a string into a NostrBech32. Must not have leading or trailing
76    /// junk for this to work.
77    pub fn try_from_string(s: &str) -> Option<NostrBech32> {
78        if s.get(..6) == Some("naddr1") {
79            if let Ok(na) = NAddr::try_from_bech32_string(s) {
80                return Some(NostrBech32::NAddr(na));
81            }
82        } else if s.get(..7) == Some("nevent1") {
83            if let Ok(ep) = NEvent::try_from_bech32_string(s) {
84                return Some(NostrBech32::NEvent(ep));
85            }
86        } else if s.get(..5) == Some("note1") {
87            if let Ok(id) = Id::try_from_bech32_string(s) {
88                return Some(NostrBech32::Id(id));
89            }
90        } else if s.get(..9) == Some("nprofile1") {
91            if let Ok(p) = Profile::try_from_bech32_string(s, true) {
92                return Some(NostrBech32::Profile(p));
93            }
94        } else if s.get(..5) == Some("npub1") {
95            if let Ok(pk) = PublicKey::try_from_bech32_string(s, true) {
96                return Some(NostrBech32::Pubkey(pk));
97            }
98        } else if s.get(..7) == Some("nrelay1") {
99            if let Ok(urls) = Self::nrelay_try_from_bech32_string(s) {
100                return Some(NostrBech32::Relay(urls));
101            }
102        } else if s.get(..10) == Some("ncryptsec1") {
103            return Some(NostrBech32::CryptSec(EncryptedPrivateKey(s.to_owned())));
104        }
105        None
106    }
107
108    /// Find all `NostrBech32`s in a string, returned in the order found
109    pub fn find_all_in_string(s: &str) -> Vec<NostrBech32> {
110        let mut output: Vec<NostrBech32> = Vec::new();
111        let mut cursor = 0;
112        while let Some((relstart, relend)) = find_nostr_bech32_pos(s.get(cursor..).unwrap()) {
113            if let Some(nurl) =
114                NostrBech32::try_from_string(s.get(cursor + relstart..cursor + relend).unwrap())
115            {
116                output.push(nurl);
117            }
118            cursor += relend;
119        }
120        output
121    }
122
123    // Because nrelay uses TLV, we can't just use UncheckedUrl::as_bech32_string()
124    fn nrelay_as_bech32_string(url: &UncheckedUrl) -> String {
125        let mut tlv: Vec<u8> = Vec::new();
126        tlv.push(0); // special for nrelay
127        let len = url.0.len() as u8;
128        tlv.push(len); // length
129        tlv.extend(&url.0.as_bytes()[..len as usize]);
130        bech32::encode::<bech32::Bech32>(*crate::HRP_NRELAY, &tlv).unwrap()
131    }
132
133    // Because nrelay uses TLV, we can't just use UncheckedUrl::try_from_bech32_string
134    fn nrelay_try_from_bech32_string(s: &str) -> Result<UncheckedUrl, Error> {
135        let data = bech32::decode(s)?;
136        if data.0 != *crate::HRP_NRELAY {
137            Err(Error::WrongBech32(
138                crate::HRP_NRELAY.to_lowercase(),
139                data.0.to_lowercase(),
140            ))
141        } else {
142            let mut url: Option<UncheckedUrl> = None;
143            let tlv = data.1;
144            let mut pos = 0;
145            loop {
146                // we need at least 2 more characters for anything meaningful
147                if pos > tlv.len() - 2 {
148                    break;
149                }
150                let ty = tlv[pos];
151                let len = tlv[pos + 1] as usize;
152                pos += 2;
153                if pos + len > tlv.len() {
154                    return Err(Error::InvalidUrlTlv);
155                }
156                let raw = &tlv[pos..pos + len];
157                #[allow(clippy::single_match)]
158                match ty {
159                    0 => {
160                        let relay_str = std::str::from_utf8(raw)?;
161                        let relay = UncheckedUrl::from_str(relay_str);
162                        url = Some(relay);
163                    }
164                    _ => {} // unhandled type for nrelay
165                }
166                pos += len;
167            }
168            if let Some(url) = url {
169                Ok(url)
170            } else {
171                Err(Error::InvalidUrlTlv)
172            }
173        }
174    }
175}
176
177/// A Nostr URL (starting with 'nostr:')
178#[derive(Clone, Debug, PartialEq, Eq)]
179pub struct NostrUrl(pub NostrBech32);
180
181impl std::fmt::Display for NostrUrl {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
183        write!(f, "nostr:")?;
184        self.0.fmt(f)
185    }
186}
187
188impl NostrUrl {
189    /// Create a new NostrUrl from a NostrBech32
190    pub fn new(bech32: NostrBech32) -> NostrUrl {
191        NostrUrl(bech32)
192    }
193
194    /// Try to convert a string into a NostrUrl. Must not have leading or trailing
195    /// junk for this to work.
196    pub fn try_from_string(s: &str) -> Option<NostrUrl> {
197        if s.get(..6) != Some("nostr:") {
198            return None;
199        }
200        NostrBech32::try_from_string(s.get(6..).unwrap()).map(NostrUrl)
201    }
202
203    /// Find all `NostrUrl`s in a string, returned in the order found
204    /// (If not prefixed with 'nostr:' they will not count, see NostrBech32)
205    pub fn find_all_in_string(s: &str) -> Vec<NostrUrl> {
206        let mut output: Vec<NostrUrl> = Vec::new();
207        let mut cursor = 0;
208        while let Some((relstart, relend)) = find_nostr_url_pos(s.get(cursor..).unwrap()) {
209            if let Some(nurl) =
210                NostrUrl::try_from_string(s.get(cursor + relstart..cursor + relend).unwrap())
211            {
212                output.push(nurl);
213            }
214            cursor += relend;
215        }
216        output
217    }
218
219    /// This converts all recognized bech32 sequences into proper nostr URLs by adding
220    /// the "nostr:" prefix where missing.
221    pub fn urlize(s: &str) -> String {
222        let mut output: String = String::with_capacity(s.len());
223        let mut cursor = 0;
224        while let Some((relstart, relend)) = find_nostr_bech32_pos(s.get(cursor..).unwrap()) {
225            // If it already has it, leave it alone
226            if relstart >= 6 && s.get(cursor + relstart - 6..cursor + relstart) == Some("nostr:") {
227                output.push_str(s.get(cursor..cursor + relend).unwrap());
228            } else {
229                output.push_str(s.get(cursor..cursor + relstart).unwrap());
230                output.push_str("nostr:");
231                output.push_str(s.get(cursor + relstart..cursor + relend).unwrap());
232            }
233            cursor += relend;
234        }
235        output.push_str(s.get(cursor..).unwrap());
236        output
237    }
238}
239
240impl From<NAddr> for NostrUrl {
241    fn from(e: NAddr) -> NostrUrl {
242        NostrUrl(NostrBech32::NAddr(e))
243    }
244}
245
246impl From<NEvent> for NostrUrl {
247    fn from(e: NEvent) -> NostrUrl {
248        NostrUrl(NostrBech32::NEvent(e))
249    }
250}
251
252impl From<Id> for NostrUrl {
253    fn from(i: Id) -> NostrUrl {
254        NostrUrl(NostrBech32::Id(i))
255    }
256}
257
258impl From<Profile> for NostrUrl {
259    fn from(p: Profile) -> NostrUrl {
260        NostrUrl(NostrBech32::Profile(p))
261    }
262}
263
264impl From<PublicKey> for NostrUrl {
265    fn from(p: PublicKey) -> NostrUrl {
266        NostrUrl(NostrBech32::Pubkey(p))
267    }
268}
269
270impl From<UncheckedUrl> for NostrUrl {
271    fn from(u: UncheckedUrl) -> NostrUrl {
272        NostrUrl(NostrBech32::Relay(u))
273    }
274}
275
276impl From<RelayUrl> for NostrUrl {
277    fn from(u: RelayUrl) -> NostrUrl {
278        NostrUrl(NostrBech32::Relay(UncheckedUrl(u.into_string())))
279    }
280}
281
282/// Returns start and end position of next valid NostrBech32
283pub fn find_nostr_bech32_pos(s: &str) -> Option<(usize, usize)> {
284    // BECH32 Alphabet:
285    // qpzry9x8gf2tvdw0s3jn54khce6mua7l
286    // acdefghjklmnpqrstuvwxyz023456789
287    use regex::Regex;
288    lazy_static! {
289        static ref BECH32_RE: Regex = Regex::new(
290            r#"(?:^|[^a-zA-Z0-9])((?:nsec|npub|nprofile|note|nevent|nrelay|naddr)1[ac-hj-np-z02-9]{7,})(?:$|[^a-zA-Z0-9])"#
291        ).expect("Could not compile nostr URL regex");
292    }
293    BECH32_RE.captures(s).map(|cap| {
294        let mat = cap.get(1).unwrap();
295        (mat.start(), mat.end())
296    })
297}
298
299/// Returns start and end position of next valid NostrUrl
300pub fn find_nostr_url_pos(s: &str) -> Option<(usize, usize)> {
301    // BECH32 Alphabet:
302    // qpzry9x8gf2tvdw0s3jn54khce6mua7l
303    // acdefghjklmnpqrstuvwxyz023456789
304    use regex::Regex;
305    lazy_static! {
306        static ref NOSTRURL_RE: Regex = Regex::new(
307            r#"(?:^|[^a-zA-Z0-9])(nostr:(?:nsec|npub|nprofile|note|nevent|nrelay|naddr)1[ac-hj-np-z02-9]{7,})(?:$|[^a-zA-Z0-9])"#
308        ).expect("Could not compile nostr URL regex");
309    }
310    NOSTRURL_RE.captures(s).map(|cap| {
311        let mat = cap.get(1).unwrap();
312        (mat.start(), mat.end())
313    })
314}
315
316#[cfg(test)]
317mod test {
318    use super::*;
319
320    #[test]
321    fn test_nostr_bech32_try_from_string() {
322        let a = "npub1sn0wdenkukak0d9dfczzeacvhkrgz92ak56egt7vdgzn8pv2wfqqhrjdv9";
323        let nurl = NostrBech32::try_from_string(a).unwrap();
324        assert!(matches!(nurl, NostrBech32::Pubkey(..)));
325
326        let b = "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p";
327        let nurl = NostrBech32::try_from_string(b).unwrap();
328        assert!(matches!(nurl, NostrBech32::Profile(..)));
329
330        let c = "note1fntxtkcy9pjwucqwa9mddn7v03wwwsu9j330jj350nvhpky2tuaspk6nqc";
331        let nurl = NostrBech32::try_from_string(c).unwrap();
332        assert!(matches!(nurl, NostrBech32::Id(..)));
333
334        let d = "nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ez6un9d3shjtn4de6x2argwghx6egpr4mhxue69uhkummnw3ez6ur4vgh8wetvd3hhyer9wghxuet5nxnepm";
335        let nurl = NostrBech32::try_from_string(d).unwrap();
336        assert!(matches!(nurl, NostrBech32::NEvent(..)));
337
338        let e = "naddr1qqxk67txd9e8xardv96x7mt9qgsgfvxyd2mfntp4avk29pj8pwz7pqwmyzrummmrjv3rdsuhg9mc9agrqsqqqa28rkfdwv";
339        let nurl = NostrBech32::try_from_string(e).unwrap();
340        assert!(matches!(nurl, NostrBech32::NAddr(..)));
341
342        let f = "naddr1qq9xuum9vd382mntv4eqz8nhwden5te0dehhxarj9eek2argvehhyurjd9mxzcme9e3k7mgpzamhxue69uhhyetvv9ujucm4wfex2mn59en8j6gpzfmhxue69uhhqatjwpkx2urpvuhx2ucpr9mhxue69uhkummnw3ezu7n9vfjkget99e3kcmm4vsq32amnwvaz7tm9v3jkutnwdaehgu3wd3skueqpp4mhxue69uhkummn9ekx7mqpr9mhxue69uhhqatjv9mxjerp9ehx7um5wghxcctwvsq3samnwvaz7tmjv4kxz7fwwdhx7un59eek7cmfv9kqz9rhwden5te0wfjkccte9ejxzmt4wvhxjmcpr4mhxue69uhkummnw3ezu6r0wa6x7cnfw33k76tw9eeksmmsqy2hwumn8ghj7mn0wd68ytn2v96x6tnvd9hxkqgkwaehxw309ashgmrpwvhxummnw3ezumrpdejqzynhwden5te0danxvcmgv95kutnsw43qzynhwden5te0wfjkccte9enrw73wd9hsz9rhwden5te0wfjkccte9ehx7um5wghxyecpzemhxue69uhhyetvv9ujumn0wd68ytnfdenx7qg7waehxw309ahx7um5wgkhyetvv9ujumn0ddhhgctjduhxxmmdqy28wumn8ghj7cnvv9ehgu3wvcmh5tnc09aqzymhwden5te0wfjkcctev93xcefwdaexwqgcwaehxw309akxjemgw3hxjmn8wfjkccte9e3k7mgprfmhxue69uhhyetvv9ujumn0wd68y6trdpjhxtn0wfnszyrhwden5te0dehhxarj9emkjmn9qyrkxmmjv93kcegzypl4c26wfzswnlk2vwjxky7dhqjgnaqzqwvdvz3qwz5k3j4grrt46qcyqqq823cd90lu6";
343        let nurl = NostrBech32::try_from_string(f).unwrap();
344        assert!(matches!(nurl, NostrBech32::NAddr(..)));
345
346        let g = "nrelay1qqghwumn8ghj7mn0wd68yv339e3k7mgftj9ag";
347        let nurl = NostrBech32::try_from_string(g).unwrap();
348        assert!(matches!(nurl, NostrBech32::Relay(..)));
349
350        // too short
351        let short = "npub1sn0wdenkukak0d9dfczzeacvhkrgz92ak56egt7vdgzn8pv2wfqqhrjdv";
352        assert!(NostrBech32::try_from_string(short).is_none());
353
354        // bad char
355        let badchar = "note1fntxtkcy9pjwucqwa9mddn7v03wwwsu9j330jj350nvhpky2tuaspk6bqc";
356        assert!(NostrBech32::try_from_string(badchar).is_none());
357
358        // unknown prefix char
359        let unknown = "nurl1sn0wdenkukak0d9dfczzeacvhkrgz92ak56egt7vdgzn8pv2wfqqhrjdv9";
360        assert!(NostrBech32::try_from_string(unknown).is_none());
361    }
362
363    #[test]
364    fn test_nostr_urlize() {
365        let sample = r#"This is now the offical Gossip Client account.  Please follow it.  I will be reposting it's messages for some time until it catches on.
366
367nprofile1qqsrjerj9rhamu30sjnuudk3zxeh3njl852mssqng7z4up9jfj8yupqpzamhxue69uhhyetvv9ujumn0wd68ytnfdenx7tcpz4mhxue69uhkummnw3ezummcw3ezuer9wchszxmhwden5te0dehhxarj9ekkj6m9v35kcem9wghxxmmd9uq3xamnwvaz7tm0venxx6rpd9hzuur4vghsz8nhwden5te0dehhxarj94c82c3wwajkcmr0wfjx2u3wdejhgtcsfx2xk
368
369#[1]
370"#;
371        let fixed = NostrUrl::urlize(sample);
372        println!("{fixed}");
373        assert!(fixed.contains("nostr:nprofile1"));
374
375        let sample2 = r#"Have you been switching nostr clients lately?
376Could be related to:
377nostr:note10ttnuuvcs29y3k23gwrcurw2ksvgd7c2rrqlfx7urmt5m963vhss8nja90
378"#;
379        let nochange = NostrUrl::urlize(sample2);
380        assert_eq!(sample2.len(), nochange.len());
381
382        let sample3 = r#"Have you been switching nostr clients lately?
383Could be related to:
384note10ttnuuvcs29y3k23gwrcurw2ksvgd7c2rrqlfx7urmt5m963vhss8nja90
385"#;
386        let fixed = NostrUrl::urlize(sample3);
387        assert!(fixed.contains("nostr:note1"));
388        assert!(fixed.len() > sample3.len());
389    }
390
391    #[test]
392    fn test_nostr_url_unicode_issues() {
393        let sample = r#"🌝🐸note1fntxtkcy9pjwucqwa9mddn7v03wwwsu9j330jj350nvhpky2tuaspk6nqc"#;
394        assert!(NostrUrl::try_from_string(sample).is_none())
395    }
396
397    #[test]
398    fn test_multiple_nostr_urls() {
399        let sample = r#"
400Here is a list of relays I use and consider reliable so far. I've included some relevant information for each relay such as if payment is required or [NIP-33](https://nips.be/33) is supported. I'll be updating this list as I discover more good relays, which ones do you find reliable?
401
402## Nokotaro
403
404nostr:nrelay1qq0hwumn8ghj7mn0wd68yttjv4kxz7fwdehkkmm5v9ex7tnrdakj78zlgae
405
406- Paid? **No**
407- [NIP-33](https://nips.be/33) supported? **Yes**
408- Operator: nostr:npub12ftld459xqw7s7fqnxstzu7r74l5yagxztwcwmaqj4d24jgpj2csee3mx0
409
410## Nostr World
411
412nostr:nrelay1qqvhwumn8ghj7mn0wd68ytthdaexcepwdqeh5tn2wqhsv5kg7j
413
414- Paid? **Yes**
415- [NIP-33](https://nips.be/33) supported? **Yes**
416- Operator: nostr:npub1zpq2gsz25wsgun2e4gtks9p63j7fvyfd46weyjzp5tv6yys89zcsjdflcv
417
418## Nos.lol
419
420nostr:nrelay1qq88wumn8ghj7mn0wvhxcmmv9uvj5a67
421
422- Paid? **No**
423- [NIP-33](https://nips.be/33) supported? **No**
424- Operator: nostr:npub1nlk894teh248w2heuu0x8z6jjg2hyxkwdc8cxgrjtm9lnamlskcsghjm9c
425
426## Nostr Wine
427
428nostr:nrelay1qqghwumn8ghj7mn0wd68ytnhd9hx2tcw2qslz
429
430- Paid? **Yes**
431- [NIP-33](https://nips.be/33) supported? **No**
432- Operators: nostr:npub1qlkwmzmrhzpuak7c2g9akvcrh7wzkd7zc7fpefw9najwpau662nqealf5y & nostr:npub18kzz4lkdtc5n729kvfunxuz287uvu9f64ywhjz43ra482t2y5sks0mx5sz
433
434## Nostrich Land
435
436nostr:nrelay1qqvhwumn8ghj7un9d3shjtnwdaehgunfvd5zumrpdejqpdl8ln
437
438- Paid? **Yes**
439- [NIP-33](https://nips.be/33) supported? **No**
440- Operator: nostr:nprofile1qqsxf8h0u35dmvg8cp0t5mg9z8f222v9grly6hcqw2cqvdsq3lrjlyspr9mhxue69uhhyetvv9ujumn0wd68y6trdqhxcctwvsj9ulqc
441"#;
442
443        assert_eq!(NostrUrl::find_all_in_string(sample).len(), 11);
444    }
445
446    #[test]
447    fn test_generate_nrelay() {
448        let url = UncheckedUrl("wss://nostr.mikedilger.com/".to_owned());
449        let nb32 = NostrBech32::new_relay(url);
450        let nurl = NostrUrl(nb32);
451        println!("{}", nurl);
452    }
453}