From 0dff0c078585481bc2f72fd844d2901f2b25b153 Mon Sep 17 00:00:00 2001 From: Natty Date: Mon, 1 Apr 2024 01:33:58 +0200 Subject: [PATCH] Implemented WebFinger flow and fixed incoming WebFinger --- Cargo.lock | 4 + core/src/web_model/acct.rs | 11 +- core/src/web_model/mod.rs | 13 +- ext_calckey_model/Cargo.toml | 13 +- ext_calckey_model/src/lib.rs | 5 +- ext_federation/Cargo.toml | 3 + ext_federation/src/lib.rs | 86 +++++------ ext_federation/src/lookup_flow.rs | 217 ++++++++++++++++++++++++++++ ext_host_meta/src/lib.rs | 5 +- ext_webfinger/src/webfinger.rs | 13 +- magnetar_common/src/config.rs | 2 +- magnetar_common/src/util.rs | 229 ++++++++++++++++++++---------- src/api_v1/user.rs | 4 +- src/webfinger.rs | 154 ++++++++++++-------- 14 files changed, 560 insertions(+), 199 deletions(-) create mode 100644 ext_federation/src/lookup_flow.rs diff --git a/Cargo.lock b/Cargo.lock index 77b11e3..1f4cf64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1679,6 +1679,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "url", ] [[package]] @@ -1708,6 +1709,7 @@ name = "magnetar_federation" version = "0.3.0-alpha" dependencies = [ "async-stream", + "async-trait", "chrono", "futures", "futures-core", @@ -1727,6 +1729,8 @@ dependencies = [ "serde_json", "thiserror", "tokio", + "tracing", + "tracing-subscriber", "url", ] diff --git a/core/src/web_model/acct.rs b/core/src/web_model/acct.rs index 62d594b..59140b9 100644 --- a/core/src/web_model/acct.rs +++ b/core/src/web_model/acct.rs @@ -1,5 +1,8 @@ use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; -use std::borrow::{Borrow, Cow}; +use std::{ + borrow::{Borrow, Cow}, + fmt::Display, +}; #[derive(Debug, Clone, Eq, PartialEq)] pub struct Acct(String); @@ -23,6 +26,12 @@ impl AsRef for Acct { } } +impl Display for Acct { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "acct:{}", self.0) + } +} + impl Serialize for Acct { fn serialize(&self, serializer: S) -> Result where diff --git a/core/src/web_model/mod.rs b/core/src/web_model/mod.rs index e4e7c07..fe17e59 100644 --- a/core/src/web_model/mod.rs +++ b/core/src/web_model/mod.rs @@ -8,10 +8,12 @@ pub mod acct; pub trait ContentType: Serialize { fn mime_type(&self) -> &'static str; + + fn alt_mime_types(&self) -> &[&'static str]; } macro_rules! content_type { - ($visib:vis $typ:ident, $expression:expr) => { + ($visib:vis $typ:ident, $expression:expr $(,$alt:expr)*) => { #[derive(Copy, Clone, Eq, PartialEq, Debug, Default)] $visib struct $typ; @@ -31,13 +33,17 @@ macro_rules! content_type { fn mime_type(&self) -> &'static str { $expression } + + fn alt_mime_types(&self) -> &[&'static str] { + &[$($alt),*] + } } impl<'de> Deserialize<'de> for $typ { fn deserialize>(deserializer: D) -> Result { let content_type = String::deserialize(deserializer)?; - if matches!(content_type.as_ref(), $expression) { + if matches!(content_type.as_ref(), $expression $(| $alt)*) { Ok(Self) } else { Err(Error::custom(format!( @@ -54,7 +60,8 @@ pub mod content_type { use serde::de::Error; use serde::{Deserialize, Deserializer, Serialize, Serializer}; - content_type!(pub ContentActivityStreams, "application/activity+json"); + content_type!(pub ContentActivityStreams, "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"", "application/activity+json"); + content_type!(pub ContentActivityJson, "application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""); content_type!(pub ContentHtml, "text/html"); content_type!(pub ContentJson, "application/json"); content_type!(pub ContentXrdXml, "application/xrd+xml"); diff --git a/ext_calckey_model/Cargo.toml b/ext_calckey_model/Cargo.toml index cb7bbca..622db60 100644 --- a/ext_calckey_model/Cargo.toml +++ b/ext_calckey_model/Cargo.toml @@ -13,16 +13,21 @@ ext_calckey_model_migration = { path = "./migration" } magnetar_common = { path = "../magnetar_common" } magnetar_sdk = { path = "../magnetar_sdk" } -dotenvy = { workspace = true} +dotenvy = { workspace = true } futures-core = { workspace = true } futures-util = { workspace = true } tokio = { workspace = true, features = ["full"] } -tokio-util = { workspace = true} -redis = { workspace = true, features = ["tokio-comp", "json", "serde_json"]} -sea-orm = { workspace = true, features = ["sqlx-postgres", "runtime-tokio-rustls", "macros"] } +tokio-util = { workspace = true } +redis = { workspace = true, features = ["tokio-comp", "json", "serde_json"] } +sea-orm = { workspace = true, features = [ + "sqlx-postgres", + "runtime-tokio-rustls", + "macros", +] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } strum = { workspace = true } chrono = { workspace = true } tracing = { workspace = true } thiserror = { workspace = true } +url = { workspace = true } diff --git a/ext_calckey_model/src/lib.rs b/ext_calckey_model/src/lib.rs index b83083f..526fda9 100644 --- a/ext_calckey_model/src/lib.rs +++ b/ext_calckey_model/src/lib.rs @@ -8,6 +8,7 @@ pub mod user_model; pub use ck; use ck::*; pub use sea_orm; +use url::Host; use user_model::UserResolver; use crate::model_ext::IdShape; @@ -73,10 +74,10 @@ impl CalckeyModel { pub async fn get_user_by_tag( &self, name: &str, - instance: Option<&str>, + instance: Option<&Host>, ) -> Result, CalckeyDbError> { let name = name.to_lowercase(); - let instance = instance.map(str::to_lowercase); + let instance = instance.map(Host::to_string); let user = if let Some(instance) = instance { user::Entity::find() diff --git a/ext_federation/Cargo.toml b/ext_federation/Cargo.toml index a805a6b..05e5284 100644 --- a/ext_federation/Cargo.toml +++ b/ext_federation/Cargo.toml @@ -13,6 +13,7 @@ magnetar_common = { path = "../magnetar_common" } magnetar_host_meta = { path = "../ext_host_meta" } magnetar_webfinger = { path = "../ext_webfinger" } +async-trait = { workspace = true } async-stream = { workspace = true } futures = { workspace = true } futures-core = { workspace = true } @@ -33,6 +34,8 @@ percent-encoding = { workspace = true } reqwest = { workspace = true, features = ["stream"] } tokio = { workspace = true, features = ["full"] } +tracing = { workspace = true } [dev-dependencies] +tracing-subscriber = { workspace = true } miette = { workspace = true, features = ["fancy"] } diff --git a/ext_federation/src/lib.rs b/ext_federation/src/lib.rs index 9458292..61583ed 100644 --- a/ext_federation/src/lib.rs +++ b/ext_federation/src/lib.rs @@ -1,56 +1,43 @@ -use std::{io::Cursor, sync::Arc}; - -use client::federation_client::{FederationClient, FederationClientError}; -use magnetar_common::{config::MagnetarNetworkingProtocol, util::FediverseTag}; -use magnetar_host_meta::{Xrd, XrdXml}; +use magnetar_common::util::{FediverseTag, ValidName}; +use magnetar_host_meta::Xrd; use magnetar_webfinger::webfinger::WebFinger; use serde::{Deserialize, Serialize}; -use url::Url; +use url::{Host, Url}; pub mod client; +pub mod lookup_flow; /// The *visible* domain of fediverse handles, that gets resolved by WebFinger #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(transparent)] -pub struct HostUnmapped(String); +pub struct HostUnmapped(Host); + +impl AsRef for HostUnmapped { + fn as_ref(&self) -> &Host { + &self.0 + } +} /// The real domain of fediverse handles used for federation #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(transparent)] -pub struct HostMapped(String); +pub struct HostMapped(Host); -pub trait HostMetaResolverService { +impl AsRef for HostMapped { + fn as_ref(&self) -> &Host { + &self.0 + } +} + +#[async_trait::async_trait] +pub trait HostMetaResolverService: Send + Sync { type Error; async fn resolve(&self, host: &HostUnmapped) -> Result; } -pub struct HostMetaResolverProviderDefault { - client: Arc, - protocol: MagnetarNetworkingProtocol, -} - -impl HostMetaResolverService for HostMetaResolverProviderDefault { - type Error = FederationClientError; - - async fn resolve(&self, HostUnmapped(host): &HostUnmapped) -> Result { - let host_meta_xml = self - .client - .get(Url::parse(&format!( - "{}://{}/.well-known/host-meta", - self.protocol.as_ref(), - host - ))?) - .send() - .await?; - - let XrdXml::Xrd(xrd) = quick_xml::de::from_reader(Cursor::new(host_meta_xml))?; - - Ok(xrd) - } -} - -pub trait WebFingerResolverService { +#[async_trait::async_trait] +pub trait WebFingerResolverService: Send + Sync { type Error; async fn resolve_url(&self, url: &str) -> Result; @@ -62,7 +49,7 @@ pub trait WebFingerResolverService { ) -> Result { self.resolve_url( &template_url.replace( - "{}", + "{uri}", &percent_encoding::utf8_percent_encode( resolved_uri, percent_encoding::NON_ALPHANUMERIC, @@ -74,29 +61,32 @@ pub trait WebFingerResolverService { } } -pub struct WebFingerResolverProviderDefault { - client: Arc, +#[derive(Debug)] +pub struct UnmappedUser { + pub name: ValidName, + pub host: HostUnmapped, } -impl WebFingerResolverService for WebFingerResolverProviderDefault { - type Error = FederationClientError; - - async fn resolve_url(&self, url: &str) -> Result { - let host_meta_xml = self.client.get(Url::parse(url)?).send().await?; - let webfinger = serde_json::from_reader(Cursor::new(host_meta_xml))?; - Ok(webfinger) +impl From<&UnmappedUser> for FediverseTag { + fn from(user: &UnmappedUser) -> Self { + FediverseTag { + name: user.name.clone(), + host: Some(user.host.as_ref().clone()), + } } } #[derive(Debug)] pub struct MappedUser { - pub host_meta: Xrd, + pub host_meta: Option, pub webfinger: WebFinger, pub tag_mapped: FediverseTag, + pub ap_url: Url, } -pub trait FederationService { +#[async_trait::async_trait] +pub trait FederationLookupService { type Error; - async fn map_fedi_tag(&self, host: &HostUnmapped) -> Result; + async fn map_fedi_tag(&self, user: &UnmappedUser) -> Result; } diff --git a/ext_federation/src/lookup_flow.rs b/ext_federation/src/lookup_flow.rs new file mode 100644 index 0000000..4d43e2c --- /dev/null +++ b/ext_federation/src/lookup_flow.rs @@ -0,0 +1,217 @@ +use std::{io::Cursor, sync::Arc}; + +use magnetar_common::{ + config::MagnetarNetworkingProtocol, + util::{FediverseTag, FediverseTagDisplay, FediverseTagParseError}, +}; +use magnetar_core::web_model::{acct::Acct, content_type::ContentJrdJson}; +use magnetar_host_meta::{Xrd, XrdXml}; +use magnetar_webfinger::webfinger::{WebFinger, WebFingerRel, WebFingerSubject}; +use thiserror::Error; +use tracing::trace; +use url::Url; + +use crate::{ + client::federation_client::{FederationClient, FederationClientError}, + FederationLookupService, HostMetaResolverService, HostUnmapped, MappedUser, UnmappedUser, + WebFingerResolverService, +}; + +pub struct HostMetaResolverProviderDefault { + client: Arc, + protocol: MagnetarNetworkingProtocol, +} + +#[async_trait::async_trait] +impl HostMetaResolverService for HostMetaResolverProviderDefault { + type Error = FederationClientError; + + async fn resolve(&self, HostUnmapped(host): &HostUnmapped) -> Result { + let host_meta_xml = self + .client + .get(Url::parse(&format!( + "{}://{}/.well-known/host-meta", + self.protocol.as_ref(), + host + ))?) + .send() + .await?; + + let XrdXml::Xrd(xrd) = quick_xml::de::from_reader(Cursor::new(host_meta_xml))?; + + Ok(xrd) + } +} + +pub struct WebFingerResolverProviderDefault { + client: Arc, +} + +#[async_trait::async_trait] +impl WebFingerResolverService for WebFingerResolverProviderDefault { + type Error = FederationClientError; + + async fn resolve_url(&self, url: &str) -> Result { + let host_meta_xml = self + .client + .get(Url::parse(url)?) + .content_type(ContentJrdJson) + .send() + .await?; + let webfinger = serde_json::from_reader(Cursor::new(host_meta_xml))?; + Ok(webfinger) + } +} + +#[derive(Debug, Error)] +pub enum FederationLookupErrror { + #[error("Federation client error: {0}")] + FederationClientError(#[from] FederationClientError), + #[error("Fediverse tag parse error: {0}")] + FediverseTagParseError(#[from] FediverseTagParseError), + #[error("URL parse error: {0}")] + UrlParseError(#[from] url::ParseError), + #[error("Missing ActivityStreams URL in WebFinger")] + MissingApUrl, + #[error("Missing Acct URI in WebFinger")] + MissingAcctUri, +} + +#[derive(Clone)] +pub struct FederationLookupServiceProviderDefault { + host_meta_resolver: Arc>, + webfinger_resolver: Arc>, + protocol: MagnetarNetworkingProtocol, +} + +impl FederationLookupServiceProviderDefault { + pub fn new( + host_meta_resolver: Arc>, + webfinger_resolver: Arc>, + protocol: MagnetarNetworkingProtocol, + ) -> Self { + Self { + host_meta_resolver, + webfinger_resolver, + protocol, + } + } +} + +#[async_trait::async_trait] +impl FederationLookupService for FederationLookupServiceProviderDefault { + type Error = FederationLookupErrror; + + #[tracing::instrument(level = "trace", skip(self))] + async fn map_fedi_tag( + &self, + user: &UnmappedUser, + ) -> Result { + trace!("Fetching flow initiated"); + let host_meta = self.host_meta_resolver.resolve(&user.host).await; + let webfinger_template = match &host_meta { + Ok(h) => { + trace!("host-meta found: {:?}", h); + h.get_webfinger_template().map(str::to_owned) + } + Err(e) => { + trace!("host-meta fetch failed: {}", e); + None + } + } + .unwrap_or_else(|| { + Xrd::default_host_meta(self.protocol.as_ref(), &user.host.as_ref().to_string()) + .get_webfinger_template() + .expect("default WebFinger template") + .to_owned() + }); + + let webfinger = self + .webfinger_resolver + .resolve( + &webfinger_template, + Acct::from(FediverseTag::from(user)).as_ref(), + ) + .await?; + + trace!("Webfinger fetched: {:?}", webfinger); + + let real_tag = match &webfinger.subject { + WebFingerSubject::Acct(acct) => Some(acct.clone()), + _ => webfinger + .aliases + .iter() + .flatten() + .find_map(|alias| match alias { + WebFingerSubject::Acct(acct) => Some(acct.clone()), + _ => None, + }), + } + .ok_or(FederationLookupErrror::MissingAcctUri)?; + + let ap_url = webfinger + .links + .iter() + .find_map(|link| match link { + WebFingerRel::RelSelf { href, .. } | WebFingerRel::RelSelfAlt { href, .. } => { + Some(href) + } + _ => None, + }) + .ok_or(FederationLookupErrror::MissingApUrl)? + .parse()?; + + Ok(MappedUser { + host_meta: host_meta.ok(), + webfinger, + tag_mapped: FediverseTag::try_from(&FediverseTagDisplay::try_from(&real_tag)?)?, + ap_url, + }) + } +} + +#[cfg(test)] +mod test { + use std::sync::Arc; + + use magnetar_common::{config::MagnetarNetworkingProtocol, util::FediverseTag}; + use tracing::{info, Level}; + + use crate::{ + client::federation_client::FederationClient, FederationLookupService, HostUnmapped, + UnmappedUser, + }; + + use super::{ + FederationLookupServiceProviderDefault, HostMetaResolverProviderDefault, + WebFingerResolverProviderDefault, + }; + + #[tokio::test] + async fn should_resolve() { + tracing_subscriber::fmt() + .with_max_level(Level::TRACE) + .init(); + + let client = Arc::new(FederationClient::new(true, 64000, 20).unwrap()); + + let federation_lookup = FederationLookupServiceProviderDefault::new( + Arc::new(HostMetaResolverProviderDefault { + protocol: MagnetarNetworkingProtocol::Https, + client: client.clone(), + }), + Arc::new(WebFingerResolverProviderDefault { client }), + MagnetarNetworkingProtocol::Https, + ); + let tag: FediverseTag = "@natty@astolfo.social".parse().unwrap(); + info!("Resolving: {}", tag); + let resolved = federation_lookup + .map_fedi_tag(&UnmappedUser { + name: tag.name, + host: HostUnmapped(tag.host.unwrap()), + }) + .await + .unwrap(); + info!("Resolved: {:#?}", resolved); + } +} diff --git a/ext_host_meta/src/lib.rs b/ext_host_meta/src/lib.rs index d433ea0..235516a 100644 --- a/ext_host_meta/src/lib.rs +++ b/ext_host_meta/src/lib.rs @@ -34,7 +34,10 @@ impl Xrd { pub fn get_webfinger_template(&self) -> Option<&str> { self.links .iter() - .find(|l| l.rel.as_deref() == Some(ContentXrdXml.as_ref())) + .find(|l| { + l.r#type.as_deref() == Some(ContentXrdXml.as_ref()) + && l.rel.as_deref() == Some(RelLrdd.as_ref()) + }) .and_then(|l| l.template.as_deref()) } } diff --git a/ext_webfinger/src/webfinger.rs b/ext_webfinger/src/webfinger.rs index 4d44157..6f42f78 100644 --- a/ext_webfinger/src/webfinger.rs +++ b/ext_webfinger/src/webfinger.rs @@ -1,7 +1,10 @@ use magnetar_core::web_model::acct::Acct; -use magnetar_core::web_model::content_type::{ContentActivityStreams, ContentHtml}; +use magnetar_core::web_model::content_type::{ + ContentActivityJson, ContentActivityStreams, ContentHtml, +}; use magnetar_core::web_model::rel::{RelOStatusSubscribe, RelSelf, RelWebFingerProfilePage}; use serde::{Deserialize, Serialize}; +use serde_json::Value; #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct WebFinger { @@ -34,10 +37,18 @@ pub enum WebFingerRel { content_type: ContentActivityStreams, href: String, }, + // Alternative content-type for some implementations + RelSelfAlt { + rel: RelSelf, + #[serde(rename = "type")] + content_type: ContentActivityJson, + href: String, + }, RelOStatusSubscribe { rel: RelOStatusSubscribe, template: String, }, + Other(Value), } #[cfg(test)] diff --git a/magnetar_common/src/config.rs b/magnetar_common/src/config.rs index 77b5c09..02ec918 100644 --- a/magnetar_common/src/config.rs +++ b/magnetar_common/src/config.rs @@ -14,7 +14,7 @@ pub struct MagnetarNetworking { pub proxy_remote_files: bool, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Debug, Clone, Copy)] #[serde(rename_all = "snake_case")] pub enum MagnetarNetworkingProtocol { Http, diff --git a/magnetar_common/src/util.rs b/magnetar_common/src/util.rs index 45b32d6..fba64fa 100644 --- a/magnetar_common/src/util.rs +++ b/magnetar_common/src/util.rs @@ -2,21 +2,166 @@ use magnetar_core::web_model::acct::Acct; use magnetar_sdk::mmm; use magnetar_sdk::mmm::Token; use percent_encoding::percent_decode_str; -use std::borrow::Cow; +use std::fmt::Display; +use std::str::FromStr; use thiserror::Error; +use url::Host; + +#[derive(Clone, Debug)] +pub struct ValidName(String); + +impl FromStr for ValidName { + type Err = FediverseTagParseError; + + fn from_str(name: &str) -> Result { + if name + .chars() + .any(|c| !c.is_alphanumeric() && c != '-' && c != '_' && c != '.') + { + return Err(FediverseTagParseError::InvalidChar(name.to_owned())); + } + + Ok(Self(name.to_owned())) + } +} + +impl AsRef for ValidName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Display for ValidName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} #[derive(Clone, Debug)] pub struct FediverseTag { - pub name: String, + pub name: ValidName, + pub host: Option, +} + +impl FediverseTag { + pub fn from_parts(parts: (&str, Option<&str>)) -> Result { + Self::try_from(FediverseTagDisplay::from_parts(parts)?) + } +} + +impl TryFrom for FediverseTag { + type Error = FediverseTagParseError; + + fn try_from( + FediverseTagDisplay { name, host }: FediverseTagDisplay, + ) -> Result { + Ok(FediverseTag { + name, + host: host.as_deref().map(Host::parse).transpose()?, + }) + } +} + +impl TryFrom<&FediverseTagDisplay> for FediverseTag { + type Error = FediverseTagParseError; + + fn try_from( + FediverseTagDisplay { name, host }: &FediverseTagDisplay, + ) -> Result { + Ok(FediverseTag { + name: name.clone(), + host: host.as_deref().map(Host::parse).transpose()?, + }) + } +} + +impl From<&FediverseTag> for Acct { + fn from(tag: &FediverseTag) -> Self { + Acct::new(tag.to_string().into()) + } +} + +impl FromStr for FediverseTag { + type Err = FediverseTagParseError; + + fn from_str(tag: &str) -> Result { + Self::try_from(FediverseTagDisplay::from_str(tag)?) + } +} + +impl Display for FediverseTag { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(ref host) = self.host { + write!(f, "{}@{}", self.name, host) + } else { + write!(f, "{}", self.name) + } + } +} + +#[derive(Clone, Debug)] +pub struct FediverseTagDisplay { + pub name: ValidName, pub host: Option, } +impl FediverseTagDisplay { + pub fn from_parts((name, host): (&str, Option<&str>)) -> Result { + Ok(FediverseTagDisplay { + name: name.parse()?, + host: host.map(str::to_owned), + }) + } +} + +impl From<&FediverseTag> for FediverseTagDisplay { + fn from(FediverseTag { name, host }: &FediverseTag) -> Self { + FediverseTagDisplay { + name: name.clone(), + host: host + .as_ref() + .map(Host::to_string) + .as_deref() + .map(idna::domain_to_unicode) + .map(|v| v.0), + } + } +} + +impl TryFrom<&Acct> for FediverseTagDisplay { + type Error = FediverseTagParseError; + + fn try_from(acct: &Acct) -> Result { + acct.as_ref().parse() + } +} + +impl FromStr for FediverseTagDisplay { + type Err = FediverseTagParseError; + + fn from_str(tag: &str) -> Result { + Self::from_parts(split_tag_inner(tag)) + } +} + +impl Display for FediverseTagDisplay { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(ref host) = self.host { + write!(f, "{}@{}", self.name, host) + } else { + write!(f, "{}", self.name) + } + } +} + #[derive(Debug, Error)] pub enum FediverseTagParseError { #[error("Invalid char in tag: {0}")] InvalidChar(String), #[error("Invalid UTF-8 in tag: {0}")] InvalidUtf8(#[from] std::str::Utf8Error), + #[error("Invalid host in tag: {0}")] + InvalidHost(#[from] url::ParseError), } impl From<&FediverseTagParseError> for &str { @@ -25,94 +170,30 @@ impl From<&FediverseTagParseError> for &str { } } -impl, S2: AsRef> From<(S1, Option)> for FediverseTag { - fn from((name, host): (S1, Option)) -> Self { - Self { - name: name.as_ref().to_owned(), - host: host.as_ref().map(S2::as_ref).map(str::to_owned), - } - } -} - impl From for Acct { fn from(value: FediverseTag) -> Self { value.to_string().into() } } -impl ToString for FediverseTag { - fn to_string(&self) -> String { - if let Some(ref host) = self.host { - format!("{}@{host}", self.name) - } else { - self.name.clone() - } - } -} - -pub fn lenient_parse_acct(acct: &Acct) -> Result { - lenient_parse_tag(acct.as_ref()) -} - -fn split_tag_inner(tag: impl AsRef) -> (String, Option) { - let tag = tag.as_ref(); +fn split_tag_inner(tag: &str) -> (&str, Option<&str>) { let tag = tag.strip_prefix('@').unwrap_or(tag.as_ref()); match tag.split_once('@') { - Some((name, "")) => (name.to_owned(), None), - Some((name, host)) => (name.to_owned(), Some(host.to_owned())), - None => (tag.to_owned(), None), + Some((name, "")) => (name, None), + Some((name, host)) => (name, Some(host)), + None => (tag, None), } } -fn validate_tag_inner((name, host): (&str, Option<&str>)) -> Result<(), FediverseTagParseError> { - if name - .chars() - .any(|c| !c.is_alphanumeric() && c != '-' && c != '_' && c != '.') - { - return Err(FediverseTagParseError::InvalidChar(name.to_owned())); - } - - if let Some(host_str) = host { - if host_str - .chars() - .any(|c| c.is_control() || c.is_whitespace() || c == '/' || c == '#') - { - return Err(FediverseTagParseError::InvalidChar(name.to_owned())); - } - } - - Ok(()) -} - -pub fn lenient_parse_tag(tag: impl AsRef) -> Result { - let (name, host) = split_tag_inner(tag); - - validate_tag_inner((&name, host.as_ref().map(String::as_ref)))?; - - Ok(FediverseTag { name, host }) -} - -pub fn lenient_parse_acct_decode(acct: &Acct) -> Result { - lenient_parse_tag_decode(acct.as_ref()) -} - pub fn lenient_parse_tag_decode( tag: impl AsRef, ) -> Result { - let (name, host) = split_tag_inner(tag); + percent_decode_str(tag.as_ref()).decode_utf8()?.parse() +} - let name_decoded = percent_decode_str(&name).decode_utf8()?; - let host_decoded = host - .map(|host| percent_decode_str(&host).decode_utf8().map(Cow::into_owned)) - .transpose()?; - - validate_tag_inner((&name_decoded, host_decoded.as_deref()))?; - - Ok(FediverseTag { - name: name_decoded.into_owned(), - host: host_decoded, - }) +pub fn lenient_parse_acct_decode(acct: &Acct) -> Result { + lenient_parse_tag_decode(acct.as_ref()) } #[derive(Debug)] diff --git a/src/api_v1/user.rs b/src/api_v1/user.rs index 84b12d2..1ebe52b 100644 --- a/src/api_v1/user.rs +++ b/src/api_v1/user.rs @@ -79,14 +79,14 @@ pub async fn handle_user_info_by_acct( MaybeUser(self_user): MaybeUser, ) -> Result>, ApiError> { let mut tag = lenient_parse_tag_decode(&tag_str)?; - if matches!(&tag.host, Some(host) if host == &service.config.networking.host) { + if matches!(&tag.host, Some(host) if host.to_string() == service.config.networking.host) { tag.host = None; } let ctx = PackingContext::new(service.clone(), self_user).await?; let user_model = service .db - .get_user_by_tag(&tag.name, tag.host.as_deref()) + .get_user_by_tag(tag.name.as_ref(), tag.host.as_ref()) .await? .ok_or(ObjectNotFound(tag_str))?; diff --git a/src/webfinger.rs b/src/webfinger.rs index 2e7e460..c49a90a 100644 --- a/src/webfinger.rs +++ b/src/webfinger.rs @@ -7,11 +7,14 @@ use magnetar_calckey_model::CalckeyModel; use magnetar_common::config::MagnetarConfig; use magnetar_common::util::{lenient_parse_acct_decode, FediverseTag}; use magnetar_core::web_model::acct::Acct; -use magnetar_core::web_model::content_type::{ContentActivityStreams, ContentHtml, ContentJrdJson}; +use magnetar_core::web_model::content_type::{ + ContentActivityJson, ContentActivityStreams, ContentHtml, ContentJrdJson, +}; use magnetar_core::web_model::rel::{RelOStatusSubscribe, RelSelf, RelWebFingerProfilePage}; use magnetar_webfinger::webfinger::{WebFinger, WebFingerRel, WebFingerSubject}; use serde::Deserialize; use tracing::error; +use url::Url; #[derive(Deserialize)] pub struct WebFingerQuery { @@ -19,11 +22,13 @@ pub struct WebFingerQuery { rel: Option>, } +// TODO: We don't have this endpoint in Magnetar yet, but make sure it's not hardcoded when +// we do +const USER_AP_ENDPOINT: &str = "/users/"; + // TODO: Filter by rel pub async fn handle_webfinger( - Query(WebFingerQuery { - resource, rel: _, .. - }): Query, + Query(WebFingerQuery { resource, .. }): Query, State((config, ck)): State<(&'static MagnetarConfig, CalckeyModel)>, ) -> Result { let resource = match resource { @@ -42,79 +47,104 @@ pub async fn handle_webfinger( StatusCode::UNPROCESSABLE_ENTITY })?; - ck.get_user_by_tag( - &tag.name, - tag.host - .filter(|host| *host != config.networking.host) - .as_deref(), - ) - .await - .map_err(|e| { - error!("Data error: {e}"); - StatusCode::INTERNAL_SERVER_ERROR - })? + ck.get_user_by_tag(tag.name.as_ref(), tag.host.as_ref()) + .await + .map_err(|e| { + error!("Data error: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })? + } + WebFingerSubject::Url(url) => { + let object_url = url.parse::().map_err(|e| { + error!("URL parse error: {e}"); + StatusCode::UNPROCESSABLE_ENTITY + })?; + + // FIXME: Jank + let path = object_url.path().strip_prefix(USER_AP_ENDPOINT); + match path { + Some(user_id) if !user_id.is_empty() && user_id.chars().all(|c| c != '/') => { + ck.get_user_by_id(user_id).await.map_err(|e| { + error!("Data error: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })? + } + _ => ck.get_user_by_uri(&url).await.map_err(|e| { + error!("Data error: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?, + } } - // TODO: Make this work for local users - WebFingerSubject::Url(url) => ck.get_user_by_uri(&url).await.map_err(|e| { - error!("Data error: {e}"); - StatusCode::INTERNAL_SERVER_ERROR - })?, }; - if user.is_none() { + let Some(user) = user else { return Err(StatusCode::NOT_FOUND); - } + }; - let user = user.unwrap(); - let tag = FediverseTag::from(( + let tag = FediverseTag::from_parts(( &user.username, - user.host.as_ref().or(Some(&config.networking.host)), - )); + user.host.as_deref().or(Some(&config.networking.host)), + )) + .map_err(|e| { + error!("URL parse error: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; let mut links = Vec::new(); let mut aliases = Vec::new(); - match tag.host { - Some(ref host) if host != &config.networking.host => { - if let Some(uri) = user.uri { - links.push(WebFingerRel::RelSelf { - rel: RelSelf, - content_type: ContentActivityStreams, - href: uri, - }); - } - } - _ => { - links.push(WebFingerRel::RelOStatusSubscribe { - rel: RelOStatusSubscribe, - template: format!( - "{}://{}/authorize-follow?acct={{uri}}", - config.networking.protocol, config.networking.host - ), - }); - - let user_url = format!( - "{}://{}/@{}", - config.networking.protocol, config.networking.host, tag.name - ); - - links.push(WebFingerRel::RelWebFingerProfilePage { - rel: RelWebFingerProfilePage, - content_type: ContentHtml, - href: user_url.clone(), - }); - - aliases.push(WebFingerSubject::Url(user_url)); - + if user.host.is_some() { + if let Some(uri) = user.uri { links.push(WebFingerRel::RelSelf { rel: RelSelf, content_type: ContentActivityStreams, - href: format!( - "{}://{}/users/{}", - config.networking.protocol, config.networking.host, user.id - ), + href: uri.clone(), + }); + + links.push(WebFingerRel::RelSelfAlt { + rel: RelSelf, + content_type: ContentActivityJson, + href: uri, }); } + } else { + links.push(WebFingerRel::RelOStatusSubscribe { + rel: RelOStatusSubscribe, + template: format!( + "{}://{}/authorize-follow?acct={{uri}}", + config.networking.protocol, config.networking.host + ), + }); + + let user_url = format!( + "{}://{}/@{}", + config.networking.protocol, config.networking.host, tag.name + ); + + links.push(WebFingerRel::RelWebFingerProfilePage { + rel: RelWebFingerProfilePage, + content_type: ContentHtml, + href: user_url.clone(), + }); + + aliases.push(WebFingerSubject::Url(user_url)); + + let self_url = format!( + "{}://{}{}{}", + config.networking.protocol, config.networking.host, USER_AP_ENDPOINT, user.id + ); + + links.push(WebFingerRel::RelSelf { + rel: RelSelf, + content_type: ContentActivityStreams, + href: self_url.clone(), + }); + + links.push(WebFingerRel::RelSelfAlt { + rel: RelSelf, + content_type: ContentActivityJson, + href: self_url, + }); } Ok((