nostr_types/types/
url.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
use crate::error::Error;
use serde::{Deserialize, Serialize};
#[cfg(feature = "speedy")]
use speedy::{Readable, Writable};
use std::fmt;

/// A string that is supposed to represent a URL but which might be invalid
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, PartialOrd, Serialize, Ord)]
#[cfg_attr(feature = "speedy", derive(Readable, Writable))]
pub struct UncheckedUrl(pub String);

impl fmt::Display for UncheckedUrl {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl UncheckedUrl {
    /// Create an UncheckedUrl from a &str
    // note - this from_str cannot error, so we don't impl std::str::FromStr which by
    //        all rights should be called TryFromStr anyway
    #[allow(clippy::should_implement_trait)]
    pub fn from_str(s: &str) -> UncheckedUrl {
        UncheckedUrl(s.to_owned())
    }

    /// Create an UncheckedUrl from a String
    pub fn from_string(s: String) -> UncheckedUrl {
        UncheckedUrl(s)
    }

    /// As &str
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// As nrelay
    pub fn as_bech32_string(&self) -> String {
        bech32::encode::<bech32::Bech32>(*crate::HRP_NRELAY, self.0.as_bytes()).unwrap()
    }

    /// Import from a bech32 encoded string ("nrelay")
    pub fn try_from_bech32_string(s: &str) -> Result<UncheckedUrl, Error> {
        let data = bech32::decode(s)?;
        if data.0 != *crate::HRP_NRELAY {
            Err(Error::WrongBech32(
                crate::HRP_NRELAY.to_lowercase(),
                data.0.to_lowercase(),
            ))
        } else {
            let s = std::str::from_utf8(&data.1)?.to_owned();
            Ok(UncheckedUrl(s))
        }
    }

    // Mock data for testing
    #[allow(dead_code)]
    pub(crate) fn mock() -> UncheckedUrl {
        UncheckedUrl("/home/user/file.txt".to_string())
    }
}

/// A String representing a valid URL with an authority present including an
/// Internet based host.
///
/// We don't serialize/deserialize these directly, see `UncheckedUrl` for that
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[cfg_attr(feature = "speedy", derive(Readable, Writable))]
pub struct Url(String);

impl fmt::Display for Url {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl Url {
    /// Create a new Url from an UncheckedUrl
    pub fn try_from_unchecked_url(u: &UncheckedUrl) -> Result<Url, Error> {
        Url::try_from_str(&u.0)
    }

    /// Create a new Url from a string
    pub fn try_from_str(s: &str) -> Result<Url, Error> {
        // We use the url crate to parse and normalize
        let url = url::Url::parse(s.trim())?;

        if !url.has_authority() {
            return Err(Error::InvalidUrlMissingAuthority);
        }

        if let Some(host) = url.host() {
            match host {
                url::Host::Domain(_) => {
                    // Strange that we can't access as a string
                    let s = format!("{host}");
                    if s != s.trim() || s.starts_with("localhost") {
                        return Err(Error::InvalidUrlHost(s));
                    }
                }
                url::Host::Ipv4(addr) => {
                    let addrx = core_net::Ipv4Addr::from(addr.octets());
                    if !addrx.is_global() {
                        return Err(Error::InvalidUrlHost(format!("{host}")));
                    }
                }
                url::Host::Ipv6(addr) => {
                    let addrx = core_net::Ipv6Addr::from(addr.octets());
                    if !addrx.is_global() {
                        return Err(Error::InvalidUrlHost(format!("{host}")));
                    }
                }
            }
        } else {
            return Err(Error::InvalidUrlHost("".to_string()));
        }

        Ok(Url(url.as_str().to_owned()))
    }

    /// Convert into a UncheckedUrl
    pub fn to_unchecked_url(&self) -> UncheckedUrl {
        UncheckedUrl(self.0.clone())
    }

    /// As &str
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Into String
    pub fn into_string(self) -> String {
        self.0
    }

    /// As url crate Url
    pub fn as_url_crate_url(&self) -> url::Url {
        url::Url::parse(&self.0).unwrap()
    }

    // Mock data for testing
    #[allow(dead_code)]
    pub(crate) fn mock() -> Url {
        Url("http://example.com/avatar.png".to_string())
    }
}

/// A Url validated as a nostr relay url in canonical form
/// We don't serialize/deserialize these directly, see `UncheckedUrl` for that
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[cfg_attr(feature = "speedy", derive(Readable, Writable))]
pub struct RelayUrl(String);

impl fmt::Display for RelayUrl {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl RelayUrl {
    /// Create a new RelayUrl from a Url
    pub fn try_from_url(u: &Url) -> Result<RelayUrl, Error> {
        // Verify we aren't looking at a comma-separated-list of URLs
        // (technically they might be valid URLs but just about 100% of the time
        // it's somebody else's bad data)
        if u.0.contains(",wss://") || u.0.contains(",ws://") {
            return Err(Error::Url(format!(
                "URL appears to be a list of multiple URLs: {}",
                u.0
            )));
        }

        let url = url::Url::parse(&u.0)?;

        // Verify the scheme is websockets
        if url.scheme() != "wss" && url.scheme() != "ws" {
            return Err(Error::InvalidUrlScheme(url.scheme().to_owned()));
        }

        // Verify host is some
        if !url.has_host() {
            return Err(Error::Url(format!("URL has no host: {}", u.0)));
        }

        Ok(RelayUrl(url.as_str().to_owned()))
    }

