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