diff --git a/examples/sir_boops.rs b/examples/fetch.rs similarity index 71% rename from examples/sir_boops.rs rename to examples/fetch.rs index 5c2710c..4162000 100644 --- a/examples/sir_boops.rs +++ b/examples/fetch.rs @@ -10,11 +10,9 @@ fn main() { let ssl_conn = SslConnector::builder(SslMethod::tls()).unwrap().build(); let conn = ClientConnector::with_connector(ssl_conn).start(); - let fut = Webfinger::fetch(conn, "Sir_Boops@sergal.org", "mastodon.sergal.org") + let fut = Webfinger::fetch(conn, "asonix@asonix.dog", "localhost:8000", false) .map(move |w: Webfinger| { - if let Some(ref link) = w.activitypub() { - println!("Sir Boop's activitypub: {:#?}", link); - } + println!("asonix's webfinger:\n{:#?}", w); System::current().stop(); }) diff --git a/examples/resolver.rs b/examples/resolver.rs new file mode 100644 index 0000000..5814d20 --- /dev/null +++ b/examples/resolver.rs @@ -0,0 +1,42 @@ +use actix_web::{server, App}; +use actix_webfinger::{Resolver, Webfinger}; +use futures::{future::IntoFuture, Future}; + +#[derive(Clone, Debug)] +pub struct MyState { + domain: String, +} + +pub struct MyResolver; + +impl Resolver for MyResolver { + type Error = actix_web::error::JsonPayloadError; + + fn find( + account: &str, + domain: &str, + state: &MyState, + ) -> Box, Error = Self::Error>> { + let w = if domain == state.domain { + Some(Webfinger::new(&format!("{}@{}", account, domain))) + } else { + None + }; + + Box::new(Ok(w).into_future()) + } +} + +fn main() { + server::new(|| { + App::with_state(MyState { + domain: "asonix.dog".to_owned(), + }) + .resource("/.well-known/webfinger", |r| { + r.with(>::endpoint) + }) + }) + .bind("127.0.0.1:8000") + .unwrap() + .run(); +} diff --git a/src/lib.rs b/src/lib.rs index 9a343fc..f3b11f7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,11 +1,79 @@ use actix::Addr; use actix_web::{ client::{self, ClientConnector}, - HttpMessage, + error::ResponseError, + HttpMessage, HttpResponse, Query, State, }; +use failure::Fail; use futures::{future::IntoFuture, Future}; use serde_derive::{Deserialize, Serialize}; +#[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, @@ -27,14 +95,47 @@ pub struct Webfinger { } 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" @@ -45,39 +146,104 @@ impl Webfinger { }) } + 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, - ) -> Box> { + https: bool, + ) -> Box> { let url = format!( - "https://{}/.well-known/webfinger?resource=acct:{}", + "{}://{}/.well-known/webfinger?resource=acct:{}", + if https { + "https" + } else { + "http" + }, domain, user ); @@ -98,10 +264,17 @@ impl Webfinger { #[cfg(test)] mod tests { - use crate::Webfinger; + 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); @@ -117,4 +290,18 @@ mod tests { 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"); + } + } }