[backend/federation] Resolve user profile mentions (ISH-33)

This commit is contained in:
Laura Hausmann 2024-02-25 01:40:23 +01:00
parent 2f3ca1e477
commit 665154b1eb
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
7 changed files with 139 additions and 16 deletions

View file

@ -22,16 +22,19 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, MfmConverter
acct += $"@{user.Host}"; acct += $"@{user.Host}";
var profileEmoji = emoji?.Where(p => user.Emojis.Contains(p.Id)).ToList() ?? await GetEmoji([user]); var profileEmoji = emoji?.Where(p => user.Emojis.Contains(p.Id)).ToList() ?? await GetEmoji([user]);
var fields = profile?.Fields var mentions = profile?.Mentions ?? [];
.Select(p => new Field var fields = profile != null
{ ? await profile.Fields
Name = p.Name, .Select(async p => new Field
Value = p.Value, {
VerifiedAt = p.IsVerified.HasValue && p.IsVerified.Value Name = p.Name,
? DateTime.Now.ToStringIso8601Like() Value = await mfmConverter.ToHtmlAsync(p.Value, mentions, user.Host),
: null VerifiedAt = p.IsVerified.HasValue && p.IsVerified.Value
}) ? DateTime.Now.ToStringIso8601Like()
.ToList(); : null
})
.AwaitAllAsync()
: null;
var res = new AccountEntity var res = new AccountEntity
{ {
@ -46,7 +49,7 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, MfmConverter
FollowersCount = user.FollowersCount, FollowersCount = user.FollowersCount,
FollowingCount = user.FollowingCount, FollowingCount = user.FollowingCount,
StatusesCount = user.NotesCount, StatusesCount = user.NotesCount,
Note = await mfmConverter.ToHtmlAsync(profile?.Description ?? "", [], user.Host), Note = await mfmConverter.ToHtmlAsync(profile?.Description ?? "", mentions, user.Host),
Url = profile?.Url ?? user.Uri ?? user.GetPublicUrl(config.Value), Url = profile?.Url ?? user.Uri ?? user.GetPublicUrl(config.Value),
AvatarStaticUrl = user.AvatarUrl ?? user.GetIdenticonUrl(config.Value), //TODO AvatarStaticUrl = user.AvatarUrl ?? user.GetIdenticonUrl(config.Value), //TODO
HeaderUrl = user.BannerUrl ?? _transparent, HeaderUrl = user.BannerUrl ?? _transparent,
@ -54,7 +57,7 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, MfmConverter
MovedToAccount = null, //TODO MovedToAccount = null, //TODO
IsBot = user.IsBot, IsBot = user.IsBot,
IsDiscoverable = user.IsExplorable, IsDiscoverable = user.IsExplorable,
Fields = fields ?? [], Fields = fields?.ToList() ?? [],
Emoji = profileEmoji Emoji = profileEmoji
}; };

View file

@ -297,6 +297,6 @@ public class Note : IEntity
[J("uri")] public required string Uri { get; set; } [J("uri")] public required string Uri { get; set; }
[J("url")] public string? Url { get; set; } [J("url")] public string? Url { get; set; }
[J("username")] public required string Username { get; set; } [J("username")] public required string Username { get; set; }
[J("host")] public required string Host { get; set; } [J("host")] public required string? Host { get; set; }
} }
} }

View file

@ -47,6 +47,7 @@ public static class ServiceExtensions
.AddScoped<DriveService>() .AddScoped<DriveService>()
.AddScoped<NotificationService>() .AddScoped<NotificationService>()
.AddScoped<DatabaseMaintenanceService>() .AddScoped<DatabaseMaintenanceService>()
.AddScoped<UserProfileMentionsResolver>()
.AddScoped<AuthorizedFetchMiddleware>() .AddScoped<AuthorizedFetchMiddleware>()
.AddScoped<AuthenticationMiddleware>() .AddScoped<AuthenticationMiddleware>()
.AddScoped<ErrorHandlerMiddleware>() .AddScoped<ErrorHandlerMiddleware>()

View file

