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}