using System.Diagnostics.CodeAnalysis; using System.Net; using System.Security.Cryptography; using AsyncKeyedLock; using EntityFramework.Exceptions.Common; using Iceshrimp.Backend.Core.Configuration; 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; using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Queues; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using static Iceshrimp.Parsing.MfmNodeTypes; namespace Iceshrimp.Backend.Core.Services; public class UserService( [SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor")] IOptionsSnapshot security, IOptions instance, ILogger logger, DatabaseContext db, ActivityPub.ActivityFetcherService fetchSvc, ActivityPub.ActivityRenderer activityRenderer, ActivityPub.ActivityDeliverService deliverSvc, DriveService driveSvc, FollowupTaskService followupTaskSvc, NotificationService notificationSvc, EmojiService emojiSvc, ActivityPub.MentionsResolver mentionsResolver, ActivityPub.UserRenderer userRenderer, QueueService queueSvc, EventService eventSvc, WebFingerService webFingerSvc, ActivityPub.FederationControlService fedCtrlSvc ) { private static readonly AsyncKeyedLocker KeyedLocker = new(o => { o.PoolSize = 100; o.PoolInitialFill = 5; }); private (string Username, string? Host) AcctToTuple(string acct) { if (!acct.StartsWith("acct:")) throw new GracefulException(HttpStatusCode.BadRequest, "Invalid query"); var split = acct[5..].Split('@'); if (split.Length != 2) return (split[0], instance.Value.AccountDomain.ToPunycode()); return (split[0], split[1].ToPunycode()); } public async Task GetUserFromQueryAsync(string query) { if (query.StartsWith("http://") || query.StartsWith("https://")) if (query.StartsWith($"https://{instance.Value.WebDomain}/users/")) { query = query[$"https://{instance.Value.WebDomain}/users/".Length..]; return await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == query) ?? throw GracefulException.NotFound("User not found"); } else if (query.StartsWith($"https://{instance.Value.WebDomain}/@")) { query = query[$"https://{instance.Value.WebDomain}/@".Length..]; if (query.Split('@').Length != 1) return await GetUserFromQueryAsync($"acct:{query}"); return await db.Users.IncludeCommonProperties() .FirstOrDefaultAsync(p => p.Username == query.ToLower()) ?? throw GracefulException.NotFound("User not found"); } else { return await db.Users .IncludeCommonProperties() .FirstOrDefaultAsync(p => (p.Uri != null && p.Uri == query) || (p.UserProfile != null && p.UserProfile.Url == query)); } var tuple = AcctToTuple(query); if (tuple.Host == instance.Value.WebDomain || tuple.Host == instance.Value.AccountDomain) tuple.Host = null; return await db.Users .IncludeCommonProperties() .FirstOrDefaultAsync(p => p.UsernameLower == tuple.Username.ToLowerInvariant() && p.Host == tuple.Host); } public async Task CreateUserAsync(string uri, string acct) { logger.LogDebug("Creating user {acct} with uri {uri}", acct, uri); var host = AcctToTuple(acct).Host ?? throw new Exception("Host must not be null at this stage"); if (await fedCtrlSvc.ShouldBlockAsync(uri, host)) throw GracefulException.UnprocessableEntity("Refusing to create user on blocked instance"); var user = await db.Users .IncludeCommonProperties() .FirstOrDefaultAsync(p => p.Uri != null && p.Uri == uri); if (user != null) { // Another thread got there first logger.LogDebug("Actor {uri} is already known, returning existing user {id}", user.Uri, user.Id); return user; } var actor = await fetchSvc.FetchActorAsync(uri); logger.LogDebug("Got actor: {url}", actor.Url); actor.Normalize(uri, acct); if (actor.Id != uri) throw GracefulException.UnprocessableEntity("Uri doesn't match id of fetched actor"); if (actor.PublicKey?.Id == null || actor.PublicKey?.PublicKey == null) throw GracefulException.UnprocessableEntity("Actor has no valid public key"); if (new Uri(actor.PublicKey.Id).Host != new Uri(actor.Id).Host) throw GracefulException.UnprocessableEntity("Actor public key id host doesn't match actor id host"); var emoji = await emojiSvc.ProcessEmojiAsync(actor.Tags?.OfType().ToList(), host); var fields = actor.Attachments != null ? await actor.Attachments .OfType() .Where(p => p is { Name: not null, Value: not null }) .Select(async p => new UserProfile.Field { Name = p.Name!, Value = await MfmConverter.FromHtmlAsync(p.Value) ?? "" }) .AwaitAllAsync() : null; var bio = actor.MkSummary ?? await MfmConverter.FromHtmlAsync(actor.Summary); var tags = ResolveHashtags(bio, actor); 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, SplitDomainResolved = true, //TODO: FollowersCount //TODO: FollowingCount Emojis = emoji.Select(p => p.Id).ToList(), Tags = tags, }; var profile = new UserProfile { User = user, Description = bio, //Birthday = TODO, //Location = TODO, Fields = fields?.ToArray() ?? [], UserHost = user.Host, Url = actor.Url?.Link }; var publicKey = new UserPublickey { UserId = user.Id, KeyId = actor.PublicKey.Id, KeyPem = actor.PublicKey.PublicKey }; try { await db.AddRangeAsync(user, profile, publicKey); await db.SaveChangesAsync(); var processPendingDeletes = await ResolveAvatarAndBanner(user, actor); await processPendingDeletes(); user = await UpdateProfileMentions(user, actor); UpdateUserPinnedNotesInBackground(actor, user); _ = followupTaskSvc.ExecuteTask("UpdateInstanceUserCounter", async provider => { var bgDb = provider.GetRequiredService(); var bgInstanceSvc = provider.GetRequiredService(); var dbInstance = await bgInstanceSvc.GetUpdatedInstanceMetadataAsync(user); await bgDb.Instances.Where(p => p.Id == dbInstance.Id) .ExecuteUpdateAsync(p => p.SetProperty(i => i.UsersCount, i => i.UsersCount + 1)); }); return user; } catch (UniqueConstraintException) { logger.LogDebug("Encountered UniqueConstraintException while creating user {uri}, attempting to refetch...", user.Uri); // another thread got there first, so we need to return the existing user var res = await db.Users .IncludeCommonProperties() .FirstOrDefaultAsync(p => p.Uri != null && p.Uri == user.Uri); // something else must have went wrong, rethrow exception if (res == null) { logger.LogError("Fetching user {uri} failed, rethrowing exception", user.Uri); throw; } logger.LogDebug("Successfully fetched user {uri}", user.Uri); return res; } } public async Task UpdateUserAsync(string id) { var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id) ?? throw new Exception("Cannot update nonexistent user"); return await UpdateUserAsync(user, force: true); } public async Task UpdateUserAsync(User user, ASActor? actor = null, bool force = false) { if (!user.NeedsUpdate && actor == null && !force) return user; if (actor is { IsUnresolved: true } or { Username: null }) actor = null; // This will trigger a fetch a couple lines down // Prevent multiple update jobs from running concurrently db.Update(user); var userId = user.Id; await db.Users.Where(u => u.Id == userId) .ExecuteUpdateAsync(p => p.SetProperty(u => u.LastFetchedAt, DateTime.UtcNow)); var uri = user.Uri ?? throw new Exception("Encountered remote user without a Uri"); logger.LogDebug("Updating user with uri {uri}", uri); actor ??= await fetchSvc.FetchActorAsync(user.Uri); actor.Normalize(uri, user.AcctWithPrefix); user.UserProfile ??= await db.UserProfiles.FirstOrDefaultAsync(p => p.User == user); user.UserProfile ??= new UserProfile { User = user }; user.LastFetchedAt = DateTime.UtcNow; // If we don't do this we'll overwrite the value with the previous one user.Inbox = actor.Inbox?.Link; user.SharedInbox = actor.SharedInbox?.Link ?? actor.Endpoints?.SharedInbox?.Id; user.DisplayName = actor.DisplayName; user.IsLocked = actor.IsLocked ?? false; user.IsBot = actor.IsBot; user.MovedToUri = actor.MovedTo?.Link; user.AlsoKnownAs = actor.AlsoKnownAs?.Where(p => p.Link != null).Select(p => p.Link!).ToList(); user.IsExplorable = actor.IsDiscoverable ?? false; user.FollowersUri = actor.Followers?.Id; user.IsCat = actor.IsCat ?? false; user.Featured = actor.Featured?.Id; var emoji = await emojiSvc.ProcessEmojiAsync(actor.Tags?.OfType().ToList(), user.Host ?? throw new Exception("User host must not be null at this stage")); var fields = actor.Attachments != null ? await actor.Attachments .OfType() .Where(p => p is { Name: not null, Value: not null }) .Select(async p => new UserProfile.Field { Name = p.Name!, Value = await MfmConverter.FromHtmlAsync(p.Value) ?? "" }) .AwaitAllAsync() : null; user.Emojis = emoji.Select(p => p.Id).ToList(); //TODO: FollowersCount //TODO: FollowingCount //TODO: update acct host via webfinger here var processPendingDeletes = await ResolveAvatarAndBanner(user, actor); user.UserProfile.Description = actor.MkSummary ?? await MfmConverter.FromHtmlAsync(actor.Summary); //user.UserProfile.Birthday = TODO; //user.UserProfile.Location = TODO; user.UserProfile.Fields = fields?.ToArray() ?? []; user.UserProfile.UserHost = user.Host; user.UserProfile.Url = actor.Url?.Link; user.UserProfile.MentionsResolved = false; user.Tags = ResolveHashtags(user.UserProfile.Description, actor); user.Host = await UpdateUserHostAsync(user); db.Update(user); await db.SaveChangesAsync(); await processPendingDeletes(); user = await UpdateProfileMentions(user, actor, true); UpdateUserPinnedNotesInBackground(actor, user, true); return user; } public async Task UpdateLocalUserAsync(User user, string? prevAvatarId, string? prevBannerId) { if (user.Host != null) throw new Exception("This method is only valid for local users"); if (user.UserProfile == null) throw new Exception("user.UserProfile must not be null at this stage"); user.Tags = ResolveHashtags(user.UserProfile.Description); user.Emojis = []; if (user.UserProfile.Description != null) { var nodes = MfmParser.Parse(user.UserProfile.Description); user.Emojis.AddRange((await emojiSvc.ResolveEmoji(nodes)).Select(p => p.Id).ToList()); } if (user.DisplayName != null) { var nodes = MfmParser.Parse(user.DisplayName); user.Emojis.AddRange((await emojiSvc.ResolveEmoji(nodes)).Select(p => p.Id).ToList()); } if (user.UserProfile.Fields.Length != 0) { var input = user.UserProfile.Fields.Select(p => $"{p.Name} {p.Value}"); var nodes = MfmParser.Parse(string.Join('\n', input)); user.Emojis.AddRange((await emojiSvc.ResolveEmoji(nodes)).Select(p => p.Id).ToList()); } db.Update(user); db.Update(user.UserProfile); await db.SaveChangesAsync(); user = await UpdateProfileMentions(user, null); var activity = activityRenderer.RenderUpdate(await userRenderer.RenderAsync(user)); await deliverSvc.DeliverToFollowersAsync(activity, user, []); _ = followupTaskSvc.ExecuteTask("UpdateLocalUserAsync", async provider => { var bgDriveSvc = provider.GetRequiredService(); if (prevAvatarId != null && user.Avatar?.Id != prevAvatarId) await bgDriveSvc.RemoveFile(prevAvatarId); if (prevBannerId != null && user.Banner?.Id != prevBannerId) await bgDriveSvc.RemoveFile(prevBannerId); }); return user; } public async Task CreateLocalUserAsync(string username, string password, string? invite) { //TODO: invite system should allow multi-use invites & time limited invites if (security.Value.Registrations == Enums.Registrations.Closed) throw new GracefulException(HttpStatusCode.Forbidden, "Registrations are disabled on this server"); if (security.Value.Registrations == Enums.Registrations.Invite && invite == null) throw new GracefulException(HttpStatusCode.Forbidden, "Request is missing the invite code"); if (security.Value.Registrations == Enums.Registrations.Invite && !await db.RegistrationInvites.AnyAsync(p => p.Code == invite)) throw new GracefulException(HttpStatusCode.Forbidden, "The specified invite code is invalid"); if (username.Contains('.')) throw new GracefulException(HttpStatusCode.BadRequest, "Username must not contain the dot character"); if (Constants.SystemUsers.Contains(username.ToLowerInvariant())) throw new GracefulException(HttpStatusCode.BadRequest, "Username must not be a system user"); if (await db.Users.AnyAsync(p => p.Host == null && p.UsernameLower == username.ToLowerInvariant())) throw new GracefulException(HttpStatusCode.BadRequest, "User already exists"); if (await db.UsedUsernames.AnyAsync(p => p.Username.ToLower() == username.ToLowerInvariant())) throw new GracefulException(HttpStatusCode.BadRequest, "Username was already used"); if (password.Length < 8) throw GracefulException.BadRequest("Password must be at least 8 characters long"); var keypair = RSA.Create(4096); var user = new User { Id = IdHelpers.GenerateSlowflakeId(), CreatedAt = DateTime.UtcNow, Username = username, UsernameLower = username.ToLowerInvariant(), Host = null }; var userKeypair = new UserKeypair { UserId = user.Id, PrivateKey = keypair.ExportPkcs8PrivateKeyPem(), PublicKey = keypair.ExportSubjectPublicKeyInfoPem() }; var userProfile = new UserProfile { UserId = user.Id, Password = AuthHelpers.HashPassword(password) }; var usedUsername = new UsedUsername { CreatedAt = DateTime.UtcNow, Username = username.ToLowerInvariant() }; if (security.Value.Registrations == Enums.Registrations.Invite) { var ticket = await db.RegistrationInvites.FirstOrDefaultAsync(p => p.Code == invite); if (ticket == null) throw GracefulException.Forbidden("The specified invite code is invalid"); db.Remove(ticket); } await db.AddRangeAsync(user, userKeypair, userProfile, usedUsername); await db.SaveChangesAsync(); return user; } private async Task> ResolveAvatarAndBanner(User user, ASActor actor) { var avatar = await driveSvc.StoreFile(actor.Avatar?.Url?.Link, user, actor.Avatar?.Sensitive ?? false, logExisting: false); var banner = await driveSvc.StoreFile(actor.Banner?.Url?.Link, user, actor.Banner?.Sensitive ?? false, logExisting: false); var prevAvatarId = user.AvatarId; var prevBannerId = user.BannerId; user.Avatar = avatar; user.Banner = banner; user.AvatarBlurhash = avatar?.Blurhash; user.BannerBlurhash = banner?.Blurhash; user.AvatarUrl = avatar?.Url; user.BannerUrl = banner?.Url; await db.SaveChangesAsync(); return async () => { if (prevAvatarId != null && avatar?.Id != prevAvatarId) await driveSvc.RemoveFile(prevAvatarId); if (prevBannerId != null && banner?.Id != prevBannerId) await driveSvc.RemoveFile(prevBannerId); }; } public async Task UpdateUserPublicKeyAsync(UserPublickey key) { var uri = key.User.Uri ?? throw new Exception("Can't update public key of user without Uri"); var actor = await fetchSvc.FetchActorAsync(uri); if (actor.PublicKey?.PublicKey == null) throw new Exception("Failed to update user public key: Invalid or missing public key"); key.KeyId = actor.PublicKey.Id; key.KeyPem = actor.PublicKey.PublicKey; db.Update(key); await db.SaveChangesAsync(); return key; } public async Task UpdateUserPublicKeyAsync(User user) { var uri = user.Uri ?? throw new Exception("Can't update public key of user without Uri"); var actor = await fetchSvc.FetchActorAsync(uri); if (actor.PublicKey?.PublicKey == null) throw new Exception("Failed to update user public key: Invalid or missing public key"); var key = await db.UserPublickeys.FirstOrDefaultAsync(p => p.User == user) ?? new UserPublickey { User = user }; var insert = key.KeyId == null!; key.KeyId = actor.PublicKey.Id; key.KeyPem = actor.PublicKey.PublicKey; if (insert) db.Add(key); else db.Update(key); await db.SaveChangesAsync(); return key; } public async Task DeleteUserAsync(ASActor actor) { var user = await db.Users.FirstOrDefaultAsync(p => p.Uri == actor.Id && p.Host != null); if (user == null) { logger.LogDebug("User {uri} is unknown, skipping delete task", actor.Id); return; } await DeleteUserAsync(user); } public async Task DeleteUserAsync(User user) { await queueSvc.BackgroundTaskQueue.EnqueueAsync(new UserDeleteJobData { UserId = user.Id }); } public void UpdateOauthTokenMetadata(OauthToken token) { UpdateUserLastActive(token.User); if (token.LastActiveDate != null && token.LastActiveDate > DateTime.UtcNow - TimeSpan.FromHours(1)) return; _ = followupTaskSvc.ExecuteTask("UpdateOauthTokenMetadata", async provider => { var bgDb = provider.GetRequiredService(); await bgDb.OauthTokens.Where(p => p.Id == token.Id) .ExecuteUpdateAsync(p => p.SetProperty(u => u.LastActiveDate, DateTime.UtcNow)); }); } public void UpdateSessionMetadata(Session session) { UpdateUserLastActive(session.User); if (session.LastActiveDate != null && session.LastActiveDate > DateTime.UtcNow - TimeSpan.FromMinutes(5)) return; _ = followupTaskSvc.ExecuteTask("UpdateSessionMetadata", async provider => { var bgDb = provider.GetRequiredService(); await bgDb.Sessions.Where(p => p.Id == session.Id) .ExecuteUpdateAsync(p => p.SetProperty(u => u.LastActiveDate, DateTime.UtcNow)); }); } private void UpdateUserLastActive(User user) { if (user.LastActiveDate != null && user.LastActiveDate > DateTime.UtcNow - TimeSpan.FromMinutes(5)) return; _ = followupTaskSvc.ExecuteTask("UpdateUserLastActive", async provider => { var bgDb = provider.GetRequiredService(); await bgDb.Users.Where(p => p.Id == user.Id) .ExecuteUpdateAsync(p => p.SetProperty(u => u.LastActiveDate, DateTime.UtcNow)); }); } public async Task AcceptFollowRequestAsync(FollowRequest request) { if (request is { Follower.Host: not null, RequestId: null }) throw GracefulException.UnprocessableEntity("Cannot accept request without request id"); var following = new Following { Id = IdHelpers.GenerateSlowflakeId(), CreatedAt = DateTime.UtcNow, Follower = request.Follower, Followee = request.Followee, FollowerHost = request.FollowerHost, FolloweeHost = request.FolloweeHost, FollowerInbox = request.FollowerInbox, FolloweeInbox = request.FolloweeInbox, FollowerSharedInbox = request.FollowerSharedInbox, FolloweeSharedInbox = request.FolloweeSharedInbox }; await db.Users.Where(p => p.Id == request.Follower.Id) .ExecuteUpdateAsync(p => p.SetProperty(i => i.FollowingCount, i => i.FollowingCount + 1)); await db.Users.Where(p => p.Id == request.Followee.Id) .ExecuteUpdateAsync(p => p.SetProperty(i => i.FollowersCount, i => i.FollowersCount + 1)); db.Remove(request); await db.AddAsync(following); await db.SaveChangesAsync(); if (request.Follower is { Host: not null }) { _ = followupTaskSvc.ExecuteTask("IncrementInstanceIncomingFollowsCounter", async provider => { var bgDb = provider.GetRequiredService(); var bgInstanceSvc = provider.GetRequiredService(); var dbInstance = await bgInstanceSvc.GetUpdatedInstanceMetadataAsync(request.Follower); await bgDb.Instances.Where(p => p.Id == dbInstance.Id) .ExecuteUpdateAsync(p => p.SetProperty(i => i.IncomingFollows, i => i.IncomingFollows + 1)); }); var activity = activityRenderer.RenderAccept(request.Followee, request.Follower, request.RequestId!); await deliverSvc.DeliverToAsync(activity, request.Followee, request.Follower); } else if (request.Followee is { Host: not null }) { _ = followupTaskSvc.ExecuteTask("IncrementInstanceOutgoingFollowsCounter", async provider => { var bgDb = provider.GetRequiredService(); var bgInstanceSvc = provider.GetRequiredService(); var dbInstance = await bgInstanceSvc.GetUpdatedInstanceMetadataAsync(request.Followee); await bgDb.Instances.Where(p => p.Id == dbInstance.Id) .ExecuteUpdateAsync(p => p.SetProperty(i => i.OutgoingFollows, i => i.OutgoingFollows + 1)); }); } if (request.Followee.IsRemoteUser && request.Follower.IsLocalUser && request.Followee.FollowersCount == 0) UpdateUserPinnedNotesInBackground(request.Followee); await notificationSvc.GenerateFollowNotification(request.Follower, request.Followee); await notificationSvc.GenerateFollowRequestAcceptedNotification(request); // Clean up notifications await db.Notifications .Where(p => p.Type == Notification.NotificationType.FollowRequestReceived && p.Notifiee == request.Followee && p.Notifier == request.Follower) .ExecuteDeleteAsync(); } public async Task RejectFollowRequestAsync(FollowRequest request) { if (request.FollowerHost != null) { var requestId = request.RequestId ?? throw new Exception("Cannot reject request without request id"); var activity = activityRenderer.RenderReject(request.Followee, request.Follower, requestId); await deliverSvc.DeliverToAsync(activity, request.Followee, request.Follower); } db.Remove(request); await db.SaveChangesAsync(); // Clean up notifications await db.Notifications .Where(p => ((p.Type == Notification.NotificationType.FollowRequestReceived || p.Type == Notification.NotificationType.Follow) && p.Notifiee == request.Followee && p.Notifier == request.Follower) || (p.Type == Notification.NotificationType.FollowRequestAccepted && p.Notifiee == request.Follower && p.Notifier == request.Followee)) .ExecuteDeleteAsync(); } [SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")] public async Task FollowUserAsync(User follower, User followee, string? requestId = null) { if (follower.Id == followee.Id) throw GracefulException.UnprocessableEntity("You cannot follow yourself"); if (follower.Host != null && followee.Host != null) throw GracefulException.UnprocessableEntity("Cannot process follow between two remote users"); if (followee.Host != null) { var activity = activityRenderer.RenderFollow(follower, followee); await deliverSvc.DeliverToAsync(activity, follower, followee); } if (follower.Host != null) { if (requestId == null) throw GracefulException.UnprocessableEntity("Cannot process remote follow without requestId"); // Check blocks first if (await db.Users.AnyAsync(p => p == followee && p.IsBlocking(follower))) { var activity = activityRenderer.RenderReject(followee, follower, requestId); await deliverSvc.DeliverToAsync(activity, followee, follower); return; } } else { if (await db.Users.AnyAsync(p => p == followee && p.IsBlocking(follower))) throw GracefulException.UnprocessableEntity("You are not allowed to follow this user"); } if (followee.IsLocked || followee.Host != null) { if (!await db.FollowRequests.AnyAsync(p => p.Follower == follower && p.Followee == followee)) { if (!await db.Followings.AnyAsync(p => p.Follower == follower && p.Followee == followee)) { var request = new FollowRequest { Id = IdHelpers.GenerateSlowflakeId(), CreatedAt = DateTime.UtcNow, RequestId = requestId, Followee = followee, Follower = follower, FolloweeHost = followee.Host, FollowerHost = follower.Host, FolloweeInbox = followee.Inbox, FollowerInbox = follower.Inbox, FolloweeSharedInbox = followee.SharedInbox, FollowerSharedInbox = follower.SharedInbox }; await db.AddAsync(request); await db.SaveChangesAsync(); await notificationSvc.GenerateFollowRequestReceivedNotification(request); } else { if (requestId == null) throw new Exception("requestId must not be null at this stage"); var activity = activityRenderer.RenderAccept(followee, follower, requestId); await deliverSvc.DeliverToAsync(activity, followee, follower); } } else { await db.FollowRequests.Where(p => p.Follower == follower && p.Followee == followee) .ExecuteUpdateAsync(p => p.SetProperty(i => i.RequestId, _ => requestId)); } } else { if (!await db.Followings.AnyAsync(p => p.Follower == follower && p.Followee == followee)) { var following = new Following { Id = IdHelpers.GenerateSlowflakeId(), CreatedAt = DateTime.UtcNow, Followee = followee, Follower = follower, FolloweeHost = followee.Host, FollowerHost = follower.Host, FolloweeInbox = followee.Inbox, FollowerInbox = follower.Inbox, FolloweeSharedInbox = followee.SharedInbox, FollowerSharedInbox = follower.SharedInbox }; await db.AddAsync(following); await db.SaveChangesAsync(); await notificationSvc.GenerateFollowNotification(follower, followee); await db.Users.Where(p => p.Id == follower.Id) .ExecuteUpdateAsync(p => p.SetProperty(i => i.FollowingCount, i => i.FollowingCount + 1)); await db.Users.Where(p => p.Id == followee.Id) .ExecuteUpdateAsync(p => p.SetProperty(i => i.FollowersCount, i => i.FollowersCount + 1)); _ = followupTaskSvc.ExecuteTask("IncrementInstanceIncomingFollowsCounter", async provider => { var bgDb = provider.GetRequiredService(); var bgInstanceSvc = provider.GetRequiredService(); var dbInstance = await bgInstanceSvc.GetUpdatedInstanceMetadataAsync(follower); await bgDb.Instances.Where(p => p.Id == dbInstance.Id) .ExecuteUpdateAsync(p => p.SetProperty(i => i.IncomingFollows, i => i.IncomingFollows + 1)); }); } if (follower.Host != null) { if (requestId == null) throw new Exception("requestId must not be null at this stage"); var activity = activityRenderer.RenderAccept(followee, follower, requestId); await deliverSvc.DeliverToAsync(activity, followee, follower); } } } /// /// Make sure to call .PrecomputeRelationshipData(user) on the database query for the followee /// public async Task UnfollowUserAsync(User user, User followee) { if ((followee.PrecomputedIsFollowedBy ?? false) || (followee.PrecomputedIsRequestedBy ?? false)) { if (followee.Host != null) { var activity = activityRenderer.RenderUnfollow(user, followee); await deliverSvc.DeliverToAsync(activity, user, followee); } } if (followee.PrecomputedIsFollowedBy ?? false) { var followings = await db.Followings.Where(p => p.Follower == user && p.Followee == followee).ToListAsync(); await db.Users.Where(p => p.Id == user.Id) .ExecuteUpdateAsync(p => p.SetProperty(i => i.FollowingCount, i => i.FollowingCount - followings.Count)); await db.Users.Where(p => p.Id == followee.Id) .ExecuteUpdateAsync(p => p.SetProperty(i => i.FollowersCount, i => i.FollowersCount - followings.Count)); db.RemoveRange(followings); await db.SaveChangesAsync(); if (followee.Host != null) { _ = followupTaskSvc.ExecuteTask("DecrementInstanceOutgoingFollowsCounter", async provider => { var bgDb = provider.GetRequiredService(); var bgInstanceSvc = provider.GetRequiredService(); var dbInstance = await bgInstanceSvc.GetUpdatedInstanceMetadataAsync(followee); await bgDb.Instances.Where(p => p.Id == dbInstance.Id) .ExecuteUpdateAsync(p => p.SetProperty(i => i.OutgoingFollows, i => i.OutgoingFollows - 1)); }); } followee.PrecomputedIsFollowedBy = false; eventSvc.RaiseUserUnfollowed(this, user, followee); } if (followee.PrecomputedIsRequestedBy ?? false) { await db.FollowRequests.Where(p => p.Follower == user && p.Followee == followee).ExecuteDeleteAsync(); followee.PrecomputedIsRequestedBy = false; } // Clean up notifications await db.Notifications .Where(p => (p.Type == Notification.NotificationType.FollowRequestAccepted && p.Notifiee == user && p.Notifier == followee) || (p.Type == Notification.NotificationType.Follow && p.Notifiee == followee && p.Notifier == user)) .ExecuteDeleteAsync(); // Clean up user list memberships await db.UserListMembers.Where(p => p.UserList.User == user && p.User == followee).ExecuteDeleteAsync(); } [SuppressMessage("ReSharper", "SuggestBaseTypeForParameter", Justification = "Method only makes sense for users")] private void UpdateUserPinnedNotesInBackground(ASActor actor, User user, bool force = false) { if (followupTaskSvc.IsBackgroundWorker && !force) return; if (KeyedLocker.IsInUse($"pinnedNotes:{user.Id}")) return; _ = followupTaskSvc.ExecuteTask("UpdateUserPinnedNotes", async provider => { using (await KeyedLocker.LockAsync($"pinnedNotes:{user.Id}")) { var bgDb = provider.GetRequiredService(); var bgNoteSvc = provider.GetRequiredService(); var bgUser = await bgDb.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == user.Id); if (bgUser == null) return; await bgNoteSvc.UpdatePinnedNotesAsync(actor, bgUser); } }); } [SuppressMessage("ReSharper", "SuggestBaseTypeForParameter", Justification = "Method only makes sense for users")] private void UpdateUserPinnedNotesInBackground(User user, bool force = false) { if (user.Uri == null) return; if (!user.IsRemoteUser) return; if (followupTaskSvc.IsBackgroundWorker && !force) return; if (KeyedLocker.IsInUse($"pinnedNotes:{user.Id}")) return; _ = followupTaskSvc.ExecuteTask("UpdateUserPinnedNotes", async provider => { using (await KeyedLocker.LockAsync($"pinnedNotes:{user.Id}")) { var bgDb = provider.GetRequiredService(); var bgNoteSvc = provider.GetRequiredService(); var bgUser = await bgDb.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == user.Id); if (bgUser == null) return; var bgFetch = provider.GetRequiredService(); var actor = await bgFetch.FetchActorAsync(user.Uri); await bgNoteSvc.UpdatePinnedNotesAsync(actor, bgUser); } }); } [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 async Task UpdateProfileMentions(User user, ASActor? actor, bool force = false) { if (followupTaskSvc.IsBackgroundWorker && !force) return user; if (KeyedLocker.IsInUse($"profileMentions:{user.Id}")) return user; var success = false; var task = followupTaskSvc.ExecuteTask("UpdateProfileMentionsInBackground", async provider => { using (await KeyedLocker.LockAsync($"profileMentions:{user.Id}")) { var bgDbContext = provider.GetRequiredService(); var bgMentionsResolver = provider.GetRequiredService(); var userId = user.Id; var bgUser = await bgDbContext.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == userId); if (bgUser?.UserProfile == null) return; if (actor != null) { var (mentions, splitDomainMapping) = await bgMentionsResolver.ResolveMentions(actor, bgUser.Host); var fields = actor.Attachments != null ? await actor.Attachments .OfType() .Where(p => p is { Name: not null, Value: not null }) .Select(async p => new UserProfile.Field { Name = p.Name!, Value = await MfmConverter.FromHtmlAsync(p.Value, mentions) ?? "" }) .AwaitAllAsync() : null; var description = actor.MkSummary != null ? mentionsResolver.ResolveMentions(actor.MkSummary, bgUser.Host, mentions, splitDomainMapping) : await MfmConverter.FromHtmlAsync(actor.Summary, mentions); bgUser.UserProfile.Mentions = mentions; bgUser.UserProfile.Fields = fields?.ToArray() ?? []; bgUser.UserProfile.Description = description; } else { bgUser.UserProfile.Mentions = await bgMentionsResolver.ResolveMentions(bgUser.UserProfile.Fields, bgUser.UserProfile.Description, bgUser.Host); } bgUser.UserProfile.MentionsResolved = true; bgDbContext.Update(bgUser.UserProfile); await bgDbContext.SaveChangesAsync(); success = true; } }); await task.SafeWaitAsync(TimeSpan.FromMilliseconds(500)); if (success) await db.ReloadEntityRecursivelyAsync(user); return user; } private List ResolveHashtags(string? text, ASActor? actor = null) { List tags = []; if (text != null) { tags = MfmParser.Parse(text) .SelectMany(p => p.Children.Append(p)) .OfType() .Select(p => p.Hashtag.ToLowerInvariant()) .Select(p => p.Trim('#')) .Distinct() .ToList(); } var extracted = actor?.Tags?.OfType() .Select(p => p.Name?.ToLowerInvariant()) .Where(p => p != null) .Cast() .Select(p => p.Trim('#')) .Distinct() .ToList(); if (extracted != null) tags.AddRange(extracted); if (tags.Count == 0) return []; tags = tags.Distinct().ToList(); _ = followupTaskSvc.ExecuteTask("UpdateHashtagsTable", async provider => { var bgDb = provider.GetRequiredService(); var existing = await bgDb.Hashtags.Where(p => tags.Contains(p.Name)).Select(p => p.Name).ToListAsync(); var dbTags = tags.Except(existing) .Select(p => new Hashtag { Id = IdHelpers.GenerateSlowflakeId(), Name = p }); await bgDb.UpsertRange(dbTags).On(p => p.Name).NoUpdate().RunAsync(); }); return tags; } public async Task MuteUserAsync(User muter, User mutee, DateTime? expiration) { mutee.PrecomputedIsMutedBy = true; var muting = await db.Mutings.FirstOrDefaultAsync(p => p.Muter == muter && p.Mutee == mutee); if (muting != null) { if (muting.ExpiresAt == expiration) return; muting.ExpiresAt = expiration; await db.SaveChangesAsync(); if (expiration == null) return; var job = new MuteExpiryJobData { MuteId = muting.Id }; await queueSvc.BackgroundTaskQueue.ScheduleAsync(job, expiration.Value); return; } muting = new Muting { Id = IdHelpers.GenerateSlowflakeId(), CreatedAt = DateTime.UtcNow, Mutee = mutee, Muter = muter, ExpiresAt = expiration }; await db.AddAsync(muting); await db.SaveChangesAsync(); eventSvc.RaiseUserMuted(this, muter, mutee); if (expiration != null) { var job = new MuteExpiryJobData { MuteId = muting.Id }; await queueSvc.BackgroundTaskQueue.ScheduleAsync(job, expiration.Value); } } public async Task UnmuteUserAsync(User muter, User mutee) { if (!mutee.PrecomputedIsMutedBy ?? false) return; await db.Mutings.Where(p => p.Muter == muter && p.Mutee == mutee).ExecuteDeleteAsync(); eventSvc.RaiseUserUnmuted(this, muter, mutee); mutee.PrecomputedIsMutedBy = false; } public async Task BlockUserAsync(User blocker, User blockee) { if (blockee.PrecomputedIsBlockedBy ?? false) return; blockee.PrecomputedIsBlockedBy = true; var blocking = new Blocking { Id = IdHelpers.GenerateSlowflakeId(), CreatedAt = DateTime.UtcNow, Blockee = blockee, Blocker = blocker }; await db.FollowRequests.Where(p => p.Follower == blockee && p.Followee == blocker).ExecuteDeleteAsync(); await db.FollowRequests.Where(p => p.Follower == blocker && p.Followee == blockee).ExecuteDeleteAsync(); var cnt1 = await db.Followings.Where(p => p.Follower == blockee && p.Followee == blocker).ExecuteDeleteAsync(); var cnt2 = await db.Followings.Where(p => p.Follower == blocker && p.Followee == blockee).ExecuteDeleteAsync(); await db.Users.Where(p => p == blocker) .ExecuteUpdateAsync(p => p.SetProperty(i => i.FollowersCount, i => i.FollowersCount - cnt1) .SetProperty(i => i.FollowingCount, i => i.FollowingCount - cnt2)); await db.Users.Where(p => p == blockee) .ExecuteUpdateAsync(p => p.SetProperty(i => i.FollowersCount, i => i.FollowersCount - cnt2) .SetProperty(i => i.FollowingCount, i => i.FollowingCount - cnt1)); // clean up notifications await db.Notifications.Where(p => ((p.Notifiee == blocker && p.Notifier == blockee) || (p.Notifiee == blockee && p.Notifier == blocker)) && (p.Type == Notification.NotificationType.Follow || p.Type == Notification.NotificationType.FollowRequestAccepted || p.Type == Notification.NotificationType.FollowRequestReceived)) .ExecuteDeleteAsync(); await db.AddAsync(blocking); await db.SaveChangesAsync(); eventSvc.RaiseUserBlocked(this, blocker, blockee); if (blocker.IsLocalUser && blockee.IsRemoteUser) { var actor = userRenderer.RenderLite(blocker); var obj = userRenderer.RenderLite(blockee); var activity = activityRenderer.RenderBlock(actor, obj, blocking.Id); await deliverSvc.DeliverToAsync(activity, blocker, blockee); } } public async Task UnblockUserAsync(User blocker, User blockee) { if (!blockee.PrecomputedIsBlockedBy ?? false) return; blockee.PrecomputedIsBlockedBy = false; var blocking = await db.Blockings.FirstOrDefaultAsync(p => p.Blocker == blocker && p.Blockee == blockee); if (blocking == null) return; db.Remove(blocking); await db.SaveChangesAsync(); eventSvc.RaiseUserUnblocked(this, blocker, blockee); if (blocker.IsLocalUser && blockee.IsRemoteUser) { var actor = userRenderer.RenderLite(blocker); var obj = userRenderer.RenderLite(blockee); var block = activityRenderer.RenderBlock(actor, obj, blocking.Id); var activity = activityRenderer.RenderUndo(actor, block); 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]; } }