Skip to main content

ojak/fetch/
walk.rs

1//! Walking another server's collection, page by page.
2//!
3//! A collection is served as one document whose items are in it, or as
4//! pages: `first` names the first, and each page's `next` the one after. A
5//! [`Walk`] fetches them as they are needed and hands out the items in
6//! order, so that reading the first few of a collection of thousands fetches
7//! only its first page. It hands them out one at a time ([`Walk::next`]), or
8//! a page at a time ([`Walk::next_page`]) for a caller whose limits are
9//! counted in pages.
10//!
11//! A walk starts from the collection's IRI ([`Fetcher::walk`]), or from the
12//! collection as another object embeds it ([`Fetcher::walk_embedded`]), as a
13//! post embeds its `replies` with their first page.
14//!
15//! Every page is fetched as [`Fetcher::lookup`] fetches a document, and has
16//! to be on the collection's origin: a page elsewhere would be another
17//! server's say about what the collection holds. A walk stops at its limits,
18//! and at a page it has seen before, rather than follow a server round a
19//! loop of its own making.
20
21use super::{FetchError, Fetcher};
22use crate::origin::same_origin;
23use crate::sig::SenderKey;
24use serde_json::Value;
25use std::collections::{HashSet, VecDeque};
26use url::Url;
27
28/// How far a [`Walk`] goes.
29#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30pub struct WalkLimits {
31    /// The most pages fetched, the collection itself included. Pages that
32    /// come embedded in the one before cost no fetch, and do not count.
33    pub pages: usize,
34    /// The most items handed out. [`Walk::next_page`] stops at the end of
35    /// the page that reaches it, rather than in the middle of one.
36    pub items: usize,
37}
38
39impl Default for WalkLimits {
40    /// A hundred pages, and ten thousand items.
41    fn default() -> Self {
42        Self {
43            pages: 100,
44            items: 10_000,
45        }
46    }
47}
48
49/// A walk through a collection's items; see [`Fetcher::walk`].
50///
51/// An item is as the collection gives it: an IRI, or an object embedded in
52/// the page. An embedded object whose `id` is on another origin than the
53/// collection is the collection's server's claim about it, not its owner's,
54/// and is fetched from its `id` before it is trusted.
55pub struct Walk<'a> {
56    fetcher: &'a Fetcher,
57    key: Option<&'a SenderKey>,
58    limits: WalkLimits,
59    /// The collection's `id`, or the object that embeds it, whose origin
60    /// every page has to be on.
61    collection: Option<String>,
62    /// Kept to the embedder's origin, whatever the collection's `id` says.
63    anchored: bool,
64    /// Pages compared with the collection by host rather than by origin.
65    by_host: bool,
66    /// The page to read next, if there is one.
67    next: Option<Next>,
68    /// Items read and not yet handed out by [`Walk::next`].
69    items: VecDeque<Value>,
70    seen: HashSet<String>,
71    pages: usize,
72    /// Items read from pages.
73    read: usize,
74    /// Items handed out one at a time.
75    handed: usize,
76    total: Option<u64>,
77}
78
79enum Next {
80    /// The collection, not yet fetched.
81    Collection(Url),
82    /// A page, by its IRI.
83    Page(String),
84    /// A document in hand: the collection as it was embedded, whose page
85    /// after it is its `first`, or a page embedded in the one before, whose
86    /// page after it is its `next`.
87    InHand(Value, &'static str),
88}
89
90impl Fetcher {
91    /// Walk the collection at `url`, from its first page to its last, within
92    /// `limits`, signing each fetch with `key` when one is given.
93    #[must_use]
94    pub fn walk<'a>(
95        &'a self,
96        url: &Url,
97        key: Option<&'a SenderKey>,
98        limits: WalkLimits,
99    ) -> Walk<'a> {
100        Walk::new(self, key, limits, None, Some(Next::Collection(url.clone())))
101    }
102
103    /// Walk a collection as the object `embedder` embeds it: its IRI, or
104    /// the collection itself, perhaps with its first page in it, as a post
105    /// embeds its `replies`.
106    ///
107    /// What an object embeds is its own say, and the walk keeps to the
108    /// object's server: the collection, and every page of it, is fetched
109    /// only from `embedder`'s origin. The items of a collection embedded in
110    /// hand are the embedder's claim, to be fetched from their own `id`s
111    /// before they are trusted, as any item on another origin is.
112    #[must_use]
113    pub fn walk_embedded<'a>(
114        &'a self,
115        collection: &Value,
116        embedder: &str,
117        key: Option<&'a SenderKey>,
118        limits: WalkLimits,
119    ) -> Walk<'a> {
120        let mut walk = Walk::new(self, key, limits, Some(embedder.to_owned()), None);
121        walk.anchored = true;
122        match collection {
123            Value::String(id) => walk.next = Url::parse(id).ok().map(Next::Collection),
124            Value::Object(object) => {
125                if let Some(id) = object.get("id").and_then(Value::as_str) {
126                    walk.seen.insert(id.to_owned());
127                }
128                walk.total = object.get("totalItems").and_then(Value::as_u64);
129                walk.next = Some(Next::InHand(collection.clone(), "first"));
130            }
131            _ => {}
132        }
133        walk
134    }
135}
136
137impl<'a> Walk<'a> {
138    fn new(
139        fetcher: &'a Fetcher,
140        key: Option<&'a SenderKey>,
141        limits: WalkLimits,
142        collection: Option<String>,
143        next: Option<Next>,
144    ) -> Self {
145        Walk {
146            fetcher,
147            key,
148            limits,
149            collection,
150            anchored: false,
151            by_host: false,
152            next,
153            items: VecDeque::new(),
154            seen: HashSet::new(),
155            pages: 0,
156            read: 0,
157            handed: 0,
158            total: None,
159        }
160    }
161}
162
163impl Walk<'_> {
164    /// Take a page on the collection's host as its own, whatever its scheme
165    /// and port, as Mastodon's `non_matching_uri_hosts?` does, rather than
166    /// only a page on the collection's origin. Either way, only an `http` or
167    /// `https` page is taken.
168    #[must_use]
169    pub fn by_host(mut self) -> Self {
170        self.by_host = true;
171        self
172    }
173
174    /// The next item, or `None` when the collection has no more, or a limit
175    /// has been reached.
176    ///
177    /// # Errors
178    ///
179    /// When a page cannot be fetched, is not a collection or a page of one,
180    /// or is on another origin than the collection: [`FetchError::Invalid`]
181    /// for those, and as [`Fetcher::lookup`] for a fetch.
182    pub async fn next(&mut self) -> Result<Option<Value>, FetchError> {
183        loop {
184            if self.handed >= self.limits.items {
185                return Ok(None);
186            }
187            if let Some(item) = self.items.pop_front() {
188                self.handed += 1;
189                return Ok(Some(item));
190            }
191            match self.read_page().await? {
192                Some(page) => self.items.extend(page),
193                None => return Ok(None),
194            }
195        }
196    }
197
198    /// The items of the next page, or `None` when the collection has no
199    /// more pages, or a limit has been reached. A collection with a `first`
200    /// page is not a page of its own unless it holds items too; one without
201    /// is its only page, even when empty. Items [`Walk::next`] has read and
202    /// not handed out come first, as a page of their own.
203    ///
204    /// # Errors
205    ///
206    /// As [`Walk::next`].
207    pub async fn next_page(&mut self) -> Result<Option<Vec<Value>>, FetchError> {
208        if !self.items.is_empty() {
209            let page: Vec<Value> = self.items.drain(..).collect();
210            self.handed += page.len();
211            return Ok(Some(page));
212        }
213        if self.read >= self.limits.items {
214            return Ok(None);
215        }
216        self.read_page().await
217    }
218
219    /// The pages fetched so far, the collection itself included.
220    #[must_use]
221    pub fn pages_fetched(&self) -> usize {
222        self.pages
223    }
224
225    /// The items the collection says it holds, `totalItems`, once its first
226    /// document has been fetched; it may differ from what the walk finds.
227    #[must_use]
228    pub fn total(&self) -> Option<u64> {
229        self.total
230    }
231
232    /// Every item left, within the walk's limits.
233    ///
234    /// # Errors
235    ///
236    /// As [`Walk::next`].
237    pub async fn collect(mut self) -> Result<Vec<Value>, FetchError> {
238        let mut items = Vec::new();
239        while let Some(item) = self.next().await? {
240            items.push(item);
241        }
242        Ok(items)
243    }
244
245    /// Read the next page, fetching it if it is not in hand, and find the
246    /// one after it.
247    async fn read_page(&mut self) -> Result<Option<Vec<Value>>, FetchError> {
248        loop {
249            let Some(next) = self.next.take() else {
250                return Ok(None);
251            };
252            let (document, link) = match next {
253                Next::InHand(document, link) => (document, link),
254                Next::Collection(url) => {
255                    if self.anchored {
256                        self.check_origin(url.as_str())?;
257                    }
258                    let Some(document) = self.fetch(&url).await? else {
259                        return Ok(None);
260                    };
261                    if !is_collection(&document.json) {
262                        return Err(FetchError::Invalid(format!(
263                            "{} is not a collection",
264                            document.id
265                        )));
266                    }
267                    if self.anchored {
268                        self.check_origin(&document.id)?;
269                    } else {
270                        self.collection = Some(document.id.clone());
271                    }
272                    self.seen.insert(document.id.clone());
273                    self.total = document.json.get("totalItems").and_then(Value::as_u64);
274                    (document.json, "first")
275                }
276                Next::Page(iri) => {
277                    let url = Url::parse(&iri)
278                        .map_err(|error| FetchError::Invalid(format!("page {iri}: {error}")))?;
279                    let Some(document) = self.fetch(&url).await? else {
280                        return Ok(None);
281                    };
282                    if !is_collection(&document.json) {
283                        return Err(FetchError::Invalid(format!(
284                            "{} is not a collection page",
285                            document.id
286                        )));
287                    }
288                    self.check_origin(&document.id)?;
289                    self.seen.insert(document.id.clone());
290                    (document.json, "next")
291                }
292            };
293            let page = items(&document);
294            self.follow(&document, link)?;
295            if link == "first" && page.is_empty() && document.get("first").is_some() {
296                continue;
297            }
298            self.read += page.len();
299            return Ok(Some(page));
300        }
301    }
302
303    /// Fetch a page, or `None` when the walk may fetch no more.
304    async fn fetch(&mut self, url: &Url) -> Result<Option<super::Document>, FetchError> {
305        if self.pages >= self.limits.pages {
306            return Ok(None);
307        }
308        self.pages += 1;
309        Ok(Some(self.fetcher.lookup(url, self.key).await?))
310    }
311
312    /// Find the page after `document` by `link`: `first` from a collection,
313    /// `next` from a page. A page embedded with its items is read from where
314    /// it is; one named by its IRI is fetched when its items are wanted.
315    fn follow(&mut self, document: &Value, link: &'static str) -> Result<(), FetchError> {
316        let Some(after) = document.get(link) else {
317            return Ok(());
318        };
319        let id = match after {
320            Value::String(id) => Some(id.clone()),
321            Value::Object(object) => object.get("id").and_then(Value::as_str).map(str::to_owned),
322            _ => return Ok(()),
323        };
324        if let Some(id) = &id {
325            self.check_origin(id)?;
326            if !self.seen.insert(id.clone()) {
327                return Ok(());
328            }
329        }
330        self.next = match after {
331            Value::Object(object)
332                if object.contains_key("orderedItems") || object.contains_key("items") =>
333            {
334                Some(Next::InHand(after.clone(), "next"))
335            }
336            _ => id.map(Next::Page),
337        };
338        Ok(())
339    }
340
341    fn check_origin(&self, id: &str) -> Result<(), FetchError> {
342        let Some(collection) = &self.collection else {
343            return Ok(());
344        };
345        let same = if self.by_host {
346            same_host(id, collection)
347        } else {
348            same_origin(id, collection)
349        };
350        if same {
351            Ok(())
352        } else {
353            Err(FetchError::Invalid(format!(
354                "page {id} is not on the {} of {collection}",
355                if self.by_host { "host" } else { "origin" }
356            )))
357        }
358    }
359}
360
361/// Whether two `http` or `https` URLs name the same host.
362fn same_host(a: &str, b: &str) -> bool {
363    let host = |url: &str| {
364        Url::parse(url)
365            .ok()
366            .filter(|url| matches!(url.scheme(), "http" | "https"))
367            .and_then(|url| url.host_str().map(str::to_ascii_lowercase))
368    };
369    matches!((host(a), host(b)), (Some(a), Some(b)) if a == b)
370}
371
372fn is_collection(document: &Value) -> bool {
373    crate::portable::has_type(
374        document,
375        &[
376            "Collection",
377            "OrderedCollection",
378            "CollectionPage",
379            "OrderedCollectionPage",
380        ],
381    )
382}
383
384fn items(page: &Value) -> Vec<Value> {
385    match page.get("orderedItems").or_else(|| page.get("items")) {
386        Some(Value::Array(items)) => items.clone(),
387        Some(Value::Null) | None => Vec::new(),
388        Some(item) => vec![item.clone()],
389    }
390}