    /// Create a new RelayUrl from an UncheckedUrl
    pub fn try_from_unchecked_url(u: &UncheckedUrl) -> Result<RelayUrl, Error> {
        Self::try_from_str(&u.0)
    }

    /// Construct a new RelayUrl from a Url
    pub fn try_from_str(s: &str) -> Result<RelayUrl, Error> {
        let url = Url::try_from_str(s)?;
        RelayUrl::try_from_url(&url)
    }

    /// Convert into a Url
    // fixme should be 'as_url'
    pub fn to_url(&self) -> Url {
        Url(self.0.clone())
    }

    /// As url crate Url
    pub fn as_url_crate_url(&self) -> url::Url {
        url::Url::parse(&self.0).unwrap()
    }

    /// As nrelay
    pub fn as_bech32_string(&self) -> String {
        bech32::encode::<bech32::Bech32>(*crate::HRP_NRELAY, self.0.as_bytes()).unwrap()
    }

    /// Convert into a UncheckedUrl
    pub fn to_unchecked_url(&self) -> UncheckedUrl {
        UncheckedUrl(self.0.clone())
    }

    /// Host
    pub fn host(&self) -> String {
        self.as_url_crate_url().host_str().unwrap().to_owned()
    }

    /// As &str
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Into String
    pub fn into_string(self) -> String {
        self.0
    }

    // Mock data for testing
    #[allow(dead_code)]
    pub(crate) fn mock() -> Url {
        Url("wss://example.com".to_string())
    }
}

impl TryFrom<Url> for RelayUrl {
    type Error = Error;

    fn try_from(u: Url) -> Result<RelayUrl, Error> {
        RelayUrl::try_from_url(&u)
    }
}

impl TryFrom<&Url> for RelayUrl {
    type Error = Error;

    fn try_from(u: &Url) -> Result<RelayUrl, Error> {
        RelayUrl::try_from_url(u)
    }
}

impl From<RelayUrl> for Url {
    fn from(ru: RelayUrl) -> Url {
        ru.to_url()
    }
}

/// A canonical URL representing just a relay's origin
/// (without path/query/fragment or username/password)
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, PartialOrd, Serialize, Ord)]
#[cfg_attr(feature = "speedy", derive(Readable, Writable))]
pub struct RelayOrigin(String);

impl RelayOrigin {
    /// Convert a RelayUrl into a RelayOrigin
    pub fn from_relay_url(url: RelayUrl) -> RelayOrigin {
        let mut xurl = url::Url::parse(url.as_str()).unwrap();
        xurl.set_fragment(None);
        xurl.set_query(None);
        xurl.set_path("/");
        let _ = xurl.set_username("");
        let _ = xurl.set_password(None);
        RelayOrigin(xurl.into())
    }

    /// Construct a new RelayOrigin from a string
    pub fn try_from_str(s: &str) -> Result<RelayOrigin, Error> {
        let url = RelayUrl::try_from_str(s)?;
        Ok(RelayOrigin::from_relay_url(url))
    }

    /// Create a new Url from an UncheckedUrl
    pub fn try_from_unchecked_url(u: &UncheckedUrl) -> Result<RelayOrigin, Error> {
        let relay_url = RelayUrl::try_from_str(&u.0)?;
        Ok(relay_url.into())
    }

    /// Convert this RelayOrigin into a RelayUrl
    pub fn into_relay_url(self) -> RelayUrl {
        RelayUrl(self.0)
    }

    /// Get a RelayUrl matching this RelayOrigin
    pub fn as_relay_url(&self) -> RelayUrl {
        RelayUrl(self.0.clone())
    }

    /// Convert into a UncheckedUrl
    pub fn to_unchecked_url(&self) -> UncheckedUrl {
        UncheckedUrl(self.0.clone())
    }

    /// As &str
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Into String
    pub fn into_string(self) -> String {
        self.0
    }
}

impl fmt::Display for RelayOrigin {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl From<RelayUrl> for RelayOrigin {
    fn from(ru: RelayUrl) -> RelayOrigin {
        RelayOrigin::from_relay_url(ru)
    }
}

impl From<RelayOrigin> for RelayUrl {
    fn from(ru: RelayOrigin) -> RelayUrl {
        ru.into_relay_url()
    }
}

#[cfg(test)]
mod test {
    use super::*;

    test_serde! {UncheckedUrl, test_unchecked_url_serde}

    #[test]
    fn test_url_case() {
        let url = Url::try_from_str("Wss://MyRelay.example.COM/PATH?Query").unwrap();
        assert_eq!(url.as_str(), "wss://myrelay.example.com/PATH?Query");
    }

    #[test]
    fn test_relay_url_slash() {
        let input = "Wss://MyRelay.example.COM";
        let url = RelayUrl::try_from_str(input).unwrap();
        assert_eq!(url.as_str(), "wss://myrelay.example.com/");
    }

    #[test]
    fn test_relay_origin() {
        let input = "wss://user:pass@filter.nostr.wine:444/npub1234?x=y#z";
        let relay_url = RelayUrl::try_from_str(input).unwrap();
        let origin: RelayOrigin = relay_url.into();
        assert_eq!(origin.as_str(), "wss://filter.nostr.wine:444/");
    }
}