1use reqwest::header::HeaderMap;
13
14pub const ACTIVITY_LINK_TYPES: [&str; 2] = [
18 "application/activity+json",
19 "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"",
20];
21
22#[must_use]
30pub fn link_header_alternate(headers: &HeaderMap) -> Option<String> {
31 let raw = headers.get("link")?.to_str().ok()?;
32 let links = parse_link_header(raw);
33 ACTIVITY_LINK_TYPES.iter().find_map(|wanted| {
34 links
35 .iter()
36 .find(|link| link.rel_includes("alternate") && link.param("type") == Some(*wanted))
37 .map(|link| link.href.clone())
38 })
39}
40
41#[derive(Clone, Debug, Eq, PartialEq)]
43pub struct WebLink {
44 pub href: String,
46 pub params: Vec<(String, String)>,
48}
49
50impl WebLink {
51 #[must_use]
53 pub fn param(&self, name: &str) -> Option<&str> {
54 self.params
55 .iter()
56 .find(|(key, _)| key == name)
57 .map(|(_, value)| value.as_str())
58 }
59
60 #[must_use]
63 pub fn rel_includes(&self, token: &str) -> bool {
64 self.param("rel")
65 .is_some_and(|rel| rel.split_whitespace().any(|t| t == token))
66 }
67}
68
69#[must_use]
73pub fn parse_link_header(raw: &str) -> Vec<WebLink> {
74 let mut links = Vec::new();
75 for entry in split_outside_quotes(raw, ',') {
76 let entry = entry.trim();
77 let Some(rest) = entry.strip_prefix('<') else {
78 continue;
79 };
80 let Some((href, params)) = rest.split_once('>') else {
81 continue;
82 };
83 let params = split_outside_quotes(params, ';')
84 .into_iter()
85 .filter_map(|param| {
86 let (key, value) = param.trim().split_once('=')?;
87 Some((
88 key.trim().to_ascii_lowercase(),
89 value.trim().trim_matches('"').to_owned(),
90 ))
91 })
92 .collect();
93 links.push(WebLink {
94 href: href.trim().to_owned(),
95 params,
96 });
97 }
98 links
99}
100
101fn split_outside_quotes(raw: &str, separator: char) -> Vec<&str> {
102 let mut parts = Vec::new();
103 let (mut start, mut in_quotes, mut in_angles) = (0, false, false);
104 for (i, c) in raw.char_indices() {
105 match c {
106 '"' => in_quotes = !in_quotes,
107 '<' if !in_quotes => in_angles = true,
108 '>' if !in_quotes => in_angles = false,
109 c if c == separator && !in_quotes && !in_angles => {
110 parts.push(&raw[start..i]);
111 start = i + c.len_utf8();
112 }
113 _ => {}
114 }
115 }
116 parts.push(&raw[start..]);
117 parts
118}
119
120#[cfg(feature = "html")]
125#[must_use]
126pub fn html_alternate(html: &str, base: &url::Url) -> Option<String> {
127 let document = scraper::Html::parse_document(html);
128 let selector = scraper::Selector::parse(r#"link[rel~="alternate"]"#).ok()?;
130 let href = document
131 .select(&selector)
132 .find(|link| {
133 link.value()
134 .attr("type")
135 .is_some_and(|kind| ACTIVITY_LINK_TYPES.contains(&kind))
136 })
137 .and_then(|link| link.value().attr("href"))?;
138 base.join(href).ok().map(String::from)
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 fn link(value: &str) -> HeaderMap {
146 let mut headers = HeaderMap::new();
147 headers.insert("link", value.parse().unwrap());
148 headers
149 }
150
151 #[test]
152 fn finds_the_alternate_link_in_a_link_header() {
153 let headers = link(
154 r#"<https://a.test/style.css>; rel="preload", <https://a.test/ap/1>; rel="alternate"; type="application/activity+json""#,
155 );
156 assert_eq!(
157 link_header_alternate(&headers).as_deref(),
158 Some("https://a.test/ap/1")
159 );
160 }
161
162 #[test]
166 fn link_header_prefers_activity_json_over_ld_json() {
167 let headers = link(concat!(
168 r#"<https://a.test/ld>; rel="alternate"; type="application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"", "#,
169 r#"<https://a.test/ap>; rel="alternate"; type="application/activity+json""#
170 ));
171 assert_eq!(
172 link_header_alternate(&headers).as_deref(),
173 Some("https://a.test/ap")
174 );
175 }
176
177 #[test]
178 fn link_header_alternate_needs_an_activitystreams_type() {
179 let headers =
180 link(r#"<https://a.test/feed.xml>; rel="alternate"; type="application/rss+xml""#);
181 assert_eq!(link_header_alternate(&headers), None);
182 }
183
184 #[test]
185 fn link_header_commas_inside_quotes_do_not_split_entries() {
186 let links = parse_link_header(r#"<https://a.test/1>; rel="alternate"; title="one, two""#);
187 assert_eq!(links.len(), 1);
188 assert_eq!(links[0].href, "https://a.test/1");
189 assert_eq!(links[0].param("title"), Some("one, two"));
190 }
191
192 #[test]
193 fn rel_is_a_token_list_in_a_link_header() {
194 let links = parse_link_header(r#"<https://a.test/1>; REL="me alternate""#);
195 assert!(links[0].rel_includes("alternate"));
196 assert!(!links[0].rel_includes("alt"));
197 }
198
199 #[cfg(feature = "html")]
200 mod html {
201 use super::super::html_alternate;
202
203 #[test]
204 fn finds_the_alternate_link_in_a_page() {
205 let html = r#"
207 <html><head>
208 <link rel="stylesheet" href="/static/style.css" type="text/css" />
209 <link rel="alternate" type="application/rss+xml" href="/feed.xml" />
210 <link rel="alternate"
211 type="application/activity+json"
212 href="https://oeee.cafe/ap/posts/75fbf20d" />
213 </head><body>drawing</body></html>"#;
214 let base = url::Url::parse("https://oeee.cafe/@pokemon/75fbf20d").unwrap();
215 assert_eq!(
216 html_alternate(html, &base).as_deref(),
217 Some("https://oeee.cafe/ap/posts/75fbf20d")
218 );
219 }
220
221 #[test]
222 fn alternate_link_href_may_be_relative() {
223 let html =
224 r#"<link rel="alternate" type="application/activity+json" href="/ap/posts/1">"#;
225 let base = url::Url::parse("https://oeee.cafe/@pokemon/1").unwrap();
226 assert_eq!(
227 html_alternate(html, &base).as_deref(),
228 Some("https://oeee.cafe/ap/posts/1")
229 );
230 }
231
232 #[test]
233 fn rel_is_a_token_list() {
234 let html = r#"<link rel="me alternate" type="application/activity+json" href="https://a.test/1">"#;
235 let base = url::Url::parse("https://a.test/page").unwrap();
236 assert_eq!(
237 html_alternate(html, &base).as_deref(),
238 Some("https://a.test/1")
239 );
240 }
241
242 #[test]
243 fn a_page_naming_no_object_resolves_to_nothing() {
244 let html = r#"<html><head><title>a page</title></head></html>"#;
245 let base = url::Url::parse("https://a.test/page").unwrap();
246 assert_eq!(html_alternate(html, &base), None);
247 }
248 }
249}