nostr_types/types/
nostr_url.rs1use super::{EncryptedPrivateKey, Id, NAddr, NEvent, Profile, PublicKey, RelayUrl, UncheckedUrl};
2use crate::Error;
3use lazy_static::lazy_static;
4
5#[derive(Clone, Debug, PartialEq, Eq)]
8pub enum NostrBech32 {
9 NAddr(NAddr),
11 NEvent(NEvent),
13 Id(Id),
15 Profile(Profile),
17 Pubkey(PublicKey),
19 Relay(UncheckedUrl),
21 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 pub fn new_pubkey(pubkey: PublicKey) -> NostrBech32 {
42 NostrBech32::Pubkey(pubkey)
43 }
44
45 pub fn new_profile(profile: Profile) -> NostrBech32 {
47 NostrBech32::Profile(profile)
48 }
49
50 pub fn new_id(id: Id) -> NostrBech32 {
52 NostrBech32::Id(id)
53 }
54
55 pub fn new_nevent(ne: NEvent) -> NostrBech32 {
57 NostrBech32::NEvent(ne)
58 }
59
60 pub fn new_naddr(na: NAddr) -> NostrBech32 {
62 NostrBech32::NAddr(na)
63 }
64
65 pub fn new_relay(url: UncheckedUrl) -> NostrBech32 {
67 NostrBech32::Relay(url)
68 }
69
70 pub fn new_cryptsec(epk: EncryptedPrivateKey) -> NostrBech32 {
72 NostrBech32::CryptSec(epk)
73 }
74
75 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 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 fn nrelay_as_bech32_string(url: &UncheckedUrl) -> String {
125 let mut tlv: Vec<u8> = Vec::new();
126 tlv.push(0); let len = url.0.len() as u8;
128 tlv.push(len); tlv.extend(&url.0.as_bytes()[..len as usize]);
130 bech32::encode::<bech32::Bech32>(*crate::HRP_NRELAY, &tlv).unwrap()
131 }
132
133 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 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 _ => {} }
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#[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 pub fn new(bech32: NostrBech32) -> NostrUrl {
191 NostrUrl(bech32)
192 }
193
194 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 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 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 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
282pub fn find_nostr_bech32_pos(s: &str) -> Option<(usize, usize)> {
284 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
299pub fn find_nostr_url_pos(s: &str) -> Option<(usize, usize)> {
301 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 let short = "npub1sn0wdenkukak0d9dfczzeacvhkrgz92ak56egt7vdgzn8pv2wfqqhrjdv";
352 assert!(NostrBech32::try_from_string(short).is_none());
353
354 let badchar = "note1fntxtkcy9pjwucqwa9mddn7v03wwwsu9j330jj350nvhpky2tuaspk6bqc";
356 assert!(NostrBech32::try_from_string(badchar).is_none());
357
358 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}