Implemented WebFinger flow and fixed incoming WebFinger
ci/woodpecker/push/ociImagePush Pipeline is running Details

This commit is contained in:
Natty 2024-04-01 01:33:58 +02:00
parent ffed556107
commit 0dff0c0785
Signed by: natty
GPG Key ID: BF6CB659ADEE60EC
14 changed files with 560 additions and 199 deletions

4
Cargo.lock generated
View File

@ -1679,6 +1679,7 @@ dependencies = [
"tokio", "tokio",
"tokio-util", "tokio-util",
"tracing", "tracing",
"url",
] ]
[[package]] [[package]]
@ -1708,6 +1709,7 @@ name = "magnetar_federation"
version = "0.3.0-alpha" version = "0.3.0-alpha"
dependencies = [ dependencies = [
"async-stream", "async-stream",
"async-trait",
"chrono", "chrono",
"futures", "futures",
"futures-core", "futures-core",
@ -1727,6 +1729,8 @@ dependencies = [
"serde_json", "serde_json",
"thiserror", "thiserror",
"tokio", "tokio",
"tracing",
"tracing-subscriber",
"url", "url",
] ]

View File

@ -1,5 +1,8 @@
use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
use std::borrow::{Borrow, Cow}; use std::{
borrow::{Borrow, Cow},
fmt::Display,
};
#[derive(Debug, Clone, Eq, PartialEq)] #[derive(Debug, Clone, Eq, PartialEq)]
pub struct Acct(String); pub struct Acct(String);
@ -23,6 +26,12 @@ impl AsRef<str> 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 { impl Serialize for Acct {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where where

View File

@ -8,10 +8,12 @@ pub mod acct;
pub trait ContentType: Serialize { pub trait ContentType: Serialize {
fn mime_type(&self) -> &'static str; fn mime_type(&self) -> &'static str;
fn alt_mime_types(&self) -> &[&'static str];
} }
macro_rules! content_type { 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)] #[derive(Copy, Clone, Eq, PartialEq, Debug, Default)]
$visib struct $typ; $visib struct $typ;
@ -31,13 +33,17 @@ macro_rules! content_type {
fn mime_type(&self) -> &'static str { fn mime_type(&self) -> &'static str {
$expression $expression
} }
fn alt_mime_types(&self) -> &[&'static str] {
&[$($alt),*]
}
} }
impl<'de> Deserialize<'de> for $typ { impl<'de> Deserialize<'de> for $typ {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let content_type = String::deserialize(deserializer)?; let content_type = String::deserialize(deserializer)?;
if matches!(content_type.as_ref(), $expression) { if matches!(content_type.as_ref(), $expression $(| $alt)*) {
Ok(Self) Ok(Self)
} else { } else {
Err(Error::custom(format!( Err(Error::custom(format!(
@ -54,7 +60,8 @@ pub mod content_type {
use serde::de::Error; use serde::de::Error;
use serde::{Deserialize, Deserializer, Serialize, Serializer}; 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 ContentHtml, "text/html");
content_type!(pub ContentJson, "application/json"); content_type!(pub ContentJson, "application/json");
content_type!(pub ContentXrdXml, "application/xrd+xml"); content_type!(pub ContentXrdXml, "application/xrd+xml");

View File

@ -13,16 +13,21 @@ ext_calckey_model_migration = { path = "./migration" }
magnetar_common = { path = "../magnetar_common" } magnetar_common = { path = "../magnetar_common" }
magnetar_sdk = { path = "../magnetar_sdk" } magnetar_sdk = { path = "../magnetar_sdk" }
dotenvy = { workspace = true} dotenvy = { workspace = true }
futures-core = { workspace = true } futures-core = { workspace = true }
futures-util = { workspace = true } futures-util = { workspace = true }
tokio = { workspace = true, features = ["full"] } tokio = { workspace = true, features = ["full"] }
tokio-util = { workspace = true} tokio-util = { workspace = true }
redis = { workspace = true, features = ["tokio-comp", "json", "serde_json"]} redis = { workspace = true, features = ["tokio-comp", "json", "serde_json"] }
sea-orm = { workspace = true, features = ["sqlx-postgres", "runtime-tokio-rustls", "macros"] } sea-orm = { workspace = true, features = [
"sqlx-postgres",
"runtime-tokio-rustls",
"macros",
] }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
strum = { workspace = true } strum = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
url = { workspace = true }

View File

@ -8,6 +8,7 @@ pub mod user_model;
pub use ck; pub use ck;
use ck::*; use ck::*;
pub use sea_orm; pub use sea_orm;
use url::Host;
use user_model::UserResolver; use user_model::UserResolver;
use crate::model_ext::IdShape; use crate::model_ext::IdShape;
@ -73,10 +74,10 @@ impl CalckeyModel {
pub async fn get_user_by_tag( pub async fn get_user_by_tag(
&self, &self,
name: &str, name: &str,
instance: Option<&str>, instance: Option<&Host>,
) -> Result<Option<user::Model>, CalckeyDbError> { ) -> Result<Option<user::Model>, CalckeyDbError> {
let name = name.to_lowercase(); 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 { let user = if let Some(instance) = instance {
user::Entity::find() user::Entity::find()

View File

@ -13,6 +13,7 @@ magnetar_common = { path = "../magnetar_common" }
magnetar_host_meta = { path = "../ext_host_meta" } magnetar_host_meta = { path = "../ext_host_meta" }
magnetar_webfinger = { path = "../ext_webfinger" } magnetar_webfinger = { path = "../ext_webfinger" }
async-trait = { workspace = true }
async-stream = { workspace = true } async-stream = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
futures-core = { workspace = true } futures-core = { workspace = true }
@ -33,6 +34,8 @@ percent-encoding = { workspace = true }
reqwest = { workspace = true, features = ["stream"] } reqwest = { workspace = true, features = ["stream"] }
tokio = { workspace = true, features = ["full"] } tokio = { workspace = true, features = ["full"] }
tracing = { workspace = true }
[dev-dependencies] [dev-dependencies]
tracing-subscriber = { workspace = true }
miette = { workspace = true, features = ["fancy"] } miette = { workspace = true, features = ["fancy"] }

View File

@ -1,56 +1,43 @@
use std::{io::Cursor, sync::Arc}; use magnetar_common::util::{FediverseTag, ValidName};
use magnetar_host_meta::Xrd;
use client::federation_client::{FederationClient, FederationClientError};
use magnetar_common::{config::MagnetarNetworkingProtocol, util::FediverseTag};
use magnetar_host_meta::{Xrd, XrdXml};
use magnetar_webfinger::webfinger::WebFinger; use magnetar_webfinger::webfinger::WebFinger;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use url::Url; use url::{Host, Url};
pub mod client; pub mod client;
pub mod lookup_flow;
/// The *visible* domain of fediverse handles, that gets resolved by WebFinger /// The *visible* domain of fediverse handles, that gets resolved by WebFinger
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)] #[serde(transparent)]
pub struct HostUnmapped(String); pub struct HostUnmapped(Host);
impl AsRef<Host> for HostUnmapped {
fn as_ref(&self) -> &Host {
&self.0
}
}
/// The real domain of fediverse handles used for federation /// The real domain of fediverse handles used for federation
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)] #[serde(transparent)]
pub struct HostMapped(String); pub struct HostMapped(Host);
pub trait HostMetaResolverService { impl AsRef<Host> for HostMapped {
fn as_ref(&self) -> &Host {
&self.0
}
}
#[async_trait::async_trait]
pub trait HostMetaResolverService: Send + Sync {
type Error; type Error;
async fn resolve(&self, host: &HostUnmapped) -> Result<Xrd, Self::Error>; async fn resolve(&self, host: &HostUnmapped) -> Result<Xrd, Self::Error>;
} }
pub struct HostMetaResolverProviderDefault { #[async_trait::async_trait]
client: Arc<FederationClient>, pub trait WebFingerResolverService: Send + Sync {
protocol: MagnetarNetworkingProtocol,
}
impl HostMetaResolverService for HostMetaResolverProviderDefault {
type Error = FederationClientError;
async fn resolve(&self, HostUnmapped(host): &HostUnmapped) -> Result<Xrd, Self::Error> {
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 {
type Error; type Error;
async fn resolve_url(&self, url: &str) -> Result<WebFinger, Self::Error>; async fn resolve_url(&self, url: &str) -> Result<WebFinger, Self::Error>;
@ -62,7 +49,7 @@ pub trait WebFingerResolverService {
) -> Result<WebFinger, Self::Error> { ) -> Result<WebFinger, Self::Error> {
self.resolve_url( self.resolve_url(
&template_url.replace( &template_url.replace(
"{}", "{uri}",
&percent_encoding::utf8_percent_encode( &percent_encoding::utf8_percent_encode(
resolved_uri, resolved_uri,
percent_encoding::NON_ALPHANUMERIC, percent_encoding::NON_ALPHANUMERIC,
@ -74,29 +61,32 @@ pub trait WebFingerResolverService {
} }
} }
pub struct WebFingerResolverProviderDefault { #[derive(Debug)]
client: Arc<FederationClient>, pub struct UnmappedUser {
pub name: ValidName,
pub host: HostUnmapped,
} }
impl WebFingerResolverService for WebFingerResolverProviderDefault { impl From<&UnmappedUser> for FediverseTag {
type Error = FederationClientError; fn from(user: &UnmappedUser) -> Self {
FediverseTag {
async fn resolve_url(&self, url: &str) -> Result<WebFinger, Self::Error> { name: user.name.clone(),
let host_meta_xml = self.client.get(Url::parse(url)?).send().await?; host: Some(user.host.as_ref().clone()),
let webfinger = serde_json::from_reader(Cursor::new(host_meta_xml))?; }
Ok(webfinger)
} }
} }
#[derive(Debug)] #[derive(Debug)]
pub struct MappedUser { pub struct MappedUser {
pub host_meta: Xrd, pub host_meta: Option<Xrd>,
pub webfinger: WebFinger, pub webfinger: WebFinger,
pub tag_mapped: FediverseTag, pub tag_mapped: FediverseTag,
pub ap_url: Url,
} }
pub trait FederationService { #[async_trait::async_trait]
pub trait FederationLookupService {
type Error; type Error;
async fn map_fedi_tag(&self, host: &HostUnmapped) -> Result<MappedUser, Self::Error>; async fn map_fedi_tag(&self, user: &UnmappedUser) -> Result<MappedUser, Self::Error>;
} }

View File

@ -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<FederationClient>,
protocol: MagnetarNetworkingProtocol,
}
#[async_trait::async_trait]
impl HostMetaResolverService for HostMetaResolverProviderDefault {
type Error = FederationClientError;
async fn resolve(&self, HostUnmapped(host): &HostUnmapped) -> Result<Xrd, Self::Error> {
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<FederationClient>,
}
#[async_trait::async_trait]
impl WebFingerResolverService for WebFingerResolverProviderDefault {
type Error = FederationClientError;
async fn resolve_url(&self, url: &str) -> Result<WebFinger, Self::Error> {
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<dyn HostMetaResolverService<Error = FederationClientError>>,
webfinger_resolver: Arc<dyn WebFingerResolverService<Error = FederationClientError>>,
protocol: MagnetarNetworkingProtocol,
}
impl FederationLookupServiceProviderDefault {
pub fn new(
host_meta_resolver: Arc<dyn HostMetaResolverService<Error = FederationClientError>>,
webfinger_resolver: Arc<dyn WebFingerResolverService<Error = FederationClientError>>,
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<MappedUser, FederationLookupErrror> {
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);
}
}

View File

@ -34,7 +34,10 @@ impl Xrd {
pub fn get_webfinger_template(&self) -> Option<&str> { pub fn get_webfinger_template(&self) -> Option<&str> {
self.links self.links
.iter() .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()) .and_then(|l| l.template.as_deref())
} }
} }

View File

@ -1,7 +1,10 @@
use magnetar_core::web_model::acct::Acct; 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 magnetar_core::web_model::rel::{RelOStatusSubscribe, RelSelf, RelWebFingerProfilePage};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct WebFinger { pub struct WebFinger {
@ -34,10 +37,18 @@ pub enum WebFingerRel {
content_type: ContentActivityStreams, content_type: ContentActivityStreams,
href: String, href: String,
}, },
// Alternative content-type for some implementations
RelSelfAlt {
rel: RelSelf,
#[serde(rename = "type")]
content_type: ContentActivityJson,
href: String,
},
RelOStatusSubscribe { RelOStatusSubscribe {
rel: RelOStatusSubscribe, rel: RelOStatusSubscribe,
template: String, template: String,
}, },
Other(Value),
} }
#[cfg(test)] #[cfg(test)]

View File

@ -14,7 +14,7 @@ pub struct MagnetarNetworking {
pub proxy_remote_files: bool, pub proxy_remote_files: bool,
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug, Clone, Copy)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum MagnetarNetworkingProtocol { pub enum MagnetarNetworkingProtocol {
Http, Http,

View File

@ -2,21 +2,166 @@ use magnetar_core::web_model::acct::Acct;
use magnetar_sdk::mmm; use magnetar_sdk::mmm;
use magnetar_sdk::mmm::Token; use magnetar_sdk::mmm::Token;
use percent_encoding::percent_decode_str; use percent_encoding::percent_decode_str;
use std::borrow::Cow; use std::fmt::Display;
use std::str::FromStr;
use thiserror::Error; 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<Self, Self::Err> {
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<str> 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)] #[derive(Clone, Debug)]
pub struct FediverseTag { pub struct FediverseTag {
pub name: String, pub name: ValidName,
pub host: Option<Host>,
}
impl FediverseTag {
pub fn from_parts(parts: (&str, Option<&str>)) -> Result<Self, FediverseTagParseError> {
Self::try_from(FediverseTagDisplay::from_parts(parts)?)
}
}
impl TryFrom<FediverseTagDisplay> for FediverseTag {
type Error = FediverseTagParseError;
fn try_from(
FediverseTagDisplay { name, host }: FediverseTagDisplay,
) -> Result<Self, FediverseTagParseError> {
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<Self, FediverseTagParseError> {
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, Self::Err> {
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<String>, pub host: Option<String>,
} }
impl FediverseTagDisplay {
pub fn from_parts((name, host): (&str, Option<&str>)) -> Result<Self, FediverseTagParseError> {
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<Self, Self::Error> {
acct.as_ref().parse()
}
}
impl FromStr for FediverseTagDisplay {
type Err = FediverseTagParseError;
fn from_str(tag: &str) -> Result<Self, Self::Err> {
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)] #[derive(Debug, Error)]
pub enum FediverseTagParseError { pub enum FediverseTagParseError {
#[error("Invalid char in tag: {0}")] #[error("Invalid char in tag: {0}")]
InvalidChar(String), InvalidChar(String),
#[error("Invalid UTF-8 in tag: {0}")] #[error("Invalid UTF-8 in tag: {0}")]
InvalidUtf8(#[from] std::str::Utf8Error), InvalidUtf8(#[from] std::str::Utf8Error),
#[error("Invalid host in tag: {0}")]
InvalidHost(#[from] url::ParseError),
} }
impl From<&FediverseTagParseError> for &str { impl From<&FediverseTagParseError> for &str {
@ -25,94 +170,30 @@ impl From<&FediverseTagParseError> for &str {
} }
} }
impl<S1: AsRef<str>, S2: AsRef<str>> From<(S1, Option<S2>)> for FediverseTag {
fn from((name, host): (S1, Option<S2>)) -> Self {
Self {
name: name.as_ref().to_owned(),
host: host.as_ref().map(S2::as_ref).map(str::to_owned),
}
}
}
impl From<FediverseTag> for Acct { impl From<FediverseTag> for Acct {
fn from(value: FediverseTag) -> Self { fn from(value: FediverseTag) -> Self {
value.to_string().into() value.to_string().into()
} }
} }
impl ToString for FediverseTag { fn split_tag_inner(tag: &str) -> (&str, Option<&str>) {
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<FediverseTag, FediverseTagParseError> {
lenient_parse_tag(acct.as_ref())
}
fn split_tag_inner(tag: impl AsRef<str>) -> (String, Option<String>) {
let tag = tag.as_ref();
let tag = tag.strip_prefix('@').unwrap_or(tag.as_ref()); let tag = tag.strip_prefix('@').unwrap_or(tag.as_ref());
match tag.split_once('@') { match tag.split_once('@') {
Some((name, "")) => (name.to_owned(), None), Some((name, "")) => (name, None),
Some((name, host)) => (name.to_owned(), Some(host.to_owned())), Some((name, host)) => (name, Some(host)),
None => (tag.to_owned(), None), 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<str>) -> Result<FediverseTag, FediverseTagParseError> {
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<FediverseTag, FediverseTagParseError> {
lenient_parse_tag_decode(acct.as_ref())
}
pub fn lenient_parse_tag_decode( pub fn lenient_parse_tag_decode(
tag: impl AsRef<str>, tag: impl AsRef<str>,
) -> Result<FediverseTag, FediverseTagParseError> { ) -> Result<FediverseTag, FediverseTagParseError> {
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()?; pub fn lenient_parse_acct_decode(acct: &Acct) -> Result<FediverseTag, FediverseTagParseError> {
let host_decoded = host lenient_parse_tag_decode(acct.as_ref())
.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,
})
} }
#[derive(Debug)] #[derive(Debug)]

View File

@ -79,14 +79,14 @@ pub async fn handle_user_info_by_acct(
MaybeUser(self_user): MaybeUser, MaybeUser(self_user): MaybeUser,
) -> Result<Json<Res<GetUserByAcct>>, ApiError> { ) -> Result<Json<Res<GetUserByAcct>>, ApiError> {
let mut tag = lenient_parse_tag_decode(&tag_str)?; 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; tag.host = None;
} }
let ctx = PackingContext::new(service.clone(), self_user).await?; let ctx = PackingContext::new(service.clone(), self_user).await?;
let user_model = service let user_model = service
.db .db
.get_user_by_tag(&tag.name, tag.host.as_deref()) .get_user_by_tag(tag.name.as_ref(), tag.host.as_ref())
.await? .await?
.ok_or(ObjectNotFound(tag_str))?; .ok_or(ObjectNotFound(tag_str))?;

View File

@ -7,11 +7,14 @@ use magnetar_calckey_model::CalckeyModel;
use magnetar_common::config::MagnetarConfig; use magnetar_common::config::MagnetarConfig;
use magnetar_common::util::{lenient_parse_acct_decode, FediverseTag}; use magnetar_common::util::{lenient_parse_acct_decode, FediverseTag};
use magnetar_core::web_model::acct::Acct; 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_core::web_model::rel::{RelOStatusSubscribe, RelSelf, RelWebFingerProfilePage};
use magnetar_webfinger::webfinger::{WebFinger, WebFingerRel, WebFingerSubject}; use magnetar_webfinger::webfinger::{WebFinger, WebFingerRel, WebFingerSubject};
use serde::Deserialize; use serde::Deserialize;
use tracing::error; use tracing::error;
use url::Url;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct WebFingerQuery { pub struct WebFingerQuery {
@ -19,11 +22,13 @@ pub struct WebFingerQuery {
rel: Option<Vec<String>>, rel: Option<Vec<String>>,
} }
// 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 // TODO: Filter by rel
pub async fn handle_webfinger( pub async fn handle_webfinger(
Query(WebFingerQuery { Query(WebFingerQuery { resource, .. }): Query<WebFingerQuery>,
resource, rel: _, ..
}): Query<WebFingerQuery>,
State((config, ck)): State<(&'static MagnetarConfig, CalckeyModel)>, State((config, ck)): State<(&'static MagnetarConfig, CalckeyModel)>,
) -> Result<impl IntoResponse, StatusCode> { ) -> Result<impl IntoResponse, StatusCode> {
let resource = match resource { let resource = match resource {
@ -42,79 +47,104 @@ pub async fn handle_webfinger(
StatusCode::UNPROCESSABLE_ENTITY StatusCode::UNPROCESSABLE_ENTITY
})?; })?;
ck.get_user_by_tag( ck.get_user_by_tag(tag.name.as_ref(), tag.host.as_ref())
&tag.name, .await
tag.host .map_err(|e| {
.filter(|host| *host != config.networking.host) error!("Data error: {e}");
.as_deref(), StatusCode::INTERNAL_SERVER_ERROR
) })?
.await }
.map_err(|e| { WebFingerSubject::Url(url) => {
error!("Data error: {e}"); let object_url = url.parse::<Url>().map_err(|e| {
StatusCode::INTERNAL_SERVER_ERROR 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); return Err(StatusCode::NOT_FOUND);
} };
let user = user.unwrap(); let tag = FediverseTag::from_parts((
let tag = FediverseTag::from((
&user.username, &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 links = Vec::new();
let mut aliases = Vec::new(); let mut aliases = Vec::new();
match tag.host { if user.host.is_some() {
Some(ref host) if host != &config.networking.host => { if let Some(uri) = user.uri {
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));
links.push(WebFingerRel::RelSelf { links.push(WebFingerRel::RelSelf {
rel: RelSelf, rel: RelSelf,
content_type: ContentActivityStreams, content_type: ContentActivityStreams,
href: format!( href: uri.clone(),
"{}://{}/users/{}", });
config.networking.protocol, config.networking.host, user.id
), 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(( Ok((