Skip to main content

ojak/fetch/
mod.rs

1//! Fetching documents from other servers.
2//!
3//! A fetch is a GET through the guarded [`Client`], signed when the caller
4//! gives a key so that a server requiring authorized fetch answers it. The
5//! fetcher follows redirects itself: a signature covers the URL it was made
6//! for, so each hop is checked and signed again for where it goes, and a
7//! redirect from `https` to `http` is refused. A signed GET that is refused
8//! is tried once in the other scheme, as a delivery is, and the fetcher
9//! remembers which scheme each host accepted.
10//!
11//! [`Fetcher::document`] returns a document only when it may be trusted as
12//! what it says it is: it came as ActivityPub, it is a JSON object with an
13//! `id`, and that `id` has the origin of the URL it was finally served from.
14//! Anyone can serve a document naming any `id`, and the only thing that
15//! vouches for an unsigned one is where it came from. [`Fetcher::lookup`]
16//! goes one step further, as Mastodon does: a document whose `id` is on
17//! another origin is fetched again from its `id`, once, and trusted only if
18//! the owner of the `id` serves it.
19//!
20//! A portable object, whose `id` is an `ap://` IRI, is never trusted from a
21//! fetch alone: its origin is a key, and only its proof vouches for it.
22//! [`Fetcher::portable`] asks its gateways for it and keeps the first copy
23//! whose proof holds; [`Fetcher::lookup`] sends a portable URL there.
24//!
25//! [`Fetcher::walk`] goes through a collection's items, a page at a time.
26//!
27//! A web page that is not itself a document may name the document it shows
28//! in an alternate link: [`link_header_alternate`] and [`html_alternate`]
29//! find it, for an application resolving a URL someone pasted, as Mastodon's
30//! `FetchResourceService` does.
31
32mod alternate;
33mod walk;
34
35#[cfg(feature = "html")]
36pub use alternate::html_alternate;
37pub use alternate::{ACTIVITY_LINK_TYPES, WebLink, link_header_alternate, parse_link_header};
38pub use walk::{Walk, WalkLimits};
39
40use crate::client::{Client, RequestError, Response};
41use crate::federation::NodeInfo;
42use crate::origin::Origin;
43use crate::portable::{self, DidResolver};
44use crate::portable::{ApUri, Hashlink, Media};
45use crate::sig::{Scheme, SenderKey};
46use crate::sig::{rfc9421, signature};
47use ojak_vocab::json::{FromJson, ToJson};
48use ojak_vocab::{Read, ReadError, Registry, read_reporting};
49use reqwest::header::{HeaderMap, HeaderValue};
50use serde_json::Value;
51use std::collections::HashMap;
52use std::fmt;
53use std::sync::{Arc, LazyLock, Mutex};
54use url::Url;
55
56/// What a fetch asks for: ActivityStreams, in either of its media types.
57pub const ACTIVITY_ACCEPT: &str = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";
58
59/// What a gateway is asked for, as FEP-ef61 has it.
60pub const PORTABLE_ACCEPT: &str =
61    "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";
62
63/// Why a fetch did not produce a document.
64#[derive(Debug)]
65pub enum FetchError {
66    /// The request was not answered, or was refused before it was sent.
67    Request(RequestError),
68    /// The request could not be signed.
69    Signing(String),
70    /// A redirect could not be followed: too many, no `Location`, or from
71    /// `https` to `http`.
72    Redirect(String),
73    /// The server answered with a status other than success.
74    Status(u16),
75    /// The response is not ActivityPub; the content type it had.
76    NotActivityPub(String),
77    /// The response is not a JSON object.
78    Invalid(String),
79    /// The document has no `id`.
80    NoId,
81    /// The document's `id` is not on the origin it was served from, and
82    /// nothing else vouches for it.
83    CrossOrigin { id: String, url: Url },
84    /// The document names an author, in `attributedTo` or `actor`, on
85    /// another origin than its own `id`: its server cannot vouch that the
86    /// author wrote it.
87    ForeignAuthor { id: String, author: String },
88    /// The document was established but is not a value of the type asked
89    /// for.
90    Read(ReadError),
91    /// No gateway served a portable object whose proof holds; why, for each
92    /// gateway tried.
93    Portable(Vec<(String, String)>),
94}
95
96impl FetchError {
97    /// The status the server answered with, if it answered with one other
98    /// than success. A 404 or 410 says the object is gone.
99    #[must_use]
100    pub fn status(&self) -> Option<u16> {
101        match self {
102            Self::Status(status) => Some(*status),
103            _ => None,
104        }
105    }
106}
107
108impl fmt::Display for FetchError {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        match self {
111            Self::Request(error) => error.fmt(f),
112            Self::Signing(error) => write!(f, "signing: {error}"),
113            Self::Redirect(error) => write!(f, "redirect: {error}"),
114            Self::Status(status) => write!(f, "HTTP {status}"),
115            Self::NotActivityPub(content_type) => {
116                write!(f, "not ActivityPub: {content_type:?}")
117            }
118            Self::Invalid(error) => write!(f, "not a JSON object: {error}"),
119            Self::NoId => f.write_str("document has no id"),
120            Self::CrossOrigin { id, url } => {
121                write!(f, "document served from {url} claims id {id}")
122            }
123            Self::ForeignAuthor { id, author } => {
124                write!(f, "{id} claims to be by {author}, on another origin")
125            }
126            Self::Read(error) => error.fmt(f),
127            Self::Portable(tried) if tried.is_empty() => {
128                f.write_str("portable object with no gateway to ask")
129            }
130            Self::Portable(tried) => {
131                f.write_str("no gateway served it:")?;
132                for (gateway, why) in tried {
133                    write!(f, " {gateway}: {why};")?;
134                }
135                Ok(())
136            }
137        }
138    }
139}
140
141impl std::error::Error for FetchError {}
142
143impl From<RequestError> for FetchError {
144    fn from(error: RequestError) -> Self {
145        Self::Request(error)
146    }
147}
148
149/// A document, fetched and established as what it says it is.
150#[derive(Clone, Debug, PartialEq)]
151pub struct Document {
152    /// Its `id`.
153    pub id: String,
154    /// The URL it was finally served from, after any redirects.
155    pub url: Url,
156    /// The document as it came, not yet normalised.
157    pub json: Value,
158}
159
160/// A document fetched, established and read into a vocabulary type.
161#[derive(Debug)]
162pub struct Typed<T> {
163    /// The document as it came, with where it came from.
164    pub document: Document,
165    /// What it read as, and what reading lost.
166    pub read: Read<T>,
167}
168
169/// The contexts documents are read over, parsed once.
170pub(crate) static REGISTRY: LazyLock<Registry> = LazyLock::new(Registry::bundled);
171
172/// Contexts processed over [`REGISTRY`], kept for the next document naming
173/// them. Processing a context costs ten times what reading the document
174/// with it does, and the fediverse sends a few dozen distinct ones.
175pub(crate) static CONTEXTS: LazyLock<ContextCache> = LazyLock::new(ContextCache::default);
176
177/// A bounded [`ojak_jsonld::ContextCache`].
178///
179/// Inline contexts are the sender's to write, so a sender could name a new
180/// one with every activity. Rather than grow with them, the cache starts
181/// over when full; that costs only the processing it was saving.
182#[derive(Default)]
183pub(crate) struct ContextCache(Mutex<HashMap<String, Arc<ojak_jsonld::ProcessedContext>>>);
184
185impl ContextCache {
186    const CAPACITY: usize = 1_024;
187}
188
189impl ojak_jsonld::ContextCache for ContextCache {
190    fn get(&self, key: &str) -> Option<Arc<ojak_jsonld::ProcessedContext>> {
191        self.0.lock().ok()?.get(key).cloned()
192    }
193
194    fn put(&self, key: String, context: Arc<ojak_jsonld::ProcessedContext>) {
195        if let Ok(mut contexts) = self.0.lock() {
196            if contexts.len() >= Self::CAPACITY {
197                contexts.clear();
198            }
199            contexts.insert(key, context);
200        }
201    }
202}
203
204/// Fetches from other servers. Cheap to share; one per process is enough.
205pub struct Fetcher {
206    client: Client,
207    first_scheme: Scheme,
208    /// The scheme each host last accepted.
209    schemes: Mutex<HashMap<String, Scheme>>,
210    resolver: Option<Arc<dyn DidResolver>>,
211    /// Whether WebFinger is asked over plain `http`.
212    plain_http_webfinger: bool,
213}
214
215impl fmt::Debug for Fetcher {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        f.debug_struct("Fetcher")
218            .field("client", &self.client)
219            .field("first_scheme", &self.first_scheme)
220            .finish_non_exhaustive()
221    }
222}
223
224impl Fetcher {
225    /// A fetcher that signs in `first_scheme` until a host has accepted one.
226    #[must_use]
227    pub fn new(client: Client, first_scheme: Scheme) -> Self {
228        Self {
229            client,
230            first_scheme,
231            schemes: Mutex::new(HashMap::new()),
232            resolver: None,
233            plain_http_webfinger: false,
234        }
235    }
236
237    /// Ask every WebFinger query over plain `http` rather than `https`, for
238    /// tests whose servers cannot serve `https`. Off unless turned on.
239    #[must_use]
240    pub fn with_plain_http_webfinger(mut self, plain: bool) -> Self {
241        self.plain_http_webfinger = plain;
242        self
243    }
244
245    /// Whether WebFinger is asked over plain `http`.
246    #[must_use]
247    pub fn plain_http_webfinger(&self) -> bool {
248        self.plain_http_webfinger
249    }
250
251    /// Resolve DID methods other than `did:key` with `resolver`, when
252    /// verifying portable objects.
253    #[must_use]
254    pub fn did_resolver(mut self, resolver: Arc<dyn DidResolver>) -> Self {
255        self.resolver = Some(resolver);
256        self
257    }
258
259    /// The resolver portable objects are verified with, if any.
260    #[must_use]
261    pub fn resolver(&self) -> Option<&dyn DidResolver> {
262        self.resolver.as_deref()
263    }
264
265    /// The client the fetcher sends through.
266    #[must_use]
267    pub fn client(&self) -> &Client {
268        &self.client
269    }
270
271    /// GET `url`, signed with `key` when given, following redirects.
272    ///
273    /// The response's `url` is where it was finally served from.
274    ///
275    /// # Errors
276    ///
277    /// When a URL is refused, a redirect cannot be followed, the request
278    /// cannot be signed, or it fails. A response with any other status is
279    /// `Ok`.
280    pub async fn get(
281        &self,
282        url: &Url,
283        accept: &str,
284        key: Option<&SenderKey>,
285    ) -> Result<Response, FetchError> {
286        let mut url = url.clone();
287        let mut redirects = 0;
288        loop {
289            let response = self.get_once(&url, accept, key).await?;
290            if !matches!(response.status, 301 | 302 | 303 | 307 | 308) {
291                return Ok(response);
292            }
293            redirects += 1;
294            if redirects > self.client.config().max_redirects {
295                return Err(FetchError::Redirect("too many redirects".into()));
296            }
297            let location = response
298                .headers
299                .get("location")
300                .and_then(|value| value.to_str().ok())
301                .ok_or_else(|| FetchError::Redirect("no Location".into()))?;
302            let next = url
303                .join(location)
304                .map_err(|error| FetchError::Redirect(format!("{location:?}: {error}")))?;
305            if url.scheme() == "https" && next.scheme() != "https" {
306                return Err(FetchError::Redirect(format!("{url} to {next}")));
307            }
308            url = next;
309        }
310    }
311
312    /// One request, with no redirect followed; a signed one refused in its
313    /// first scheme is tried in the other.
314    async fn get_once(
315        &self,
316        url: &Url,
317        accept: &str,
318        key: Option<&SenderKey>,
319    ) -> Result<Response, FetchError> {
320        let Some(key) = key else {
321            return Ok(self.client.get_direct(url, headers(accept)?).await?);
322        };
323        let host = url.host_str().unwrap_or_default().to_owned();
324        let first = self
325            .schemes
326            .lock()
327            .expect("scheme lock")
328            .get(&host)
329            .copied()
330            .unwrap_or(self.first_scheme);
331        let mut response = self.signed(url, accept, key, first).await?;
332        let mut scheme = first;
333        if matches!(response.status, 400 | 401) {
334            scheme = first.other();
335            response = self.signed(url, accept, key, scheme).await?;
336        }
337        if (200..400).contains(&response.status) {
338            self.schemes
339                .lock()
340                .expect("scheme lock")
341                .insert(host, scheme);
342        }
343        Ok(response)
344    }
345
346    async fn signed(
347        &self,
348        url: &Url,
349        accept: &str,
350        key: &SenderKey,
351        scheme: Scheme,
352    ) -> Result<Response, FetchError> {
353        let mut headers = headers(accept)?;
354        match scheme {
355            Scheme::DraftCavage => {
356                let signed = signature::sign_get_with_key(
357                    url.as_str(),
358                    &key.key_id,
359                    &key.private_key,
360                    chrono::Utc::now().timestamp(),
361                )
362                .map_err(|error| FetchError::Signing(error.to_string()))?;
363                insert(&mut headers, "date", &signed.date)?;
364                insert(&mut headers, "signature", &signed.signature)?;
365            }
366            Scheme::Rfc9421 => {
367                let signed = rfc9421::sign_request(
368                    "get",
369                    url.as_str(),
370                    None,
371                    &key.key_id,
372                    &rfc9421::SigningKey::Rsa(&key.private_key),
373                    chrono::Utc::now().timestamp(),
374                )
375                .map_err(|error| FetchError::Signing(error.to_string()))?;
376                insert(&mut headers, "signature-input", &signed.signature_input)?;
377                insert(&mut headers, "signature", &signed.signature)?;
378            }
379        }
380        Ok(self.client.get_direct(url, headers).await?)
381    }
382
383    /// Fetch the ActivityPub document at `url` and establish it: served as
384    /// ActivityPub with success, a JSON object, with an `id` on the origin
385    /// it was finally served from, and naming no author on another origin.
386    ///
387    /// # Errors
388    ///
389    /// As [`Fetcher::get`], and when the document is not established; a
390    /// document whose `id` is elsewhere is [`FetchError::CrossOrigin`], and
391    /// one by an author elsewhere [`FetchError::ForeignAuthor`].
392    pub async fn document(
393        &self,
394        url: &Url,
395        key: Option<&SenderKey>,
396    ) -> Result<Document, FetchError> {
397        let response = self.get(url, ACTIVITY_ACCEPT, key).await?;
398        if !(200..300).contains(&response.status) {
399            return Err(FetchError::Status(response.status));
400        }
401        let content_type = response
402            .headers
403            .get("content-type")
404            .and_then(|value| value.to_str().ok())
405            .unwrap_or_default();
406        if !is_activity_content_type(content_type) {
407            return Err(FetchError::NotActivityPub(content_type.to_owned()));
408        }
409        let json: Value = serde_json::from_slice(&response.body)
410            .map_err(|error| FetchError::Invalid(error.to_string()))?;
411        if !json.is_object() {
412            return Err(FetchError::Invalid("not an object".into()));
413        }
414        let id = json
415            .get("id")
416            .and_then(Value::as_str)
417            .ok_or(FetchError::NoId)?
418            .to_owned();
419        if !served_by_its_origin(&id, &response.url) {
420            return Err(FetchError::CrossOrigin {
421                id,
422                url: response.url,
423            });
424        }
425        if let Some(author) = foreign_author(&json, &id) {
426            return Err(FetchError::ForeignAuthor { id, author });
427        }
428        Ok(Document {
429            id,
430            url: response.url,
431            json,
432        })
433    }
434
435    /// Fetch the JSON at `url` without establishing it, as Mastodon's
436    /// `fetch_resource_without_id_validation` does: asked for as
437    /// ActivityPub, and taken only from a `200` served as ActivityPub. What
438    /// it says about itself is not checked, so it is only as trustworthy as
439    /// the server it came from, whatever its `id` says: a caller that
440    /// trusts it for anything more checks that itself.
441    ///
442    /// `None` for a `2xx` other than `200`, for another content type, and
443    /// for a body that is not JSON.
444    ///
445    /// # Errors
446    ///
447    /// As [`Fetcher::get`], and [`FetchError::Status`] for a status other
448    /// than success, so that the caller can tell a temporary failure from a
449    /// permanent one.
450    pub async fn unverified_json(
451        &self,
452        url: &Url,
453        key: Option<&SenderKey>,
454    ) -> Result<Option<Value>, FetchError> {
455        let response = self
456            .get(url, "application/activity+json, application/ld+json", key)
457            .await?;
458        if !(200..300).contains(&response.status) {
459            return Err(FetchError::Status(response.status));
460        }
461        let content_type = response
462            .headers
463            .get("content-type")
464            .and_then(|value| value.to_str().ok())
465            .unwrap_or_default();
466        if response.status != 200 || !is_activity_content_type(content_type) {
467            return Ok(None);
468        }
469        Ok(serde_json::from_slice(&response.body).ok())
470    }
471
472    /// [`Fetcher::document`], and when the document's `id` is on another
473    /// origin, fetch that `id` instead, once: what comes back is trusted only
474    /// if the owner of the `id` serves it.
475    ///
476    /// # Errors
477    ///
478    /// As [`Fetcher::document`], for the second fetch when there is one.
479    ///
480    /// A portable URL, `ap` or at a gateway, is fetched with
481    /// [`Fetcher::portable`] instead.
482    pub async fn lookup(&self, url: &Url, key: Option<&SenderKey>) -> Result<Document, FetchError> {
483        if let Some(uri) = ApUri::parse(url.as_str()) {
484            return self.portable(&uri, &[], key).await;
485        }
486        match self.document(url, key).await {
487            Err(FetchError::CrossOrigin { id, url: served }) => {
488                // An id with no origin names no owner to ask.
489                let Some(Origin::Web { .. }) = Origin::of(&id) else {
490                    return Err(FetchError::CrossOrigin { id, url: served });
491                };
492                let Ok(own) = Url::parse(&id) else {
493                    return Err(FetchError::CrossOrigin { id, url: served });
494                };
495                if !matches!(own.scheme(), "http" | "https") {
496                    return Err(FetchError::CrossOrigin { id, url: served });
497                }
498                self.document(&own, key).await
499            }
500            other => other,
501        }
502    }
503}
504
505impl Fetcher {
506    /// Fetch the portable object `uri` names from its gateways: the hints it
507    /// carries, then `gateways`, in order, and no more than
508    /// [`portable::MAX_GATEWAYS`] of them. The first document served as
509    /// ActivityPub whose `id` is `uri` and whose proof by `uri`'s DID holds
510    /// is returned; a gateway that fails any of that is passed over.
511    ///
512    /// # Errors
513    ///
514    /// [`FetchError::Portable`], with why each gateway was passed over.
515    pub async fn portable(
516        &self,
517        uri: &ApUri,
518        gateways: &[&str],
519        key: Option<&SenderKey>,
520    ) -> Result<Document, FetchError> {
521        let mut tried = Vec::new();
522        let mut asked: Vec<&str> = Vec::new();
523        for gateway in uri
524            .gateways()
525            .iter()
526            .map(String::as_str)
527            .chain(gateways.iter().copied())
528        {
529            if asked.contains(&gateway) {
530                continue;
531            }
532            if !portable::is_gateway(gateway) {
533                tried.push((gateway.to_owned(), "not a gateway".into()));
534                continue;
535            }
536            if asked.len() == portable::MAX_GATEWAYS {
537                tried.push((gateway.to_owned(), "not asked: too many gateways".into()));
538                break;
539            }
540            asked.push(gateway);
541            match self.ask_gateway(uri, gateway, key).await {
542                Ok(document) => return Ok(document),
543                Err(why) => tried.push((gateway.to_owned(), why)),
544            }
545        }
546        Err(FetchError::Portable(tried))
547    }
548
549    async fn ask_gateway(
550        &self,
551        uri: &ApUri,
552        gateway: &str,
553        key: Option<&SenderKey>,
554    ) -> Result<Document, String> {
555        let document = self.served_by_gateway(uri, gateway, key).await?;
556        portable::verify(&document.json, self.resolver())
557            .await
558            .map_err(|error| error.to_string())?;
559        Ok(document)
560    }
561
562    /// What `gateway` serves as `uri`, when it is ActivityPub and its `id` is
563    /// `uri`; not yet verified.
564    async fn served_by_gateway(
565        &self,
566        uri: &ApUri,
567        gateway: &str,
568        key: Option<&SenderKey>,
569    ) -> Result<Document, String> {
570        let url = Url::parse(&uri.at_gateway(gateway)).map_err(|error| error.to_string())?;
571        let response = self
572            .get(&url, PORTABLE_ACCEPT, key)
573            .await
574            .map_err(|error| error.to_string())?;
575        if !(200..300).contains(&response.status) {
576            return Err(FetchError::Status(response.status).to_string());
577        }
578        let content_type = response
579            .headers
580            .get("content-type")
581            .and_then(|value| value.to_str().ok())
582            .unwrap_or_default();
583        if !is_activity_content_type(content_type) {
584            return Err(FetchError::NotActivityPub(content_type.to_owned()).to_string());
585        }
586        let json: Value =
587            serde_json::from_slice(&response.body).map_err(|error| error.to_string())?;
588        let id = json.get("id").and_then(Value::as_str).unwrap_or_default();
589        match ApUri::parse(id) {
590            Some(served) if served == *uri => {}
591            Some(served) => return Err(format!("served {served} for {uri}")),
592            None => return Err(format!("served {id:?} for {uri}")),
593        }
594        Ok(Document {
595            id: id.to_owned(),
596            url: response.url,
597            json,
598        })
599    }
600
601    /// Fetch a portable actor's collection, its `inbox`, `outbox`,
602    /// `followers`, `following` or `liked`, or a page of one, which FEP-ef61
603    /// lets a gateway serve without a proof. `owner` is the actor's document,
604    /// verified again here, which for `did:key` fetches nothing.
605    ///
606    /// The collection is asked of the owner's own gateways in turn, and no
607    /// more than [`portable::MAX_GATEWAYS`] of them: one with no proof is
608    /// taken because a gateway the owner lists served it, and from nowhere
609    /// else, as FEP-ef61 asks. One with a proof is held to it, as any
610    /// portable object is. A page with no proof has to name one of the
611    /// owner's collections as its `partOf`.
612    ///
613    /// # Errors
614    ///
615    /// [`FetchError::Invalid`] when `owner` is not an authentic portable
616    /// actor or `iri` is not under its DID, and [`FetchError::Portable`],
617    /// with why each gateway was passed over, when none served it.
618    pub async fn portable_collection(
619        &self,
620        owner: &Value,
621        iri: &str,
622        key: Option<&SenderKey>,
623    ) -> Result<Document, FetchError> {
624        let actor = portable::verify(owner, self.resolver())
625            .await
626            .map_err(|error| FetchError::Invalid(format!("owner: {error}")))?;
627        let uri = ApUri::parse(iri)
628            .filter(|uri| uri.did() == actor.did())
629            .ok_or_else(|| FetchError::Invalid(format!("{iri} is not {actor}'s")))?;
630        let listed: Vec<ApUri> = ["inbox", "outbox", "followers", "following", "liked"]
631            .iter()
632            .filter_map(|property| owner.get(*property))
633            .filter_map(|value| match value {
634                Value::String(id) => Some(id.as_str()),
635                Value::Object(object) => object.get("id").and_then(Value::as_str),
636                _ => None,
637            })
638            .filter_map(ApUri::parse)
639            .collect();
640        let mut tried = Vec::new();
641        for gateway in portable::gateways(owner)
642            .iter()
643            .take(portable::MAX_GATEWAYS)
644        {
645            let document = match self.served_by_gateway(&uri, gateway, key).await {
646                Ok(document) => document,
647                Err(why) => {
648                    tried.push((gateway.clone(), why));
649                    continue;
650                }
651            };
652            let taken = if document.json.get("proof").is_some() {
653                portable::verify(&document.json, self.resolver())
654                    .await
655                    .map(drop)
656                    .map_err(|error| error.to_string())
657            } else {
658                unsecured_collection(&document, gateway, &uri, &listed)
659            };
660            match taken {
661                Ok(()) => return Ok(document),
662                Err(why) => tried.push((gateway.clone(), why)),
663            }
664        }
665        Err(FetchError::Portable(tried))
666    }
667
668    /// Fetch the media a portable object refers to, and check it against
669    /// `digest`, the `digestMultibase` the object gives it: FEP-ef61 asks
670    /// for this whoever serves it. `url` is the object's `url` for it: a
671    /// hashlink is asked of `gateways` in turn, the gateways of the object's
672    /// owner, and no more than [`portable::MAX_GATEWAYS`] of them; any other
673    /// URL is fetched as it is. What a response may hold is bounded by the
674    /// client's `max_response_bytes`.
675    ///
676    /// # Errors
677    ///
678    /// [`FetchError::Invalid`] when `digest` is not a SHA-256 multihash or
679    /// `url` is a hashlink of another digest, and [`FetchError::Portable`],
680    /// with why each place asked was passed over, when none served it.
681    pub async fn portable_media(
682        &self,
683        url: &str,
684        digest: &str,
685        gateways: &[&str],
686        key: Option<&SenderKey>,
687    ) -> Result<Media, FetchError> {
688        let expected = Hashlink::from_multibase(digest)
689            .ok_or_else(|| FetchError::Invalid(format!("digest {digest:?} is not SHA-256")))?;
690        let mut tried = Vec::new();
691        let mut asked: Vec<&str> = Vec::new();
692        let places: Vec<String> = if url.starts_with("hl:") {
693            if Hashlink::parse(url).as_ref() != Some(&expected) {
694                return Err(FetchError::Invalid(format!("{url} is not {digest}")));
695            }
696            let mut places = Vec::new();
697            for gateway in gateways {
698                if asked.contains(gateway) {
699                    continue;
700                }
701                if !portable::is_gateway(gateway) {
702                    tried.push(((*gateway).to_owned(), "not a gateway".into()));
703                    continue;
704                }
705                if asked.len() == portable::MAX_GATEWAYS {
706                    tried.push(((*gateway).to_owned(), "not asked: too many gateways".into()));
707                    break;
708                }
709                asked.push(gateway);
710                places.push(format!(
711                    "{}{}{expected}",
712                    gateway.trim_end_matches('/'),
713                    portable::GATEWAY_PATH
714                ));
715            }
716            places
717        } else {
718            vec![url.to_owned()]
719        };
720        for place in places {
721            match self.media_at(&place, &expected, key).await {
722                Ok(media) => return Ok(media),
723                Err(why) => tried.push((place, why)),
724            }
725        }
726        Err(FetchError::Portable(tried))
727    }
728
729    async fn media_at(
730        &self,
731        place: &str,
732        expected: &Hashlink,
733        key: Option<&SenderKey>,
734    ) -> Result<Media, String> {
735        let url = Url::parse(place).map_err(|error| error.to_string())?;
736        if !matches!(url.scheme(), "http" | "https") {
737            return Err(format!("not fetched: {}", url.scheme()));
738        }
739        let response = self
740            .get(&url, "*/*", key)
741            .await
742            .map_err(|error| error.to_string())?;
743        if !(200..300).contains(&response.status) {
744            return Err(FetchError::Status(response.status).to_string());
745        }
746        if !expected.matches(&response.body) {
747            return Err(format!("not what {expected} names"));
748        }
749        let content_type = response
750            .headers
751            .get("content-type")
752            .and_then(|value| value.to_str().ok())
753            .unwrap_or("application/octet-stream")
754            .to_owned();
755        Ok(Media {
756            content_type,
757            bytes: response.body,
758        })
759    }
760
761    /// Find what software the server at `origin` runs, and how it is used,
762    /// from its NodeInfo: the links at `/.well-known/nodeinfo`, and the
763    /// document of the newest schema they name. A link to another host is
764    /// not followed, since a server answers only for itself.
765    ///
766    /// # Errors
767    ///
768    /// As [`Fetcher::get`], [`FetchError::Status`] when either is not
769    /// served, and [`FetchError::Invalid`] when either is not what NodeInfo
770    /// says it is.
771    pub async fn nodeinfo(&self, origin: &Url) -> Result<NodeInfo, FetchError> {
772        let links = origin
773            .join("/.well-known/nodeinfo")
774            .map_err(|error| FetchError::Invalid(error.to_string()))?;
775        let links = self.json(&links).await?;
776        let mut best: Option<(&str, &str)> = None;
777        for link in links
778            .get("links")
779            .and_then(Value::as_array)
780            .into_iter()
781            .flatten()
782        {
783            let (Some(rel), Some(href)) = (
784                link.get("rel").and_then(Value::as_str),
785                link.get("href").and_then(Value::as_str),
786            ) else {
787                continue;
788            };
789            let Some(version) = rel.strip_prefix("http://nodeinfo.diaspora.software/ns/schema/")
790            else {
791                continue;
792            };
793            if best.is_none_or(|(newest, _)| newer(version, newest)) {
794                best = Some((version, href));
795            }
796        }
797        let (_, href) =
798            best.ok_or_else(|| FetchError::Invalid("no NodeInfo schema linked".into()))?;
799        let document = origin
800            .join(href)
801            .map_err(|error| FetchError::Invalid(format!("{href}: {error}")))?;
802        if document.host_str() != origin.host_str() {
803            return Err(FetchError::Invalid(format!(
804                "NodeInfo at {document}, not on {origin}"
805            )));
806        }
807        let document = self.json(&document).await?;
808        NodeInfo::from_document(&document)
809            .ok_or_else(|| FetchError::Invalid("NodeInfo names no software".into()))
810    }
811
812    /// The JSON object at `url`, unsigned: what servers serve to anyone.
813    async fn json(&self, url: &Url) -> Result<Value, FetchError> {
814        let response = self.get(url, "application/json", None).await?;
815        if !(200..300).contains(&response.status) {
816            return Err(FetchError::Status(response.status));
817        }
818        match serde_json::from_slice(&response.body) {
819            Ok(document @ Value::Object(_)) => Ok(document),
820            Ok(_) => Err(FetchError::Invalid(format!("{url} is not a JSON object"))),
821            Err(error) => Err(FetchError::Invalid(format!("{url}: {error}"))),
822        }
823    }
824
825    /// [`Fetcher::lookup`], and read what it found into `T`: an actor, a
826    /// note, `AnyObject` for whatever it turns out to be. What reading lost
827    /// is in [`Read::lost`].
828    ///
829    /// # Errors
830    ///
831    /// As [`Fetcher::lookup`], and [`FetchError::Read`] when the document is
832    /// not a `T`.
833    pub async fn lookup_as<T: FromJson + ToJson>(
834        &self,
835        url: &Url,
836        key: Option<&SenderKey>,
837    ) -> Result<Typed<T>, FetchError> {
838        let document = self.lookup(url, key).await?;
839        let read = read_reporting(&REGISTRY, &document.json).map_err(FetchError::Read)?;
840        Ok(Typed { document, read })
841    }
842}
843
844/// Whether NodeInfo schema `version`, such as `2.1`, is newer than `than`.
845fn newer(version: &str, than: &str) -> bool {
846    let parse = |version: &str| -> Vec<u32> {
847        version
848            .split('.')
849            .map(|part| part.parse().unwrap_or(0))
850            .collect()
851    };
852    parse(version) > parse(than)
853}
854
855/// Whether `document`, served by `gateway` without a proof, may be taken
856/// as `uri`: it was served from the gateway, not redirected elsewhere, and
857/// is one of the collections in `listed` or a page of one.
858fn unsecured_collection(
859    document: &Document,
860    gateway: &str,
861    uri: &ApUri,
862    listed: &[ApUri],
863) -> Result<(), String> {
864    if !same_origin(document.url.as_str(), gateway) {
865        return Err(format!("served from {}, not the gateway", document.url));
866    }
867    let kinds = ["Collection", "OrderedCollection"];
868    let pages = ["CollectionPage", "OrderedCollectionPage"];
869    if listed.contains(uri) && portable::has_type(&document.json, &kinds) {
870        return Ok(());
871    }
872    if portable::has_type(&document.json, &pages) {
873        let part_of = match document.json.get("partOf") {
874            Some(Value::String(id)) => Some(id.as_str()),
875            Some(Value::Object(object)) => object.get("id").and_then(Value::as_str),
876            _ => None,
877        };
878        if part_of
879            .and_then(ApUri::parse)
880            .is_some_and(|collection| listed.contains(&collection))
881        {
882            return Ok(());
883        }
884        return Err(format!(
885            "page of {part_of:?}, not of its owner's collection"
886        ));
887    }
888    Err(format!(
889        "no proof, and not a collection of its owner: {}",
890        document.json.get("type").unwrap_or(&Value::Null)
891    ))
892}
893
894fn same_origin(a: &str, b: &str) -> bool {
895    match (Url::parse(a), Url::parse(b)) {
896        (Ok(a), Ok(b)) => a.origin() == b.origin(),
897        _ => false,
898    }
899}
900
901/// Whether `id` has the origin of `url`, where it was served from. A
902/// portable `id` has a key for its origin, never a host, so it is never
903/// vouched for by where it was served.
904fn served_by_its_origin(id: &str, url: &Url) -> bool {
905    match (Origin::of(id), Origin::of(url.as_str())) {
906        (Some(id @ Origin::Web { .. }), Some(served)) => id == served,
907        _ => false,
908    }
909}
910
911/// The first author `document` names, in `attributedTo` or `actor`, that is
912/// not on the origin of `id`. A server vouches for what it serves under its
913/// own origin, and a note it serves that is attributed to someone elsewhere
914/// is its claim, not that someone's.
915fn foreign_author(document: &Value, id: &str) -> Option<String> {
916    fn ids(value: &Value) -> Vec<&str> {
917        match value {
918            Value::String(id) => vec![id.as_str()],
919            Value::Object(object) => object
920                .get("id")
921                .and_then(Value::as_str)
922                .into_iter()
923                .collect(),
924            Value::Array(items) => items.iter().flat_map(ids).collect(),
925            _ => Vec::new(),
926        }
927    }
928    ["attributedTo", "actor"]
929        .iter()
930        .filter_map(|key| document.get(*key))
931        .flat_map(ids)
932        .find(|author| !crate::origin::same_origin(author, id))
933        .map(str::to_owned)
934}
935
936/// Whether `content_type` is ActivityStreams: `application/activity+json`,
937/// or `application/ld+json` with the ActivityStreams profile. Plain JSON is
938/// not enough; a server that lets its users upload files would otherwise
939/// serve documents in its own name that none of its actors wrote.
940#[must_use]
941pub fn is_activity_content_type(content_type: &str) -> bool {
942    let mut parts = content_type.split(';').map(str::trim);
943    let essence = parts.next().unwrap_or_default().to_ascii_lowercase();
944    match essence.as_str() {
945        "application/activity+json" => true,
946        "application/ld+json" => parts.any(|parameter| {
947            parameter.split_once('=').is_some_and(|(name, value)| {
948                name.trim().eq_ignore_ascii_case("profile")
949                    && value
950                        .trim()
951                        .trim_matches('"')
952                        .split_whitespace()
953                        .any(|profile| profile == "https://www.w3.org/ns/activitystreams")
954            })
955        }),
956        _ => false,
957    }
958}
959
960fn headers(accept: &str) -> Result<HeaderMap, FetchError> {
961    let mut headers = HeaderMap::new();
962    insert(&mut headers, "accept", accept)?;
963    Ok(headers)
964}
965
966fn insert(headers: &mut HeaderMap, name: &'static str, value: &str) -> Result<(), FetchError> {
967    let value = HeaderValue::from_str(value)
968        .map_err(|error| FetchError::Signing(format!("{name}: {error}")))?;
969    headers.insert(name, value);
970    Ok(())
971}
972
973#[cfg(test)]
974mod tests {
975    use super::*;
976
977    #[test]
978    fn activitystreams_is_recognised_in_either_media_type() {
979        for content_type in [
980            "application/activity+json",
981            "application/activity+json; charset=utf-8",
982            "Application/Activity+JSON",
983            "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"",
984            "application/ld+json;profile=https://www.w3.org/ns/activitystreams",
985            "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams https://example.com/other\"",
986        ] {
987            assert!(is_activity_content_type(content_type), "{content_type}");
988        }
989        for content_type in [
990            "",
991            "application/json",
992            "application/ld+json",
993            "application/ld+json; profile=\"https://example.com/other\"",
994            "text/html",
995        ] {
996            assert!(!is_activity_content_type(content_type), "{content_type}");
997        }
998    }
999
1000    #[test]
1001    fn an_id_is_vouched_for_only_by_its_own_origin() {
1002        let url = Url::parse("https://a.example/users/bob").unwrap();
1003        assert!(served_by_its_origin("https://a.example/users/bob", &url));
1004        assert!(served_by_its_origin("https://A.example:443/other", &url));
1005        assert!(!served_by_its_origin("https://b.example/users/bob", &url));
1006        assert!(!served_by_its_origin("http://a.example/users/bob", &url));
1007        assert!(!served_by_its_origin(
1008            "ap://did:key:z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2/actor",
1009            &url
1010        ));
1011        assert!(!served_by_its_origin("not an iri", &url));
1012    }
1013}