Skip to main content

ojak/fetch/
alternate.rs

1//! Finding the ActivityPub representation a web page links to.
2//!
3//! A URL someone pastes into a search box is rarely an object's `id`: it is
4//! the page they were reading. A server that serves its pages and its
5//! objects at different paths names the object from the page with a
6//! `rel="alternate"` link of an ActivityStreams type, in a `Link` header or
7//! in the page's own `<link>` elements, and Mastodon's
8//! `FetchResourceService` looks in that order. [`link_header_alternate`]
9//! reads the header, and [`html_alternate`], with the `html` feature, the
10//! page.
11
12use reqwest::header::HeaderMap;
13
14/// The types an alternate link has to carry to name an ActivityPub object,
15/// in the order they are looked for: Mastodon's
16/// `FetchResourceService::ACTIVITY_STREAM_LINK_TYPES`.
17pub const ACTIVITY_LINK_TYPES: [&str; 2] = [
18    "application/activity+json",
19    "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"",
20];
21
22/// The target of the first `Link` header's `rel="alternate"` entry of an
23/// [`ACTIVITY_LINK_TYPES`] type, as written.
24///
25/// As in Mastodon, only the first `Link` header is read when there are
26/// several, and `application/activity+json` is looked for across all its
27/// entries before the JSON-LD spelling: the order of the types decides, not
28/// the order of the links.
29#[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/// One entry of a `Link` header (RFC 8288).
42#[derive(Clone, Debug, Eq, PartialEq)]
43pub struct WebLink {
44    /// The target, as written between `<` and `>`.
45    pub href: String,
46    /// The parameters, names lowercased and values unquoted, in order.
47    pub params: Vec<(String, String)>,
48}
49
50impl WebLink {
51    /// The first parameter named `name`, which is lowercase.
52    #[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    /// Whether `rel`, a space-separated list of tokens, includes `token`,
61    /// so that `rel="alternate me"` is an alternate link.
62    #[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/// The entries of a `Link` header. A comma inside `<…>` or a quoted
70/// parameter value does not separate entries, and an entry without a
71/// `<target>` is passed over.
72#[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/// The target of the page's first `<link rel="alternate">` of an
121/// [`ACTIVITY_LINK_TYPES`] type, resolved against `base`, the URL the page
122/// was served from. Mastodon only meets absolute targets here, and resolving
123/// a relative one costs nothing.
124#[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    // `rel` is a token list; `~=` is the selector for "contains this token".
129    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    /// `application/activity+json` wins over the JSON-LD spelling wherever
163    /// each appears in the header, because that is the order Mastodon asks
164    /// in.
165    #[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            // A human page whose only pointer to the object is this tag.
206            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}