1use std::fmt;
2
3use http::Uri;
4use http::uri::PathAndQuery;
5use sofie_core::{Error, Result};
6
7const MAXIMUM_LENGTH: usize = 2048;
8
9#[derive(Clone, Debug, PartialEq, Eq, Hash)]
10pub struct Url {
11 text: String,
12}
13
14impl Url {
15 pub fn parse(text: &str) -> Result<Self> {
16 if text.len() > MAXIMUM_LENGTH {
17 return Err(invalid_url("must have at most 2048 characters"));
18 }
19 let (address, fragment) = match text.split_once('#') {
21 Some((address, fragment)) => (address, Some(fragment)),
22 None => (text, None),
23 };
24 if fragment.is_some_and(|fragment| !is_fragment(fragment)) {
25 return Err(invalid_url(
26 "must not have spaces or a second # in its fragment",
27 ));
28 }
29 let uri: Uri = address
30 .parse()
31 .map_err(|_| invalid_url("must be an address such as https://example.com"))?;
32 let scheme = uri
33 .scheme_str()
34 .map(str::to_ascii_lowercase)
35 .filter(|scheme| matches!(scheme.as_str(), "http" | "https"))
36 .ok_or_else(|| invalid_url("must start with http:// or https://"))?;
37 let authority = uri
38 .authority()
39 .filter(|authority| !authority.host().is_empty())
40 .ok_or_else(|| invalid_url("must name a host"))?
41 .as_str();
42 if authority.contains('@') {
43 return Err(invalid_url("must not carry a user name or password"));
44 }
45 let path = uri.path_and_query().map_or("/", PathAndQuery::as_str);
46 let mut text = format!("{scheme}://{}{path}", authority.to_ascii_lowercase());
47 if let Some(fragment) = fragment {
48 text.push('#');
49 text.push_str(fragment);
50 }
51 Ok(Self { text })
52 }
53
54 #[must_use]
55 pub fn as_str(&self) -> &str {
56 &self.text
57 }
58}
59
60fn is_fragment(fragment: &str) -> bool {
61 fragment
62 .bytes()
63 .all(|byte| byte.is_ascii_graphic() && byte != b'#')
64}
65
66fn invalid_url(message: &str) -> Error {
67 Error::invalid("url", message)
68}
69
70impl fmt::Display for Url {
71 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
72 formatter.write_str(&self.text)
73 }
74}
75
76sofie_core::text_value!(Url, text);
77
78crate::present::present_text!(Url, crate::Input::typed(&[("type", "url")]), Exact);
79
80#[cfg(test)]
81mod tests {
82 use sofie_core::SqlValue;
83
84 use super::*;
85 use crate::testing::{assert_refused, round_trip};
86
87 #[test]
88 fn accepts_absolute_web_addresses() {
89 for (text, normalized) in [
90 ("https://example.com", "https://example.com/"),
91 (
92 "HTTPS://Example.COM/Shop?Page=2",
93 "https://example.com/Shop?Page=2",
94 ),
95 ("http://localhost:8080/a/b", "http://localhost:8080/a/b"),
96 (
97 "https://example.com/docs#Install",
98 "https://example.com/docs#Install",
99 ),
100 ("https://[::1]:443/", "https://[::1]:443/"),
101 ] {
102 assert_eq!(Url::parse(text).unwrap().as_str(), normalized, "{text}");
103 }
104 }
105
106 #[test]
107 fn refuses_other_schemes_relative_addresses_and_credentials() {
108 let too_long = format!("https://example.com/{}", "a".repeat(2048));
109 assert_refused(
110 Url::parse,
111 &[
112 "",
113 "example.com",
114 "/relative/path",
115 "ftp://example.com/file",
116 "javascript:alert(1)",
117 "mailto:jane@example.com",
118 "https://jane:secret@example.com/",
119 "https://example.com/a b",
120 "https://example.com/#a#b",
121 "https://example.com/#a b",
122 "https:///path",
123 "https://exämple.com/",
124 &too_long,
125 ],
126 );
127 }
128
129 #[test]
130 fn round_trips_through_a_text_column_and_json() {
131 let url = Url::parse("https://example.com/shop?page=2#top").unwrap();
132 assert_eq!(
133 round_trip(&url),
134 vec![SqlValue::Text("https://example.com/shop?page=2#top".into())]
135 );
136 let json = serde_json::to_value(&url).unwrap();
137 assert_eq!(serde_json::from_value::<Url>(json).unwrap(), url);
138 assert_eq!(url.to_string(), url.as_str());
139 }
140}