use actix::Addr; use actix_web::{ client::{self, ClientConnector}, error::ResponseError, http::Method, pred::Predicate, HttpMessage, HttpResponse, Query, Request, State, }; use failure::Fail; use futures::{future::IntoFuture, Future}; use serde_derive::{Deserialize, Serialize}; pub struct WebfingerPredicate; impl Predicate for WebfingerPredicate { fn check(&self, req: &Request, _: &S) -> bool { if let Some(val) = req.headers().get("Accept") { if let Ok(s) = val.to_str() { return s.split(",").any(|v| { let v = if let Some(index) = v.find(';') { v.split_at(index).0 } else { v }; let trimmed = v.trim(); trimmed == "application/jrd+json" || trimmed == "application/json" || trimmed == "application/*" || trimmed == "*/*" }) && req.method() == Method::GET; } } false } } #[derive(Clone, Debug, Fail)] #[fail(display = "Resource {} is invalid", _0)] pub struct InvalidResource(String); #[derive(Clone, Debug)] pub struct WebfingerResource { account: String, domain: String, } impl std::str::FromStr for WebfingerResource { type Err = InvalidResource; fn from_str(s: &str) -> Result { let trimmed = s.trim_start_matches("acct:").trim_start_matches('@'); if let Some(index) = trimmed.find('@') { let (account, domain) = trimmed.split_at(index); Ok(WebfingerResource { account: account.to_owned(), domain: domain.trim_start_matches('@').to_owned(), }) } else { Err(InvalidResource(s.to_owned())) } } } impl<'de> serde::de::Deserialize<'de> for WebfingerResource { fn deserialize(deserializer: D) -> Result where D: serde::de::Deserializer<'de>, { let s = String::deserialize(deserializer)?; s.parse::() .map_err(serde::de::Error::custom) } } #[derive(Clone, Debug, Deserialize)] pub struct WebfingerQuery { resource: WebfingerResource, } pub trait Resolver { type Error: ResponseError; fn find( account: &str, domain: &str, state: &S, ) -> Box, Error = Self::Error>>; fn endpoint( (query, state): (Query, State), ) -> Box> { let WebfingerResource { account, domain } = query.into_inner().resource; Box::new(Self::find(&account, &domain, &state).map(|w| match w { Some(w) => w.respond(), None => HttpResponse::NotFound().finish(), })) } } #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Link { pub rel: String, #[serde(skip_serializing_if = "Option::is_none")] pub href: Option, #[serde(skip_serializing_if = "Option::is_none")] pub template: Option, #[serde(rename = "type")] #[serde(skip_serializing_if = "Option::is_none")] pub kind: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Webfinger { pub aliases: Vec, pub links: Vec, pub subject: String, } impl Webfinger { pub fn new(subject: &str) -> Self { Webfinger { aliases: Vec::new(), links: Vec::new(), subject: subject.to_owned(), } } pub fn add_aliases(&mut self, aliases: &[String]) -> &mut Self { self.aliases.extend_from_slice(aliases); self } pub fn add_alias(&mut self, alias: &str) -> &mut Self { self.aliases.push(alias.to_owned()); self } pub fn aliases(&self) -> &[String] { &self.aliases } pub fn add_link(&mut self, link: Link) -> &mut Self { self.links.push(link); self } pub fn links(&self) -> &[Link] { &self.links } pub fn add_activitypub(&mut self, href: &str) -> &mut Self { self.links.push(Link { rel: "self".to_owned(), kind: Some("application/activity+json".to_owned()), href: Some(href.to_owned()), template: None, }); self } pub fn activitypub(&self) -> Option<&Link> { self.links.iter().find(|l| { l.rel == "self" && l.kind .as_ref() .map(|k| k == "application/activity+json") .unwrap_or(false) }) } pub fn add_profile(&mut self, href: &str) -> &mut Self { self.links.push(Link { rel: "http://webfinger.net/rel/profile-page".to_owned(), href: Some(href.to_owned()), kind: Some("text/html".to_owned()), template: None, }); self } pub fn profile(&self) -> Option<&Link> { self.links .iter() .find(|l| l.rel == "http://webfinger.net/rel/profile-page") } pub fn add_atom(&mut self, href: &str) -> &mut Self { self.links.push(Link { rel: "http://schemas.google.com/g/2010#updates-from".to_owned(), href: Some(href.to_owned()), kind: Some("application/atom+xml".to_owned()), template: None, }); self } pub fn atom(&self) -> Option<&Link> { self.links .iter() .find(|l| l.rel == "http://schemas.google.com/g/2010#updates-from") } pub fn set_salmon(&mut self, href: &str) -> &mut Self { self.links.push(Link { rel: "salmon".to_owned(), href: Some(href.to_owned()), kind: None, template: None, }); self } pub fn salmon(&self) -> Option<&Link> { self.links.iter().find(|l| l.rel == "salmon") } pub fn set_magic_public_key(&mut self, magic_public_key: &str) -> &mut Self { self.links.push(Link { rel: "magic-public-key".to_owned(), href: Some(format!( "data:application/magic-public-key,{}", magic_public_key )), kind: None, template: None, }); self } pub fn magic_public_key(&self) -> Option<&Link> { self.links.iter().find(|l| l.rel == "magic-public-key") } pub fn set_ostatus(&mut self, template: &str) -> &mut Self { self.links.push(Link { rel: "http://ostatus.org/schema/1.0/subscribe".to_owned(), href: None, kind: None, template: Some(template.to_owned()), }); self } pub fn ostatus(&self) -> Option<&Link> { self.links .iter() .find(|l| l.rel == "http://ostatus.org/schema/1.0/subscribe") } pub fn respond(self) -> HttpResponse { HttpResponse::Ok() .content_type("application/jrd+json") .json(self) } pub fn fetch( conn: Addr, user: &str, domain: &str, https: bool, ) -> Box> { let url = format!( "{}://{}/.well-known/webfinger?resource=acct:{}", if https { "https" } else { "http" }, domain, user ); let fut = client::get(url) .with_connector(conn) .header("Accept", "application/jrd+json") .finish() .into_future() .and_then(|r| { r.send() .from_err() .and_then(|res| res.json::().from_err()) }); Box::new(fut) } } #[cfg(test)] mod tests { use crate::{Webfinger, WebfingerQuery}; const SIR_BOOPS: &'static str = r#"{"subject":"acct:Sir_Boops@sergal.org","aliases":["https://mastodon.sergal.org/@Sir_Boops","https://mastodon.sergal.org/users/Sir_Boops"],"links":[{"rel":"http://webfinger.net/rel/profile-page","type":"text/html","href":"https://mastodon.sergal.org/@Sir_Boops"},{"rel":"http://schemas.google.com/g/2010#updates-from","type":"application/atom+xml","href":"https://mastodon.sergal.org/users/Sir_Boops.atom"},{"rel":"self","type":"application/activity+json","href":"https://mastodon.sergal.org/users/Sir_Boops"},{"rel":"salmon","href":"https://mastodon.sergal.org/api/salmon/1"},{"rel":"magic-public-key","href":"data:application/magic-public-key,RSA.vwDujxmxoYHs64MyVB3LG5ZyBxV3ufaMRBFu42bkcTpISq1WwZ-3Zb6CI8zOO-nM-Q2llrVRYjZa4ZFnOLvMTq_Kf-Zf5wy2aCRer88gX-MsJOAtItSi412y0a_rKOuFaDYLOLeTkRvmGLgZWbsrZJOp-YWb3zQ5qsIOInkc5BwI172tMsGeFtsnbNApPV4lrmtTGaJ8RiM8MR7XANBOfOHggSt1-eAIKGIsCmINEMzs1mG9D75xKtC_sM8GfbvBclQcBstGkHAEj1VHPW0ch6Bok5_QQppicyb8UA1PAA9bznSFtKlYE4xCH8rlCDSDTBRtdnBWHKcj619Ujz4Qaw==.AQAB"},{"rel":"http://ostatus.org/schema/1.0/subscribe","template":"https://mastodon.sergal.org/authorize_interaction?uri={uri}"}]}"#; const QUERIES: [&'static str; 4] = [ r#"{"resource":"acct:asonix@asonix.dog"}"#, r#"{"resource":"asonix@asonix.dog"}"#, r#"{"resource":"acct:@asonix@asonix.dog"}"#, r#"{"resource":"@asonix@asonix.dog"}"#, ]; #[test] fn can_deserialize_sir_boops() { let webfinger: Result = serde_json::from_str(SIR_BOOPS); assert!(webfinger.is_ok()); let webfinger = webfinger.unwrap(); assert!(webfinger.salmon().is_some()); assert!(webfinger.ostatus().is_some()); assert!(webfinger.activitypub().is_some()); assert!(webfinger.atom().is_some()); assert!(webfinger.magic_public_key().is_some()); assert!(webfinger.profile().is_some()); } #[test] fn can_deserialize_queries() { for resource in &QUERIES { let res: Result = serde_json::from_str(resource); assert!(res.is_ok()); let query = res.unwrap(); assert_eq!(query.resource.account, "asonix"); assert_eq!(query.resource.domain, "asonix.dog"); } } }