1mod 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
56pub const ACTIVITY_ACCEPT: &str = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";
58
59pub const PORTABLE_ACCEPT: &str =
61 "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";
62
63#[derive(Debug)]
65pub enum FetchError {
66 Request(RequestError),
68 Signing(String),
70 Redirect(String),
73 Status(u16),
75 NotActivityPub(String),
77 Invalid(String),
79 NoId,
81 CrossOrigin { id: String, url: Url },
84 ForeignAuthor { id: String, author: String },
88 Read(ReadError),
91 Portable(Vec<(String, String)>),
94}
95
96impl FetchError {
97 #[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#[derive(Clone, Debug, PartialEq)]
151pub struct Document {
152 pub id: String,
154 pub url: Url,
156 pub json: Value,
158}
159
160#[derive(Debug)]
162pub struct Typed<T> {
163 pub document: Document,
165 pub read: Read<T>,
167}
168
169pub(crate) static REGISTRY: LazyLock<Registry> = LazyLock::new(Registry::bundled);
171
172pub(crate) static CONTEXTS: LazyLock<ContextCache> = LazyLock::new(ContextCache::default);
176
177#[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
204pub struct Fetcher {
206 client: Client,
207 first_scheme: Scheme,
208 schemes: Mutex<HashMap<String, Scheme>>,
210 resolver: Option<Arc<dyn DidResolver>>,
211 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 #[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 #[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 #[must_use]
247 pub fn plain_http_webfinger(&self) -> bool {
248 self.plain_http_webfinger
249 }
250
251 #[must_use]
254 pub fn did_resolver(mut self, resolver: Arc<dyn DidResolver>) -> Self {
255 self.resolver = Some(resolver);
256 self
257 }
258
259 #[must_use]
261 pub fn resolver(&self) -> Option<&dyn DidResolver> {
262 self.resolver.as_deref()
263 }
264
265 #[must_use]
267 pub fn client(&self) -> &Client {
268 &self.client
269 }
270
271 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 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 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 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 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 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 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 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 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 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 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 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 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(®ISTRY, &document.json).map_err(FetchError::Read)?;
840 Ok(Typed { document, read })
841 }
842}
843
844fn 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
855fn 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
901fn 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
911fn 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#[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}