diff --git a/Iceshrimp.Backend/Core/Configuration/Constants.cs b/Iceshrimp.Backend/Core/Configuration/Constants.cs index db847d2d..f2d939f7 100644 --- a/Iceshrimp.Backend/Core/Configuration/Constants.cs +++ b/Iceshrimp.Backend/Core/Configuration/Constants.cs @@ -20,6 +20,9 @@ public static class Constants public const string MisskeyNs = "https://misskey-hub.net/ns"; public static readonly string[] SystemUsers = ["instance.actor", "relay.actor"]; + public const string ASMime = "application/activity+json"; + public const string ASMimeAlt = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""; + public static readonly string[] BrowserSafeMimeTypes = [ "image/png", diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/UserResolver.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/UserResolver.cs index d1cfea2d..e0dff3eb 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/UserResolver.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/UserResolver.cs @@ -30,16 +30,26 @@ public class UserResolver( * The full web finger algorithm: * * 1. WebFinger(input_uri), find the rel=self type=application/activity+json entry, that's ap_uri + * 1.1 Failing this, fetch the actor + * 1.1.1 If the actor uri differs from the query, recurse (once!) with the new uri + * 1.1.2 Otherwise, Perform reverse discovery for the actor uri * 2. WebFinger(ap_uri), find the first acct: URI in [subject] + aliases, that's candidate_acct_uri + * 2.1 Failing this, perform reverse discovery for ap_uri * 3. WebFinger(candidate_acct_uri), validate it also points to ap_uri. If so, you have acct_uri - * 4. Failing this, acct_uri = "acct:" + preferredUsername from AP actor + "@" + hostname from ap_uri + * 3.1 Failing this, acct_uri = "acct:" + preferredUsername from AP actor + "@" + hostname from ap_uri * - * Avoid repeating WebFinger's with same URI for performance, optimize away validation checks when the domain matches + * Avoid repeating WebFinger's with same URI for performance, optimize away validation checks when the domain matches. + * Skip step 2 when performing reverse discovery & ap_uri matches the actor uri. */ - private async Task<(string Acct, string Uri)> WebFingerAsync(string query, bool recurse = true) + private async Task<(string Acct, string Uri)> WebFingerAsync( + string query, bool recurse = true, string? actorUri = null + ) { - logger.LogDebug("Running WebFinger for query '{query}'", query); + if (actorUri == null) + logger.LogDebug("Running WebFinger for query '{query}'", query); + else + logger.LogDebug("Running WebFinger reverse discovery for query '{query}' and uri '{uri}'", query, actorUri); var responses = new Dictionary(); var fingerRes = await webFingerSvc.ResolveAsync(query); @@ -56,6 +66,12 @@ public class UserResolver( logger.LogDebug("Actor ID differs from query, retrying..."); return await WebFingerAsync(actor.Id, false); } + + logger.LogDebug("Actor ID matches query, performing reverse discovery..."); + actor.Normalize(query); + var domain = new Uri(actor.Id).Host; + var username = actor.Username!; + return await WebFingerAsync($"acct:{username}@{domain}", false, actor.Id); } catch (Exception e) { @@ -68,7 +84,10 @@ public class UserResolver( responses.Add(query, fingerRes); - var apUri = fingerRes.Links.FirstOrDefault(p => p is { Rel: "self", Type: "application/activity+json" })?.Href; + var apUri = fingerRes.Links + .FirstOrDefault(p => p is { Rel: "self", Type: Constants.ASMime or Constants.ASMimeAlt }) + ?.Href; + if (apUri == null) throw new GracefulException($"WebFinger response for '{query}' didn't contain a candidate link"); var subjectUri = GetAcctUri(fingerRes) ?? @@ -80,12 +99,39 @@ public class UserResolver( if (subjectHost == apUriHost && subjectHost == queryHost) return (subjectUri, apUri); - fingerRes = responses.GetValueOrDefault(apUri); - if (fingerRes == null) + // We need to skip this step when performing reverse discovery & the uris match + if (apUri != actorUri) { - logger.LogDebug("AP uri didn't match query, re-running WebFinger for '{apUri}'", apUri); - fingerRes = await webFingerSvc.ResolveAsync(apUri) ?? throw new Exception($"Failed to WebFinger '{apUri}'"); - responses.Add(apUri, fingerRes); + if (actorUri != null) throw new Exception("Reverse discovery failed: uri mismatch"); + fingerRes = responses.GetValueOrDefault(apUri); + if (fingerRes == null) + { + logger.LogDebug("AP uri didn't match query, re-running WebFinger for '{apUri}'", apUri); + fingerRes = await webFingerSvc.ResolveAsync(apUri); + if (fingerRes == null) + { + logger.LogDebug("Failed to validate apUri, falling back to reverse discovery"); + try + { + var actor = await fetchSvc.FetchActorAsync(apUri); + if (apUri != actor.Id) + throw new Exception("Reverse discovery fallback failed: uri mismatch"); + + logger.LogDebug("Actor ID matches apUri, performing reverse discovery..."); + actor.Normalize(apUri); + var domain = new Uri(actor.Id).Host; + var username = new Uri(actor.Username!).Host; + return await WebFingerAsync($"acct:{username}@{domain}", false, apUri); + } + catch (Exception e) + { + logger.LogDebug("Failed to fetch actor {uri}: {e}", query, e.Message); + throw new GracefulException($"Failed to WebFinger '{query}'"); + } + } + + responses.Add(apUri, fingerRes); + } } var acctUri = GetAcctUri(fingerRes) ?? diff --git a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASActor.cs b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASActor.cs index 72261e5c..fff14128 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASActor.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASActor.cs @@ -128,7 +128,7 @@ public class ASActor : ASObject [JI] public bool IsBot => Type == $"{Constants.ActivityStreamsNs}#Service"; - public void Normalize(string uri, string acct) + public void Normalize(string uri) { if (Type == null || !ActorTypes.Contains(Type)) throw new Exception("Actor is of invalid type"); diff --git a/Iceshrimp.Backend/Core/Services/UserService.cs b/Iceshrimp.Backend/Core/Services/UserService.cs index f36e7fae..63a71c05 100644 --- a/Iceshrimp.Backend/Core/Services/UserService.cs +++ b/Iceshrimp.Backend/Core/Services/UserService.cs @@ -116,7 +116,7 @@ public class UserService( var actor = await fetchSvc.FetchActorAsync(uri); logger.LogDebug("Got actor: {url}", actor.Url); - actor.Normalize(uri, acct); + actor.Normalize(uri); if (actor.Id != uri) throw GracefulException.UnprocessableEntity("Uri doesn't match id of fetched actor"); @@ -257,7 +257,7 @@ public class UserService( logger.LogDebug("Updating user with uri {uri}", uri); actor ??= await fetchSvc.FetchActorAsync(user.Uri); - actor.Normalize(uri, user.AcctWithPrefix); + actor.Normalize(uri); user.UserProfile ??= await db.UserProfiles.FirstOrDefaultAsync(p => p.User == user); user.UserProfile ??= new UserProfile { User = user };