@ -6,10 +6,10 @@ namespace Iceshrimp.Backend.Core.Extensions;
public static class StringExtensions public static class StringExtensions
{ {
public static bool EqualsInvariant(this string s1, string s2) => public static bool EqualsInvariant(this string? s1, string? s2) =>
string.Equals(s1, s2, StringComparison.InvariantCulture); string.Equals(s1, s2, StringComparison.InvariantCulture);
public static bool EqualsIgnoreCase(this string s1, string s2) => public static bool EqualsIgnoreCase(this string? s1, string s2) =>
string.Equals(s1, s2, StringComparison.InvariantCultureIgnoreCase); string.Equals(s1, s2, StringComparison.InvariantCultureIgnoreCase);
public static string Truncate(this string target, int maxLength) public static string Truncate(this string target, int maxLength)

View file

@ -133,6 +133,33 @@ public class UserResolver(
} }
} }
public async Task<User?> ResolveAsyncLimited(string query, Func<bool> limitReached)
{
query = NormalizeQuery(query);
// First, let's see if we already know the user
var user = await userSvc.GetUserFromQueryAsync(query);
if (user != null)
return await GetUpdatedUser(user);
// We don't, so we need to run WebFinger
var (acct, uri) = await WebFingerAsync(query);
// Check the database again with the new data
if (uri != query) user = await userSvc.GetUserFromQueryAsync(uri);
if (user == null && acct != query) await userSvc.GetUserFromQueryAsync(acct);
if (user != null)
return await GetUpdatedUser(user);
if (limitReached()) return null;
using (await KeyedLocker.LockAsync(uri))
{
// Pass the job on to userSvc, which will create the user
return await userSvc.CreateUserAsync(uri, acct);
}
}
private async Task<User> GetUpdatedUser(User user) private async Task<User> GetUpdatedUser(User user)
{ {
if (!user.NeedsUpdate) return user; if (!user.NeedsUpdate) return user;

View file

@ -0,0 +1,70 @@
using System.Diagnostics.CodeAnalysis;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Types;
using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Core.Services;
public class UserProfileMentionsResolver(ActivityPub.UserResolver userResolver, IOptions<Config.InstanceSection> config)
{
private int _recursionLimit = 10;
public async Task<List<Note.MentionedUser>> ResolveMentions(UserProfile.Field[]? fields, string? bio)
{
if (fields is not { Length: > 0 } && bio == null) return [];
var input = (fields ?? [])
.SelectMany<UserProfile.Field, string>(p => [p.Name, p.Value])
.Prepend(bio)
.Where(p => p != null)
.Cast<string>()
.ToList();
var nodes = input.SelectMany(p => MfmParser.Parse(p));
var mentionNodes = EnumerateMentions(nodes);
var users = await mentionNodes.DistinctBy(p => p.Acct)
.Select(async p =>
{
try
{
return await userResolver.ResolveAsyncLimited(p.Acct,
() => --_recursionLimit >= 0);
}
catch
{
return null;
}
})
.AwaitAllNoConcurrencyAsync();
return users.Where(p => p != null)
.Cast<User>()
.Select(p => new Note.MentionedUser
{
Host = p.Host,
Uri = p.Uri ?? p.GetPublicUri(config.Value),
Url = p.UserProfile?.Url,
Username = p.Username
})
.ToList();
}
[SuppressMessage("ReSharper", "ReturnTypeCanBeEnumerable.Local",
Justification = "Roslyn inspection says this hurts performance")]
private static List<MfmMentionNode> EnumerateMentions(IEnumerable<MfmNode> nodes)
{
var list = new List<MfmMentionNode>();
foreach (var node in nodes)
{
if (node is MfmMentionNode mention)
list.Add(mention);
else
list.AddRange(EnumerateMentions(node.Children));
}
return list;
}
}

View file

@ -107,6 +107,8 @@ public class UserService(
.Select(p => new UserProfile.Field { Name = p.Name!, Value = p.Value! }) .Select(p => new UserProfile.Field { Name = p.Name!, Value = p.Value! })
.ToArray(); .ToArray();
var bio = actor.MkSummary ?? await mfmConverter.FromHtmlAsync(actor.Summary);
user = new User user = new User
{ {
Id = IdHelpers.GenerateSlowflakeId(), Id = IdHelpers.GenerateSlowflakeId(),
@ -136,7 +138,7 @@ public class UserService(
var profile = new UserProfile var profile = new UserProfile
{ {
User = user, User = user,
Description = actor.MkSummary ?? await mfmConverter.FromHtmlAsync(actor.Summary), Description = bio,
//Birthday = TODO, //Birthday = TODO,
//Location = TODO, //Location = TODO,
Fields = fields ?? [], Fields = fields ?? [],
@ -157,6 +159,7 @@ public class UserService(
var processPendingDeletes = await ResolveAvatarAndBanner(user, actor); var processPendingDeletes = await ResolveAvatarAndBanner(user, actor);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await processPendingDeletes(); await processPendingDeletes();
UpdateProfileMentionsInBackground(user);
return user; return user;
} }
catch (UniqueConstraintException) catch (UniqueConstraintException)
@ -256,6 +259,7 @@ public class UserService(
db.Update(user); db.Update(user);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await processPendingDeletes(); await processPendingDeletes();
UpdateProfileMentionsInBackground(user);
return user; return user;
} }
@ -586,4 +590,22 @@ public class UserService(
// Clean up user list memberships // Clean up user list memberships
await db.UserListMembers.Where(p => p.UserList.User == user && p.User == followee).ExecuteDeleteAsync(); await db.UserListMembers.Where(p => p.UserList.User == user && p.User == followee).ExecuteDeleteAsync();
} }
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataQuery", Justification = "Projectables")]
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataUsage", Justification = "Same as above")]
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameter", Justification = "Method only makes sense for users")]
private void UpdateProfileMentionsInBackground(User user)
{
_ = followupTaskSvc.ExecuteTask("UpdateProfileMentionsInBackground", async provider =>
{
var bgDbContext = provider.GetRequiredService<DatabaseContext>();
var bgMentionsResolver = provider.GetRequiredService<UserProfileMentionsResolver>();
var bgUser = await bgDbContext.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == user.Id);
if (bgUser?.UserProfile == null) return;
bgUser.UserProfile.Mentions =
await bgMentionsResolver.ResolveMentions(bgUser.UserProfile.Fields, bgUser.UserProfile.Description);
bgDbContext.Update(bgUser.UserProfile);
await bgDbContext.SaveChangesAsync();
});
}
} }