diff --git a/Iceshrimp.Backend/Core/Queues/BackgroundTaskQueue.cs b/Iceshrimp.Backend/Core/Queues/BackgroundTaskQueue.cs index 671c632e..1f836c9a 100644 --- a/Iceshrimp.Backend/Core/Queues/BackgroundTaskQueue.cs +++ b/Iceshrimp.Backend/Core/Queues/BackgroundTaskQueue.cs @@ -47,6 +47,9 @@ public class BackgroundTaskQueue(int parallelism) case UserPurgeJobData userPurgeJob: await ProcessUserPurgeAsync(userPurgeJob, scope, token); break; + case ProfileFieldUpdateJobData profileFieldUpdateJob: + await ProcessProfileFieldUpdateAsync(profileFieldUpdateJob, scope, token); + break; } } @@ -299,6 +302,46 @@ public class BackgroundTaskQueue(int parallelism) logger.LogDebug("User {id} purged successfully", jobData.UserId); } + + [SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataQuery", Justification = "Projectables")] + [SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataUsage", Justification = "Same as above")] + private static async Task ProcessProfileFieldUpdateAsync( + ProfileFieldUpdateJobData jobData, + IServiceProvider scope, + CancellationToken token + ) + { + var db = scope.GetRequiredService(); + var userSvc = scope.GetRequiredService(); + var logger = scope.GetRequiredService>(); + + logger.LogDebug("Processing profile field update for user {id}", jobData.UserId); + + var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == jobData.UserId, token); + if (user == null) + { + logger.LogDebug("Failed to update profile fields for user {id}: user not found in database", jobData.UserId); + return; + } + if (user.UserProfile == null) + { + logger.LogDebug("Failed to update profile fields for user {id}: user profile not found in database", jobData.UserId); + return; + } + + var profileUrl = user.Host != null ? user.UserProfile.Url : null; + if (user.Host != null && profileUrl == null) + { + logger.LogDebug("Failed to update profile fields for user {id}: profile URL not found in database", jobData.UserId); + return; + } + + user = await userSvc.VerifyProfileFieldsAsync(user, profileUrl); + db.Update(user.UserProfile!); + await db.SaveChangesAsync(token); + + logger.LogDebug("Profile fields for user {id} updated successfully", jobData.UserId); + } } [JsonDerivedType(typeof(DriveFileDeleteJobData), "driveFileDelete")] @@ -307,6 +350,7 @@ public class BackgroundTaskQueue(int parallelism) [JsonDerivedType(typeof(FilterExpiryJobData), "filterExpiry")] [JsonDerivedType(typeof(UserDeleteJobData), "userDelete")] [JsonDerivedType(typeof(UserPurgeJobData), "userPurge")] +[JsonDerivedType(typeof(ProfileFieldUpdateJobData), "profileFieldUpdate")] public abstract class BackgroundTaskJobData; public class DriveFileDeleteJobData : BackgroundTaskJobData @@ -336,6 +380,11 @@ public class UserDeleteJobData : BackgroundTaskJobData } public class UserPurgeJobData : BackgroundTaskJobData +{ + [JR] [J("userId")] public required string UserId { get; set; } +} + +public class ProfileFieldUpdateJobData : BackgroundTaskJobData { [JR] [J("userId")] public required string UserId { get; set; } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Services/UserService.cs b/Iceshrimp.Backend/Core/Services/UserService.cs index 6f5f1686..98be3d9d 100644 --- a/Iceshrimp.Backend/Core/Services/UserService.cs +++ b/Iceshrimp.Backend/Core/Services/UserService.cs @@ -226,7 +226,7 @@ public class UserService( var processPendingDeletes = await ResolveAvatarAndBannerAsync(user, actor); await processPendingDeletes(); user = await UpdateProfileMentionsAsync(user, actor); - UpdateProfileFieldsInBackground(user); + await queueSvc.BackgroundTaskQueue.EnqueueAsync(new ProfileFieldUpdateJobData { UserId = user.Id}); UpdateUserPinnedNotesInBackground(actor, user); _ = followupTaskSvc.ExecuteTaskAsync("UpdateInstanceUserCounter", async provider => { @@ -361,7 +361,7 @@ public class UserService( await db.SaveChangesAsync(); await processPendingDeletes(); user = await UpdateProfileMentionsAsync(user, actor, true); - UpdateProfileFieldsInBackground(user); + await queueSvc.BackgroundTaskQueue.EnqueueAsync(new ProfileFieldUpdateJobData { UserId = user.Id}); UpdateUserPinnedNotesInBackground(actor, user, true); return user; } @@ -400,7 +400,7 @@ public class UserService( await db.SaveChangesAsync(); user = await UpdateProfileMentionsAsync(user, null, wait: true); - UpdateProfileFieldsInBackground(user); + await queueSvc.BackgroundTaskQueue.EnqueueAsync(new ProfileFieldUpdateJobData { UserId = user.Id}); var avatar = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == user.AvatarId); var banner = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == user.BannerId); @@ -1170,47 +1170,33 @@ public class UserService( return user; } - [SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataQuery", Justification = "Projectables")] [SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataUsage", Justification = "Same as above")] - private void UpdateProfileFieldsInBackground(User user) + public async Task VerifyProfileFieldsAsync(User user, string? profileUrl) { - if (KeyedLocker.IsInUse($"profileFields:{user.Id}")) return; + profileUrl ??= user.GetPublicUrl(instance.Value); - _ = followupTaskSvc.ExecuteTaskAsync("UpdateProfileFieldsInBackground", async provider => + foreach (var userProfileField in user.UserProfile!.Fields) { - using (await KeyedLocker.LockAsync($"profileFields:{user.Id}")) - { - var bgDbContext = provider.GetRequiredService(); - var userId = user.Id; - var bgUser = await bgDbContext.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == userId); - if (bgUser?.UserProfile == null) return; - var profileUrl = user.Host != null ? user.UserProfile?.Url : bgUser.GetPublicUrl(instance.Value); - if (profileUrl == null) return; + if (!userProfileField.Value.StartsWith("https://") + || !Uri.TryCreate(userProfileField.Value, UriKind.Absolute, out var uri)) + continue; - foreach (var userProfileField in bgUser.UserProfile.Fields) - { - if (!userProfileField.Value.StartsWith("https://") || !Uri.TryCreate(userProfileField.Value, UriKind.Absolute, out var uri)) - continue; + var res = await httpClient.GetAsync(uri); - var res = await httpClient.GetAsync(uri); + if (!res.IsSuccessStatusCode || res.Content.Headers.ContentType?.MediaType != "text/html") + continue; - if (!res.IsSuccessStatusCode || res.Content.Headers.ContentType?.MediaType != "text/html") - continue; + var html = await res.Content.ReadAsStringAsync(); + var document = await new HtmlParser().ParseDocumentAsync(html); - var html = await res.Content.ReadAsStringAsync(); - var document = await new HtmlParser().ParseDocumentAsync(html); + userProfileField.IsVerified = + document.Links.Any(a => (a.GetAttribute("rel")?.Contains("me") + ?? false) + && a.GetAttribute("href") == profileUrl + || a.GetAttribute("href") == user.GetUriOrPublicUri(instance.Value)); + } - userProfileField.IsVerified = - document.Links.Any(a => (a.GetAttribute("rel")?.Contains("me") - ?? false) - && a.GetAttribute("href") == profileUrl - || a.GetAttribute("href") == user.GetUriOrPublicUri(instance.Value)); - } - - bgDbContext.Update(bgUser.UserProfile); - await bgDbContext.SaveChangesAsync(); - } - }); + return user; } private List ResolveHashtags(IMfmNode[]? parsedText, ASActor? actor = null)