nostr_types/types/
url.rs

1use crate::error::Error;
2use serde::{Deserialize, Serialize};
3#[cfg(feature = "speedy")]
4use speedy::{Readable, Writable};
5use std::fmt;
6
7/// A string that is supposed to represent a URL but which might be invalid
8#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, PartialOrd, Serialize, Ord)]
9#[cfg_attr(feature = "speedy", derive(Readable, Writable))]
10pub struct UncheckedUrl(pub String);
11
12impl fmt::Display for UncheckedUrl {
13    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
14        write!(f, "{}", self.0)
15    }
16}
17
18impl UncheckedUrl {
19    /// Create an UncheckedUrl from a &str
20    // note - this from_str cannot error, so we don't impl std::str::FromStr which by
21    //        all rights should be called TryFromStr anyway
22    #[allow(clippy::should_implement_trait)]
23    pub fn from_str(s: &str) -> UncheckedUrl {
24        UncheckedUrl(s.to_owned())
25    }
26
27    /// Create an UncheckedUrl from a String
28    pub fn from_string(s: String) -> UncheckedUrl {
29        UncheckedUrl(s)
30    }
31
32    /// As &str
33    pub fn as_str(&self) -> &str {
34        &self.0
35    }
36
37    /// As nrelay
38    pub fn as_bech32_string(&self) -> String {
39        bech32::encode::<bech32::Bech32>(*crate::HRP_NRELAY, self.0.as_bytes()).unwrap()
40    }
41
42    /// Import from a bech32 encoded string ("nrelay")
43    pub fn try_from_bech32_string(s: &str) -> Result<UncheckedUrl, Error> {
44        let data = bech32::decode(s)?;
45        if data.0 != *crate::HRP_NRELAY {
46            Err(Error::WrongBech32(
47                crate::HRP_NRELAY.to_lowercase(),
48                data.0.to_lowercase(),
49            ))
50        } else {
51            let s = std::str::from_utf8(&data.1)?.to_owned();
52            Ok(UncheckedUrl(s))
53        }
54    }
55
56    // Mock data for testing
57    #[allow(dead_code)]
58    pub(crate) fn mock() -> UncheckedUrl {
59        UncheckedUrl("/home/user/file.txt".to_string())
60    }
61}
62
63/// A String representing a valid URL with an authority present including an
64/// Internet based host.
65///
66/// We don't serialize/deserialize these directly, see `UncheckedUrl` for that
67#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
68#[cfg_attr(feature = "speedy", derive(Readable, Writable))]
69pub struct Url(String);
70
71impl fmt::Display for Url {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        write!(f, "{}", self.0)
74    }
75}
76
77impl Url {
78    /// Create a new Url from an UncheckedUrl
79    pub fn try_from_unchecked_url(u: &UncheckedUrl) -> Result<Url, Error> {
80        Url::try_from_str(&u.0)
81    }
82
83    /// Create a new Url from a string
84    pub fn try_from_str(s: &str) -> Result<Url, Error> {
85        // We use the url crate to parse and normalize
86        let url = url::Url::parse(s.trim())?;
87
88        if !url.has_authority() {
89            return Err(Error::InvalidUrlMissingAuthority);
90        }
91
92        if let Some(host) = url.host() {
93            match host {
94                url::Host::Domain(_) => {
95                    // Strange that we can't access as a string
96                    let s = format!("{host}");
97                    if s != s.trim() || s.starts_with("localhost") {
98                        return Err(Error::InvalidUrlHost(s));
99                    }
100                }
101                url::Host::Ipv4(addr) => {
102                    let addrx = core_net::Ipv4Addr::from(addr.octets());
103                    if !addrx.is_global() {
104                        return Err(Error::InvalidUrlHost(format!("{host}")));
105                    }
106                }
107                url::Host::Ipv6(addr) => {
108                    let addrx = core_net::Ipv6Addr::from(addr.octets());
109                    if !addrx.is_global() {
110                        return Err(Error::InvalidUrlHost(format!("{host}")));
111                    }
112                }
113            }
114        } else {
115            return Err(Error::InvalidUrlHost("".to_string()));
116        }
117
118        Ok(Url(url.as_str().to_owned()))
119    }
120
121    /// Convert into a UncheckedUrl
122    pub fn to_unchecked_url(&self) -> UncheckedUrl {
123        UncheckedUrl(self.0.clone())
124    }
125
126    /// As &str
127    pub fn as_str(&self) -> &str {
128        &self.0
129    }
130
131    /// Into String
132    pub fn into_string(self) -> String {
133        self.0
134    }
135
136    /// As url crate Url
137    pub fn as_url_crate_url(&self) -> url::Url {
138        url::Url::parse(&self.0).unwrap()
139    }
140
141    // Mock data for testing
142    #[allow(dead_code)]
143    pub(crate) fn mock() -> Url {
144        Url("http://example.com/avatar.png".to_string())
145    }
146}
147
148/// A Url validated as a nostr relay url in canonical form
149/// We don't serialize/deserialize these directly, see `UncheckedUrl` for that
150#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
151#[cfg_attr(feature = "speedy", derive(Readable, Writable))]
152pub struct RelayUrl(String);
153
154impl fmt::Display for RelayUrl {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        write!(f, "{}", self.0)
157    }
158}
159
160impl RelayUrl {
161    /// Create a new RelayUrl from a Url
162    pub fn try_from_url(u: &Url) -> Result<RelayUrl, Error> {
163        // Verify we aren't looking at a comma-separated-list of URLs
164        // (technically they might be valid URLs but just about 100% of the time
165        // it's somebody else's bad data)
166        if u.0.contains(",wss://") || u.0.contains(",ws://") {
167            return Err(Error::Url(format!(
168                "URL appears to be a list of multiple URLs: {}",
169                u.0
170            )));
171        }
172
173        let url = url::Url::parse(&u.0)?;
174
175        // Verify the scheme is websockets
176        if url.scheme() != "wss" && url.scheme() != "ws" {
177            return Err(Error::InvalidUrlScheme(url.scheme().to_owned()));
178        }
179
180        // Verify host is some
181        if !url.has_host() {
182            return Err(Error::Url(format!("URL has no host: {}", u.0)));
183        }
184
185        Ok(RelayUrl(url.as_str().to_owned()))
186    }
187
188    /// Create a new RelayUrl from an UncheckedUrl
189    pub fn try_from_unchecked_url(u: &UncheckedUrl) -> Result<RelayUrl, Error> {
190        Self::try_from_str(&u.0)
191    }
192
193    /// Construct a new RelayUrl from a Url
194    pub fn try_from_str(s: &str) -> Result<RelayUrl, Error> {
195        let url = Url::try_from_str(s)?;
196        RelayUrl::try_from_url(&url)
197    }
198
199    /// Convert into a Url
200    // fixme should be 'as_url'
201    pub fn to_url(&self) -> Url {
202        Url(self.0.clone())
203    }
204
205    /// As url crate Url
206    pub fn as_url_crate_url(&self) -> url::Url {
207        url::Url::parse(&self.0).unwrap()
208    }
209
210    /// As nrelay
211    pub fn as_bech32_string(&self) -> String {
212        bech32::encode::<bech32::Bech32>(*crate::HRP_NRELAY, self.0.as_bytes()).unwrap()
213    }
214
215    /// Convert into a UncheckedUrl
216    pub fn to_unchecked_url(&self) -> UncheckedUrl {
217        UncheckedUrl(self.0.clone())
218    }
219
220    /// Host
221    pub fn host(&self) -> String {
222        self.as_url_crate_url().host_str().unwrap().to_owned()
223    }
224
225    /// As &str
226    pub fn as_str(&self) -> &str {
227        &self.0
228    }
229
230    /// Into String
231    pub fn into_string(self) -> String {
232        self.0
233    }
234
235    // Mock data for testing
236    #[allow(dead_code)]
237    pub(crate) fn mock() -> Url {
238        Url("wss://example.com".to_string())
239    }
240}
241
242impl TryFrom<Url> for RelayUrl {
243    type Error = Error;
244
245    fn try_from(u: Url) -> Result<RelayUrl, Error> {
246        RelayUrl::try_from_url(&u)
247    }
248}
249
250impl TryFrom<&Url> for RelayUrl {
251    type Error = Error;
252
253    fn try_from(u: &Url) -> Result<RelayUrl, Error> {
254        RelayUrl::try_from_url(u)
255    }
256}
257
258impl From<RelayUrl> for Url {
259    fn from(ru: RelayUrl) -> Url {
260        ru.to_url()
261    }
262}
263
264/// A canonical URL representing just a relay's origin
265/// (without path/query/fragment or username/password)
266#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, PartialOrd, Serialize, Ord)]
267#[cfg_attr(feature = "speedy", derive(Readable, Writable))]
268pub struct RelayOrigin(String);
269
270impl RelayOrigin {
271    /// Convert a RelayUrl into a RelayOrigin
272    pub fn from_relay_url(url: RelayUrl) -> RelayOrigin {
273        let mut xurl = url::Url::parse(url.as_str()).unwrap();
274        xurl.set_fragment(None);
275        xurl.set_query(None);
276        xurl.set_path("/");
277        let _ = xurl.set_username("");
278        let _ = xurl.set_password(None);
279        RelayOrigin(xurl.into())
280    }
281
282    /// Construct a new RelayOrigin from a string
283    pub fn try_from_str(s: &str) -> Result<RelayOrigin, Error> {
284        let url = RelayUrl::try_from_str(s)?;
285        Ok(RelayOrigin::from_relay_url(url))
286    }
287
288    /// Create a new Url from an UncheckedUrl
289    pub fn try_from_unchecked_url(u: &UncheckedUrl) -> Result<RelayOrigin, Error> {
290        let relay_url = RelayUrl::try_from_str(&u.0)?;
291        Ok(relay_url.into())
292    }
293
294    /// Convert this RelayOrigin into a RelayUrl
295    pub fn into_relay_url(self) -> RelayUrl {
296        RelayUrl(self.0)
297    }
298
299    /// Get a RelayUrl matching this RelayOrigin
300    pub fn as_relay_url(&self) -> RelayUrl {
301        RelayUrl(self.0.clone())
302    }
303
304    /// Convert into a UncheckedUrl
305    pub fn to_unchecked_url(&self) -> UncheckedUrl {
306        UncheckedUrl(self.0.clone())
307    }
308
309    /// As &str
310    pub fn as_str(&self) -> &str {
311        &self.0
312    }
313
314    /// Into String
315    pub fn into_string(self) -> String {
316        self.0
317    }
318}
319
320impl fmt::Display for RelayOrigin {
321    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
322        write!(f, "{}", self.0)
323    }
324}
325
326impl From<RelayUrl> for RelayOrigin {
327    fn from(ru: RelayUrl) -> RelayOrigin {
328        RelayOrigin::from_relay_url(ru)
329    }
330}
331
332impl From<RelayOrigin> for RelayUrl {
333    fn from(ru: RelayOrigin) -> RelayUrl {
334        ru.into_relay_url()
335    }
336}
337
338#[cfg(test)]
339mod test {
340    use super::*;
341
342    test_serde! {UncheckedUrl, test_unchecked_url_serde}
343
344    #[test]
345    fn test_url_case() {
346        let url = Url::try_from_str("Wss://MyRelay.example.COM/PATH?Query").unwrap();
347        assert_eq!(url.as_str(), "wss://myrelay.example.com/PATH?Query");
348    }
349
350    #[test]
351    fn test_relay_url_slash() {
352        let input = "Wss://MyRelay.example.COM";
353        let url = RelayUrl::try_from_str(input).unwrap();
354        assert_eq!(url.as_str(), "wss://myrelay.example.com/");
355    }
356
357    #[test]
358    fn test_relay_origin() {
359        let input = "wss://user:pass@filter.nostr.wine:444/npub1234?x=y#z";
360        let relay_url = RelayUrl::try_from_str(input).unwrap();
361        let origin: RelayOrigin = relay_url.into();
362        assert_eq!(origin.as_str(), "wss://filter.nostr.wine:444/");
363    }
364}