From ebbec76cfe05b78b21ac6b8453b584fa9f8428a0 Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Fri, 19 Apr 2024 00:55:30 +0200 Subject: [PATCH] [backend/federation] Resolve split domain user hosts exactly once (ISH-201) This is necessary, since while the current version is handling split domain instances correctly, previous versions (for users who migrated from iceshrimp-js) may have not done so. Since account domains never change, we only have to do this once. --- .../DatabaseContextModelSnapshot.cs | 6 ++ ...223753_AddUserSplitDomainResolvedColumn.cs | 36 +++++++ .../Core/Database/Tables/User.cs | 4 + .../Federation/ActivityPub/UserResolver.cs | 4 +- .../Core/Services/UserService.cs | 95 +++++++++++++++---- 5 files changed, 123 insertions(+), 22 deletions(-) create mode 100644 Iceshrimp.Backend/Core/Database/Migrations/v2024.1-beta1/20240418223753_AddUserSplitDomainResolvedColumn.cs diff --git a/Iceshrimp.Backend/Core/Database/Migrations/DatabaseContextModelSnapshot.cs b/Iceshrimp.Backend/Core/Database/Migrations/DatabaseContextModelSnapshot.cs index f216b810..ee7fb568 100644 --- a/Iceshrimp.Backend/Core/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Iceshrimp.Backend/Core/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -4028,6 +4028,12 @@ namespace Iceshrimp.Backend.Core.Database.Migrations .HasColumnName("speakAsCat") .HasComment("Whether to speak as a cat if isCat."); + b.Property("SplitDomainResolved") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("splitDomainResolved"); + b.Property>("Tags") .IsRequired() .ValueGeneratedOnAdd() diff --git a/Iceshrimp.Backend/Core/Database/Migrations/v2024.1-beta1/20240418223753_AddUserSplitDomainResolvedColumn.cs b/Iceshrimp.Backend/Core/Database/Migrations/v2024.1-beta1/20240418223753_AddUserSplitDomainResolvedColumn.cs new file mode 100644 index 00000000..fa047530 --- /dev/null +++ b/Iceshrimp.Backend/Core/Database/Migrations/v2024.1-beta1/20240418223753_AddUserSplitDomainResolvedColumn.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Infrastructure; + +#nullable disable + +namespace Iceshrimp.Backend.Core.Database.Migrations +{ + /// + [DbContext(typeof(DatabaseContext))] + [Migration("20240418223753_AddUserSplitDomainResolvedColumn")] + public partial class AddUserSplitDomainResolvedColumn : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "splitDomainResolved", + table: "user", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.Sql(""" + UPDATE "user" SET "splitDomainResolved" = true WHERE "host" IS NULL; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "splitDomainResolved", + table: "user"); + } + } +} diff --git a/Iceshrimp.Backend/Core/Database/Tables/User.cs b/Iceshrimp.Backend/Core/Database/Tables/User.cs index ad0236c9..aa88e93d 100644 --- a/Iceshrimp.Backend/Core/Database/Tables/User.cs +++ b/Iceshrimp.Backend/Core/Database/Tables/User.cs @@ -265,6 +265,9 @@ public class User : IEntity [Column("bannerBlurhash")] [StringLength(128)] public string? BannerBlurhash { get; set; } + + [Column("splitDomainResolved")] + public bool SplitDomainResolved { get; set; } [InverseProperty(nameof(AbuseUserReport.Assignee))] public virtual ICollection AbuseUserReportAssignees { get; set; } = new List(); @@ -700,6 +703,7 @@ public class UserEntityTypeConfiguration : IEntityTypeConfiguration .HasComment("The URI of the User. It will be null if the origin of the user is local."); entity.Property(e => e.Username).HasComment("The username of the User."); entity.Property(e => e.UsernameLower).HasComment("The username (lowercased) of the User."); + entity.Property(e => e.SplitDomainResolved).HasDefaultValue(false); entity.HasOne(d => d.Avatar) .WithOne(p => p.UserAvatar) diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/UserResolver.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/UserResolver.cs index 38a9ccb7..9404197d 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/UserResolver.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/UserResolver.cs @@ -84,7 +84,7 @@ public class UserResolver( return (finalAcct, finalUri); } - private static string NormalizeQuery(string query) + public static string NormalizeQuery(string query) { if (query.StartsWith("https://") || query.StartsWith("http://")) if (query.Contains('#')) @@ -93,7 +93,7 @@ public class UserResolver( return query; else if (query.StartsWith('@')) query = $"acct:{query[1..]}"; - else + else if (!query.StartsWith("acct:")) query = $"acct:{query}"; return query; diff --git a/Iceshrimp.Backend/Core/Services/UserService.cs b/Iceshrimp.Backend/Core/Services/UserService.cs index 636c5171..ad462490 100644 --- a/Iceshrimp.Backend/Core/Services/UserService.cs +++ b/Iceshrimp.Backend/Core/Services/UserService.cs @@ -8,6 +8,7 @@ using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; +using Iceshrimp.Backend.Core.Federation.WebFinger; using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion; using Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing; @@ -35,7 +36,8 @@ public class UserService( ActivityPub.MentionsResolver mentionsResolver, ActivityPub.UserRenderer userRenderer, QueueService queueSvc, - EventService eventSvc + EventService eventSvc, + WebFingerService webFingerSvc ) { private static readonly AsyncKeyedLocker KeyedLocker = new(o => @@ -139,28 +141,29 @@ public class UserService( user = new User { - Id = IdHelpers.GenerateSlowflakeId(), - CreatedAt = DateTime.UtcNow, - LastFetchedAt = followupTaskSvc.IsBackgroundWorker ? null : DateTime.UtcNow, - DisplayName = actor.DisplayName, - IsLocked = actor.IsLocked ?? false, - IsBot = actor.IsBot, - Username = actor.Username!, - UsernameLower = actor.Username!.ToLowerInvariant(), - Host = AcctToTuple(acct).Host, - MovedToUri = actor.MovedTo?.Link, - AlsoKnownAs = actor.AlsoKnownAs?.Where(p => p.Link != null).Select(p => p.Link!).ToList(), - IsExplorable = actor.IsDiscoverable ?? false, - Inbox = actor.Inbox?.Link, - SharedInbox = actor.SharedInbox?.Link ?? actor.Endpoints?.SharedInbox?.Id, - FollowersUri = actor.Followers?.Id, - Uri = actor.Id, - IsCat = actor.IsCat ?? false, - Featured = actor.Featured?.Id, + Id = IdHelpers.GenerateSlowflakeId(), + CreatedAt = DateTime.UtcNow, + LastFetchedAt = followupTaskSvc.IsBackgroundWorker ? null : DateTime.UtcNow, + DisplayName = actor.DisplayName, + IsLocked = actor.IsLocked ?? false, + IsBot = actor.IsBot, + Username = actor.Username!, + UsernameLower = actor.Username!.ToLowerInvariant(), + Host = AcctToTuple(acct).Host, + MovedToUri = actor.MovedTo?.Link, + AlsoKnownAs = actor.AlsoKnownAs?.Where(p => p.Link != null).Select(p => p.Link!).ToList(), + IsExplorable = actor.IsDiscoverable ?? false, + Inbox = actor.Inbox?.Link, + SharedInbox = actor.SharedInbox?.Link ?? actor.Endpoints?.SharedInbox?.Id, + FollowersUri = actor.Followers?.Id, + Uri = actor.Id, + IsCat = actor.IsCat ?? false, + Featured = actor.Featured?.Id, + SplitDomainResolved = true, //TODO: FollowersCount //TODO: FollowingCount Emojis = emoji.Select(p => p.Id).ToList(), - Tags = tags + Tags = tags, }; var profile = new UserProfile @@ -296,6 +299,7 @@ public class UserService( user.UserProfile.MentionsResolved = false; user.Tags = ResolveHashtags(user.UserProfile.Description, actor); + user.Host = await UpdateUserHostAsync(user); db.Update(user); await db.SaveChangesAsync(); @@ -969,4 +973,55 @@ public class UserService( await deliverSvc.DeliverToAsync(activity, blocker, blockee); } } + + private async Task UpdateUserHostAsync(User user) + { + if (user.Host == null || user.Uri == null || user.SplitDomainResolved) + return user.Host; + + var res = await webFingerSvc.ResolveAsync(user.Uri); + var match = res?.Links.FirstOrDefault(p => p is { Rel: "self", Type: "application/activity+json" })?.Href; + if (res == null || match != user.Uri) + { + logger.LogWarning("Updating split domain host failed for user {id}: uri mismatch (pass 1) - '{uri}' <> '{match}'", + user.Id, user.Uri, match); + return user.Host; + } + + var acct = ActivityPub.UserResolver.NormalizeQuery(res.Subject); + var split = acct.Split('@'); + if (split.Length != 2) + { + logger.LogWarning("Updating split domain host failed for user {id}: invalid acct - '{acct}'", + user.Id, acct); + return user.Host; + } + + if (user.Host == split[1]) + { + user.SplitDomainResolved = true; + return user.Host; + } + + logger.LogDebug("Updating split domain for user {id}: {host} -> {newHost}", user.Id, user.Host, split[1]); + + res = await webFingerSvc.ResolveAsync(acct); + match = res?.Links.FirstOrDefault(p => p is { Rel: "self", Type: "application/activity+json" })?.Href; + if (res == null || match != user.Uri) + { + logger.LogWarning("Updating split domain host failed for user {id}: uri mismatch (pass 2) - '{uri}' <> '{match}'", + user.Id, user.Uri, match); + return user.Host; + } + + if (acct != ActivityPub.UserResolver.NormalizeQuery(res.Subject)) + { + logger.LogWarning("Updating split domain host failed for user {id}: subject mismatch - '{acct}' <> '{subject}'", + user.Id, acct, res.Subject.TrimStart('@')); + return user.Host; + } + + user.SplitDomainResolved = true; + return split[1]; + } } \ No newline at end of file