//! ActivityPub channel: works for any ActivityPub-compatible service — //! Mastodon, Pleroma, Akkoma, Misskey, GoToSocial, Friendica, PeerTube, … //! //! Two public endpoints, no auth: //! //! 1. **WebFinger** — `GET https:///.well-known/webfinger?resource=acct:@` //! Returns the user's ActivityPub actor URL. //! 2. **Actor JSON** — `GET ` with `Accept: application/activity+json` //! Returns the user's profile, including `attachment` (Mastodon profile //! fields) and `summary` (bio). //! //! Proof discovery order: //! 1. `attachment[].value` (profile fields — Mastodon's explicit "user-published //! metadata" surface) //! 2. `summary` (bio) //! //! Pinned posts (`featured` collection) are a TODO for v0.2; not needed for the //! minimal flow because the compact `kez:z1:` form fits in both attachment //! values and bios on every major instance. //! //! Identifier shape: `ap:@@` is canonical. `mastodon:@@` //! is registered as an alias and dispatches to the same adapter. use async_trait::async_trait; use kez_core::Identity; use reqwest::Client; use serde_json::Value; use crate::{Channel, ChannelError, ChannelHit, ChannelResult, parse_and_verify_for}; const USER_AGENT: &str = "kez-channels/0.1 (+https://example.invalid/kez)"; #[derive(Clone)] pub struct ActivityPubChannel { client: Client, /// If set, every fetch (WebFinger + actor) goes here instead of /// `https://`. Used by tests pointing at wiremock. None in prod. base_override: Option, /// Canonical scheme this instance reports via `Channel::system()`. /// In production this is "ap"; aliases (e.g. "mastodon") get registered /// separately in the registry. canonical_system: &'static str, } impl ActivityPubChannel { pub fn new() -> anyhow::Result { let client = Client::builder().user_agent(USER_AGENT).build()?; Ok(Self { client, base_override: None, canonical_system: "ap", }) } /// For tests: route every HTTP call to `base` regardless of server name. pub fn with_base(client: Client, base: String) -> Self { Self { client, base_override: Some(base), canonical_system: "ap", } } fn base_for(&self, server: &str) -> String { self.base_override .clone() .unwrap_or_else(|| format!("https://{server}")) } async fn fetch_json(&self, url: &str, accept: &str) -> ChannelResult { let resp = self .client .get(url) .header("Accept", accept) .send() .await .map_err(|e| ChannelError::Unreachable(format!("GET {url}: {e}")))?; // Distinguish 404 (NotFound) from other failures (Unreachable). if resp.status() == reqwest::StatusCode::NOT_FOUND { return Err(ChannelError::Unreachable(format!("GET {url}: 404"))); } let resp = resp .error_for_status() .map_err(|e| ChannelError::Unreachable(format!("GET {url}: {e}")))?; resp.json() .await .map_err(|e| ChannelError::Other(anyhow::anyhow!("parse JSON {url}: {e}"))) } } #[async_trait] impl Channel for ActivityPubChannel { fn system(&self) -> &'static str { self.canonical_system } async fn fetch_and_verify(&self, identity: &Identity) -> ChannelResult { let (user, server) = parse_handle(identity.value())?; let base = self.base_for(&server); // 1. WebFinger → actor URL. let wf_url = webfinger_url(&base, &user, &server); let wf = self .fetch_json(&wf_url, "application/jrd+json") .await?; let actor_url = extract_actor_url(&wf) .ok_or_else(|| ChannelError::NotFound(identity.clone()))?; // 2. Actor JSON → candidate proof strings. let actor = self .fetch_json(&actor_url, "application/activity+json") .await?; let candidates = extract_actor_candidates(&actor); // 3. Try each candidate against parse_and_verify_for. let mut last_error: Option = None; for raw in candidates { match parse_and_verify_for(&raw, identity) { Ok(hit) => return Ok(hit), Err(err) => last_error = Some(err), } } Err(last_error.unwrap_or_else(|| ChannelError::NotFound(identity.clone()))) } } /// Pure: split a Mastodon-style handle (`@user@server` or `user@server`) into /// its parts. Leading `@` is stripped. pub fn parse_handle(value: &str) -> ChannelResult<(String, String)> { let trimmed = value.strip_prefix('@').unwrap_or(value); let (user, server) = trimmed.split_once('@').ok_or_else(|| { ChannelError::Other(anyhow::anyhow!( "expected `@user@server`, got: {value}" )) })?; if user.is_empty() || server.is_empty() { return Err(ChannelError::Other(anyhow::anyhow!( "invalid handle (empty part): {value}" ))); } Ok((user.to_owned(), server.to_owned())) } /// Pure: WebFinger URL. pub fn webfinger_url(base: &str, user: &str, server: &str) -> String { format!("{base}/.well-known/webfinger?resource=acct:{user}@{server}") } /// Pure: pull the actor URL out of a WebFinger response. We accept either /// `application/activity+json` or `application/ld+json` in the link's /// `type` (Mastodon uses the former, some servers use the latter). pub fn extract_actor_url(webfinger: &Value) -> Option { let links = webfinger.get("links")?.as_array()?; for link in links { let rel = link.get("rel").and_then(|v| v.as_str()).unwrap_or(""); let typ = link.get("type").and_then(|v| v.as_str()).unwrap_or(""); if rel == "self" && (typ.contains("activity+json") || typ.contains("ld+json")) && let Some(href) = link.get("href").and_then(|v| v.as_str()) { return Some(href.to_owned()); } } None } /// Pure: pull candidate proof strings out of an Actor JSON. Attachments come /// first (explicit user-published metadata fields), bio second. pub fn extract_actor_candidates(actor: &Value) -> Vec { let mut out = Vec::new(); if let Some(attachments) = actor.get("attachment").and_then(|v| v.as_array()) { for att in attachments { if let Some(value) = att.get("value").and_then(|v| v.as_str()) { out.push(strip_html(value)); } } } if let Some(summary) = actor.get("summary").and_then(|v| v.as_str()) { out.push(strip_html(summary)); } out } /// Pure: strip HTML tags and decode a small set of named entities so that /// `parse_proof` can run against the underlying text. Good enough for /// Mastodon-style bios and PropertyValue fields; we are not building a /// general HTML parser. pub fn strip_html(html: &str) -> String { let mut out = String::with_capacity(html.len()); let mut chars = html.chars().peekable(); while let Some(c) = chars.next() { match c { '<' => { // Drop everything up to and including the next '>'. for next in chars.by_ref() { if next == '>' { break; } } } '&' => { let mut entity = String::new(); let mut closed = false; for _ in 0..8 { match chars.peek() { Some(';') => { chars.next(); closed = true; break; } Some(&c2) if c2.is_ascii_alphanumeric() || c2 == '#' => { chars.next(); entity.push(c2); } _ => break, } } if closed { match entity.as_str() { "amp" => out.push('&'), "lt" => out.push('<'), "gt" => out.push('>'), "quot" => out.push('"'), "apos" | "#39" => out.push('\''), "nbsp" => out.push(' '), _ => { out.push('&'); out.push_str(&entity); out.push(';'); } } } else { out.push('&'); out.push_str(&entity); } } _ => out.push(c), } } out } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn parse_handle_accepts_canonical_and_unprefixed() { let (u, s) = parse_handle("@jason@mastodon.social").unwrap(); assert_eq!(u, "jason"); assert_eq!(s, "mastodon.social"); let (u, s) = parse_handle("jason@mastodon.social").unwrap(); assert_eq!(u, "jason"); assert_eq!(s, "mastodon.social"); } #[test] fn parse_handle_rejects_malformed() { assert!(parse_handle("jason").is_err()); // no server assert!(parse_handle("@@server").is_err()); // empty user assert!(parse_handle("@jason@").is_err()); // empty server } #[test] fn webfinger_url_matches_spec_shape() { let url = webfinger_url("https://mastodon.social", "jason", "mastodon.social"); assert_eq!( url, "https://mastodon.social/.well-known/webfinger?resource=acct:jason@mastodon.social" ); } #[test] fn extract_actor_url_picks_self_activity_json() { let wf = json!({ "subject": "acct:jason@mastodon.social", "links": [ {"rel": "http://webfinger.net/rel/profile-page", "type": "text/html", "href": "https://mastodon.social/@jason"}, {"rel": "self", "type": "application/activity+json", "href": "https://mastodon.social/users/jason"} ] }); assert_eq!( extract_actor_url(&wf).as_deref(), Some("https://mastodon.social/users/jason") ); } #[test] fn extract_actor_url_accepts_ld_json() { let wf = json!({ "links": [{"rel": "self", "type": "application/ld+json; profile=\"...\"", "href": "https://example/u/jason"}] }); assert_eq!(extract_actor_url(&wf).as_deref(), Some("https://example/u/jason")); } #[test] fn extract_actor_url_missing_returns_none() { assert!(extract_actor_url(&json!({})).is_none()); assert!(extract_actor_url(&json!({"links": []})).is_none()); assert!(extract_actor_url(&json!({ "links": [{"rel": "self", "type": "text/html", "href": "..."}] })) .is_none()); } #[test] fn extract_actor_candidates_attachment_then_summary() { let actor = json!({ "attachment": [ {"type": "PropertyValue", "name": "site", "value": "x"}, {"type": "PropertyValue", "name": "kez", "value": "kez:z1:abc"} ], "summary": "

bio with kez:z1:def in it

" }); let cands = extract_actor_candidates(&actor); // attachments are emitted first, in order assert_eq!(cands[0], "x"); assert_eq!(cands[1], "kez:z1:abc"); assert_eq!(cands[2], "bio with kez:z1:def in it"); } #[test] fn strip_html_handles_tags_and_entities() { assert_eq!(strip_html("

hello world

"), "hello world"); assert_eq!(strip_html("a & b <c>"), "a & b "); assert_eq!(strip_html(""quoted""), r#""quoted""#); assert_eq!(strip_html("'apos'"), "'apos'"); } #[test] fn strip_html_preserves_compact_kez_prefix() { let html = "

my proof: kez:z1:KLUv_QBYabc

"; assert_eq!(strip_html(html), "my proof: kez:z1:KLUv_QBYabc"); } #[test] fn strip_html_preserves_markdown_fence_chars() { let html = "

```kez\n{...}\n```

"; assert_eq!(strip_html(html), "```kez\n{...}\n```"); } }