Iceshrimp.NET/Iceshrimp.Backend/Core/Services/UserService.cs

1668 lines
62 KiB
C#

using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
using AngleSharp.Html.Parser;
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.Middleware;
using Iceshrimp.Backend.Core.Queues;
using Iceshrimp.EntityFrameworkCore.Extensions;
using Iceshrimp.MfmSharp;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Core.Services;
public class UserService(
IOptionsSnapshot<Config.SecuritySection> security,
IOptions<Config.InstanceSection> instance,
IOptionsSnapshot<Config.BackfillSection> backfillConfig,
ILogger<UserService> 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,
HttpClient httpClient
) : IScopedService
{
private static readonly AsyncKeyedLocker<string> KeyedLocker = new(o =>
{
o.PoolSize = 100;
o.PoolInitialFill = 5;
});
private static (string Username, string? Host) AcctToTuple(Uri acct)
{
if (acct.Scheme is not "acct") throw GracefulException.BadRequest($"Invalid query scheme: {acct}");
var split = acct.AbsolutePath.Split('@');
if (split.Length > 2) throw GracefulException.BadRequest($"Invalid query: {acct}");
return split.Length != 2
? (split[0], null)
: (split[0], split[1].ToPunycodeLower());
}
private static (string Username, string? Host) AcctToTuple(string acct) => AcctToTuple(new Uri(acct));
public async Task<User?> GetUserFromQueryAsync(Uri query, bool allowUrl)
{
if (query.Scheme is "https")
{
if (query.Host == instance.Value.WebDomain)
{
if (query.AbsolutePath.StartsWith("/users/"))
{
var userId = query.AbsolutePath["/users/".Length..];
return await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == userId);
}
if (query.AbsolutePath.StartsWith("/@"))
{
var acct = query.AbsolutePath[2..];
var split = acct.Split('@');
if (split.Length != 1)
return await GetUserFromQueryAsync(new Uri($"acct:{acct}"), allowUrl);
return await db.Users.IncludeCommonProperties()
.FirstOrDefaultAsync(p => p.Username == split[0].ToLower() && p.IsLocalUser);
}
return null;
}
var res = await db.Users.IncludeCommonProperties()
.FirstOrDefaultAsync(p => p.Uri != null && p.Uri == query.AbsoluteUri);
if (res != null || !allowUrl)
return res;
return await db.Users.IncludeCommonProperties()
.FirstOrDefaultAsync(p => p.UserProfile != null && p.UserProfile.Url == query.AbsoluteUri);
}
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<User> 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 (host == instance.Value.WebDomain || host == instance.Value.AccountDomain)
throw GracefulException.UnprocessableEntity("Refusing to create remote user on local instance domain");
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.NormalizeAndValidate(uri);
user = await db.Users.FirstOrDefaultAsync(p => p.UsernameLower == actor.Username!.ToLowerInvariant()
&& p.Host == host);
if (user is not null)
throw GracefulException
.UnprocessableEntity($"A user with acct @{user.UsernameLower}@{user.Host} already exists: {user.Uri}");
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");
var emoji = await emojiSvc.ProcessEmojiAsync(actor.Tags?.OfType<ASEmoji>().ToList(), host);
var fields = actor.Attachments != null
? await actor.Attachments
.OfType<ASField>()
.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)).Mfm
})
.AwaitAllAsync()
: null;
var pronouns = actor.Pronouns?.Values.ToDictionary(p => p.Key, p => p.Value ?? "");
var bio = actor.MkSummary?.ReplaceLineEndings("\n").Trim();
if (bio == null)
{
var asHashtags = actor.Tags?.OfType<ASHashtag>()
.Select(p => p.Name?.ToLowerInvariant().TrimStart('#'))
.NotNull()
.ToList()
?? [];
bio = (await MfmConverter.FromHtmlAsync(actor.Summary, hashtags: asHashtags)).Mfm;
}
var tags = ResolveHashtags(MfmParser.Parse(bio), actor);
user = new User
{
Id = IdHelpers.GenerateSnowflakeId(),
CreatedAt = DateTime.UtcNow,
LastFetchedAt = followupTaskSvc.IsBackgroundWorker.Value ? null : DateTime.UtcNow,
DisplayName = actor.DisplayName?.ReplaceLineEndings("\n").Trim(),
IsLocked = actor.IsLocked ?? false,
IsBot = actor.IsBot,
Username = actor.Username!,
UsernameLower = actor.Username!.ToLowerInvariant(),
Host = 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,
Outbox = actor.Outbox?.Id,
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,
Pronouns = pronouns
};
var publicKey = new UserPublickey
{
UserId = user.Id,
KeyId = actor.PublicKey.Id,
KeyPem = actor.PublicKey.PublicKey.Trim()
};
try
{
await db.AddRangeAsync(user, profile, publicKey);
await db.SaveChangesAsync();
var processPendingDeletes = await ResolveAvatarAndBannerAsync(user, actor);
await processPendingDeletes();
user = await UpdateProfileMentionsAsync(user, actor);
await queueSvc.BackgroundTaskQueue.EnqueueAsync(new ProfileFieldUpdateJobData { UserId = user.Id});
UpdateUserPinnedNotesInBackground(actor, user);
_ = followupTaskSvc.ExecuteTaskAsync("UpdateInstanceUserCounter", async provider =>
{
var bgDb = provider.GetRequiredService<DatabaseContext>();
var bgInstanceSvc = provider.GetRequiredService<InstanceService>();
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 e) when (e.ConstraintProperties is [nameof(User.Uri)])
{
logger.LogError("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;
}
catch (UniqueConstraintException e)
{
logger.LogError("Failed to insert user: Unable to satisfy unique constraint: {constraint}",
e.ConstraintName);
throw;
}
}
public async Task<User> 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<User> UpdateUserAsync(User user, ASActor? actor = null, bool force = false)
{
if (!user.NeedsUpdate && actor == null && !force) return user;
if (user.IsLocalUser) 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.NormalizeAndValidate(uri);
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.Outbox = actor.Outbox?.Id;
user.SharedInbox = actor.SharedInbox?.Link ?? actor.Endpoints?.SharedInbox?.Id;
user.DisplayName = actor.DisplayName?.ReplaceLineEndings("\n").Trim();
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<ASEmoji>().ToList(),
user.Host
?? throw new
Exception("User host must not be null at this stage"));
var fields = actor.Attachments != null
? await actor.Attachments
.OfType<ASField>()
.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)).Mfm
})
.AwaitAllAsync()
: null;
var pronouns = actor.Pronouns?.Values.ToDictionary(p => p.Key, p => p.Value ?? "");
user.Emojis = emoji.Select(p => p.Id).ToList();
//TODO: FollowersCount
//TODO: FollowingCount
//TODO: update acct host via webfinger here
var processPendingDeletes = await ResolveAvatarAndBannerAsync(user, actor);
user.UserProfile.Description = actor.MkSummary?.ReplaceLineEndings("\n").Trim();
if (user.UserProfile.Description == null)
{
var asHashtags = actor.Tags?.OfType<ASHashtag>()
.Select(p => p.Name?.ToLowerInvariant().TrimStart('#'))
.NotNull()
.ToList()
?? [];
user.UserProfile.Description = (await MfmConverter.FromHtmlAsync(actor.Summary, hashtags: asHashtags)).Mfm;
}
//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.Pronouns = pronouns;
user.UserProfile.MentionsResolved = false;
user.Tags = ResolveHashtags(MfmParser.Parse(user.UserProfile.Description), actor);
user.Host = await UpdateUserHostAsync(user);
db.Update(user);
await db.SaveChangesAsync();
await processPendingDeletes();
user = await UpdateProfileMentionsAsync(user, actor, true);
await queueSvc.BackgroundTaskQueue.EnqueueAsync(new ProfileFieldUpdateJobData { UserId = user.Id});
UpdateUserPinnedNotesInBackground(actor, user, true);
return user;
}
public async Task<User> UpdateLocalUserAsync(User user, string? prevAvatarId, string? prevBannerId)
{
if (user.IsRemoteUser) 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.DisplayName = user.DisplayName?.ReplaceLineEndings("\n").Trim();
user.UserProfile.Description = user.UserProfile.Description?.ReplaceLineEndings("\n").Trim();
var parsedName = user.DisplayName != null ? MfmParser.Parse(user.DisplayName) : null;
var parsedBio = user.UserProfile.Description != null ? MfmParser.Parse(user.UserProfile.Description) : null;
user.Tags = parsedBio != null ? ResolveHashtags(parsedBio) : [];
user.Emojis = [];
if (parsedBio != null)
user.Emojis.AddRange((await emojiSvc.ResolveEmojiAsync(parsedBio)).Select(p => p.Id).ToList());
if (parsedName != null)
user.Emojis.AddRange((await emojiSvc.ResolveEmojiAsync(parsedName)).Select(p => p.Id).ToList());
if (user.UserProfile.Fields is { Length: > 0 } fields)
{
foreach (var field in fields)
field.Name = field.Name.ReplaceLineEndings("\n").Trim();
var input = user.UserProfile.Fields.Select(p => $"{p.Name} {p.Value}");
var nodes = MfmParser.Parse(string.Join('\n', input));
user.Emojis.AddRange((await emojiSvc.ResolveEmojiAsync(nodes)).Select(p => p.Id).ToList());
}
db.Update(user);
db.Update(user.UserProfile);
await db.SaveChangesAsync();
user = await UpdateProfileMentionsAsync(user, null, wait: true);
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);
user.Avatar = avatar;
user.Banner = banner;
var activity = activityRenderer.RenderUpdate(await userRenderer.RenderAsync(user));
await deliverSvc.DeliverToFollowersAsync(activity, user, []);
_ = followupTaskSvc.ExecuteTaskAsync("UpdateLocalUserAsync", async provider =>
{
var bgDriveSvc = provider.GetRequiredService<DriveService>();
if (prevAvatarId != null && user.Avatar?.Id != prevAvatarId)
await bgDriveSvc.RemoveFileAsync(prevAvatarId);
if (prevBannerId != null && user.Banner?.Id != prevBannerId)
await bgDriveSvc.RemoveFileAsync(prevBannerId);
});
return user;
}
public async Task<User> CreateLocalUserAsync(string username, string password, string? invite, bool force = false)
{
//TODO: invite system should allow multi-use invites & time limited invites
if (security.Value.Registrations == Enums.Registrations.Closed && !force)
throw new GracefulException(HttpStatusCode.Forbidden, "Registrations are disabled on this server");
if (security.Value.Registrations == Enums.Registrations.Invite && invite == null && !force)
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)
&& !force)
throw new GracefulException(HttpStatusCode.Forbidden, "The specified invite code is invalid");
if (!Regex.IsMatch(username, @"^\w+$"))
throw new GracefulException(HttpStatusCode.BadRequest, "Username must only contain letters and numbers");
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.IsLocalUser && 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.GenerateSnowflakeId(),
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 };
var userSettings = new UserSettings { UserId = user.Id, Password = AuthHelpers.HashPassword(password) };
var usedUsername = new UsedUsername { CreatedAt = DateTime.UtcNow, Username = username.ToLowerInvariant() };
if (security.Value.Registrations == Enums.Registrations.Invite && !force)
{
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, userSettings, usedUsername);
await db.SaveChangesAsync();
return user;
}
private async Task<Func<Task>> ResolveAvatarAndBannerAsync(User user, ASActor actor)
{
var avatar = await driveSvc.StoreFileAsync(actor.Avatar?.Url?.Link, user, actor.Avatar?.Sensitive ?? false,
actor.Avatar?.Description, logExisting: false);
var banner = await driveSvc.StoreFileAsync(actor.Banner?.Url?.Link, user, actor.Banner?.Sensitive ?? false,
actor.Banner?.Description, logExisting: false);
var prevAvatarId = user.AvatarId;
var prevBannerId = user.BannerId;
user.Avatar = avatar;
user.Banner = banner;
user.AvatarBlurhash = avatar?.Blurhash;
user.BannerBlurhash = banner?.Blurhash;
await db.SaveChangesAsync();
return async () =>
{
if (prevAvatarId != null && avatar?.Id != prevAvatarId)
await driveSvc.RemoveFileAsync(prevAvatarId);
if (prevBannerId != null && banner?.Id != prevBannerId)
await driveSvc.RemoveFileAsync(prevBannerId);
};
}
public async Task<UserPublickey> 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<UserPublickey> 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.Trim();
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.IsRemoteUser);
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 async Task PurgeUserAsync(User user)
{
await queueSvc.BackgroundTaskQueue.EnqueueAsync(new UserPurgeJobData { UserId = user.Id });
}
public void UpdateOauthTokenMetadata(OauthToken token)
{
UpdateUserLastActive(token.User);
if (token.LastActiveDate != null && token.LastActiveDate > DateTime.UtcNow - TimeSpan.FromHours(1)) return;
_ = followupTaskSvc.ExecuteTaskAsync("UpdateOauthTokenMetadata", async provider =>
{
var bgDb = provider.GetRequiredService<DatabaseContext>();
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.ExecuteTaskAsync("UpdateSessionMetadata", async provider =>
{
var bgDb = provider.GetRequiredService<DatabaseContext>();
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.ExecuteTaskAsync("UpdateUserLastActive", async provider =>
{
var bgDb = provider.GetRequiredService<DatabaseContext>();
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.IsRemoteUser: true, RequestId: null })
throw GracefulException.UnprocessableEntity("Cannot accept remote request without request id");
var following = new Following
{
Id = IdHelpers.GenerateSnowflakeId(),
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,
RelationshipId = request.RelationshipId
};
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 { IsRemoteUser: true })
{
_ = followupTaskSvc.ExecuteTaskAsync("IncrementInstanceIncomingFollowsCounter", async provider =>
{
var bgDb = provider.GetRequiredService<DatabaseContext>();
var bgInstanceSvc = provider.GetRequiredService<InstanceService>();
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 { IsRemoteUser: true })
{
_ = followupTaskSvc.ExecuteTaskAsync("IncrementInstanceOutgoingFollowsCounter", async provider =>
{
var bgDb = provider.GetRequiredService<DatabaseContext>();
var bgInstanceSvc = provider.GetRequiredService<InstanceService>();
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.GenerateFollowNotificationAsync(request.Follower, request.Followee);
await notificationSvc.GenerateFollowRequestAcceptedNotificationAsync(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.IsRemoteUser && followee.IsRemoteUser)
throw GracefulException.UnprocessableEntity("Cannot process follow between two remote users");
if (follower.IsSystemUser || followee.IsSystemUser)
throw GracefulException.UnprocessableEntity("System users cannot have follow relationships");
Guid? relationshipId = null;
// If followee is remote, send a follow activity immediately
if (followee.IsRemoteUser)
{
relationshipId = Guid.NewGuid();
var activity = activityRenderer.RenderFollow(follower, followee, relationshipId);
await deliverSvc.DeliverToAsync(activity, follower, followee);
}
// Check blocks separately for local/remote follower
if (follower.IsRemoteUser)
{
if (requestId == null)
throw GracefulException.UnprocessableEntity("Cannot process remote follow without requestId");
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");
}
// We have to create a request instead of a follow relationship in these cases
if (followee.IsLocked || followee.IsRemoteUser)
{
// We already have a pending follow request, so we want to update the request id in case it changed
if (await db.FollowRequests.AnyAsync(p => p.Follower == follower && p.Followee == followee))
{
await db.FollowRequests.Where(p => p.Follower == follower && p.Followee == followee)
.ExecuteUpdateAsync(p => p.SetProperty(i => i.RequestId, _ => requestId));
}
else
{
// There already is an established follow relationship
if (await db.Followings.AnyAsync(p => p.Follower == follower && p.Followee == followee))
{
// If the follower is remote, immediately send an accept activity, otherwise do nothing
if (follower.IsRemoteUser)
{
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);
}
}
// Otherwise, create a new request and insert it into the database
else
{
var autoAccept = followee.IsLocalUser
&& await db.Followings.AnyAsync(p => p.Follower == followee
&& p.Followee == follower
&& p.Follower.UserSettings != null
&& p.Follower.UserSettings
.AutoAcceptFollowed);
// Followee has auto accept enabled & is already following the follower user
if (autoAccept)
{
if (follower.IsRemoteUser)
{
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);
}
var following = new Following
{
Id = IdHelpers.GenerateSnowflakeId(),
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.GenerateFollowNotificationAsync(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));
if (follower.IsRemoteUser)
{
await EnqueueBackfillTaskAsync(follower);
// @formatter:off
_ = followupTaskSvc.ExecuteTaskAsync("IncrementInstanceIncomingFollowsCounter", async provider =>
{
var bgDb = provider.GetRequiredService<DatabaseContext>();
var bgInstanceSvc = provider.GetRequiredService<InstanceService>();
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));
});
// @formatter:on
}
return;
}
var request = new FollowRequest
{
Id = IdHelpers.GenerateSnowflakeId(),
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,
RelationshipId = relationshipId
};
await db.AddAsync(request);
await db.SaveChangesAsync();
await notificationSvc.GenerateFollowRequestReceivedNotificationAsync(request);
if (follower.IsRemoteUser) await EnqueueBackfillTaskAsync(follower);
}
}
}
// Followee is local and not locked
else
{
// If there isn't an established follow relationship already, create one
if (!await db.Followings.AnyAsync(p => p.Follower == follower && p.Followee == followee))
{
var following = new Following
{
Id = IdHelpers.GenerateSnowflakeId(),
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.GenerateFollowNotificationAsync(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));
if (follower.IsRemoteUser)
{
await EnqueueBackfillTaskAsync(follower);
_ = followupTaskSvc.ExecuteTaskAsync("IncrementInstanceIncomingFollowsCounter", async provider =>
{
var bgDb = provider.GetRequiredService<DatabaseContext>();
var bgInstanceSvc = provider.GetRequiredService<InstanceService>();
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 is remote, send an accept activity
if (follower.IsRemoteUser)
{
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);
}
}
}
public async Task EnqueueBackfillTaskAsync(User user)
{
var cfg = backfillConfig.Value.User;
// return immediately if backfilling is not enabled
if (!cfg.Enabled) return;
// don't try to schedule a backfill for local users
if (user.IsLocalUser) return;
// we don't need to backfill anyone we have followers for since we'll get their posts delivered to us
var needBackfill = await db.Users.AnyAsync(u => u.Id == user.Id
&& !u.Followers.Any()
&& u.Outbox != null
&& (u.OutboxFetchedAt == null || u.OutboxFetchedAt <= DateTime.UtcNow - cfg.RefreshAfterTimeSpan));
if (!needBackfill) return;
var jobData = new BackfillUserJobData
{
UserId = user.Id
};
await queueSvc.BackfillUserQueue.EnqueueAsync(jobData, mutex: $"backfill-user:{user.Id}");
}
/// <remarks>
/// Make sure to call .PrecomputeRelationshipData(user) on the database query for the followee
/// </remarks>
public async Task RemoveFromFollowersAsync(User user, User follower)
{
if ((follower.PrecomputedIsFollowing ?? false) && follower.IsRemoteUser)
{
var activity = activityRenderer.RenderReject(userRenderer.RenderLite(user),
activityRenderer.RenderFollow(follower, user, null));
await deliverSvc.DeliverToAsync(activity, user, follower);
}
if (follower.PrecomputedIsFollowing ?? false)
{
var followers = await db.Followings
.Where(p => p.Followee == user && p.Follower == follower)
.ToListAsync();
await db.Users
.Where(p => p.Id == user.Id)
.ExecuteUpdateAsync(p => p.SetProperty(i => i.FollowersCount,
i => i.FollowersCount - followers.Count));
await db.Users
.Where(p => p.Id == follower.Id)
.ExecuteUpdateAsync(p => p.SetProperty(i => i.FollowingCount,
i => i.FollowingCount - followers.Count));
db.RemoveRange(followers);
await db.SaveChangesAsync();
if (follower.IsRemoteUser)
{
_ = followupTaskSvc.ExecuteTaskAsync("DecrementInstanceIncomingFollowsCounter", async provider =>
{
var bgDb = provider.GetRequiredService<DatabaseContext>();
var bgInstanceSvc = provider.GetRequiredService<InstanceService>();
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));
});
}
follower.PrecomputedIsFollowedBy = false;
eventSvc.RaiseUserUnfollowed(this, follower, user);
}
}
/// <remarks>
/// Make sure to call .PrecomputeRelationshipData(user) on the database query for the followee
/// </remarks>
public async Task UnfollowUserAsync(User user, User followee)
{
if (((followee.PrecomputedIsFollowedBy ?? false) || (followee.PrecomputedIsRequestedBy ?? false))
&& followee.IsRemoteUser)
{
var relationshipId = await db.Followings.Where(p => p.Follower == user && p.Followee == followee)
.Select(p => p.RelationshipId)
.FirstOrDefaultAsync()
?? await db.FollowRequests.Where(p => p.Follower == user && p.Followee == followee)
.Select(p => p.RelationshipId)
.FirstOrDefaultAsync();
var activity = activityRenderer.RenderUnfollow(user, followee, relationshipId);
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.IsRemoteUser)
{
_ = followupTaskSvc.ExecuteTaskAsync("DecrementInstanceOutgoingFollowsCounter", async provider =>
{
var bgDb = provider.GetRequiredService<DatabaseContext>();
var bgInstanceSvc = provider.GetRequiredService<InstanceService>();
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.Value && !force) return;
if (KeyedLocker.IsInUse($"pinnedNotes:{user.Id}")) return;
_ = followupTaskSvc.ExecuteTaskAsync("UpdateUserPinnedNotes", async provider =>
{
using (await KeyedLocker.LockAsync($"pinnedNotes:{user.Id}"))
{
var bgDb = provider.GetRequiredService<DatabaseContext>();
var bgNoteSvc = provider.GetRequiredService<NoteService>();
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.Value && !force) return;
if (KeyedLocker.IsInUse($"pinnedNotes:{user.Id}")) return;
_ = followupTaskSvc.ExecuteTaskAsync("UpdateUserPinnedNotes", async provider =>
{
using (await KeyedLocker.LockAsync($"pinnedNotes:{user.Id}"))
{
var bgDb = provider.GetRequiredService<DatabaseContext>();
var bgNoteSvc = provider.GetRequiredService<NoteService>();
var bgUser = await bgDb.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == user.Id);
if (bgUser == null) return;
var bgFetch = provider.GetRequiredService<ActivityPub.ActivityFetcherService>();
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<User> UpdateProfileMentionsAsync(
User user, ASActor? actor, bool force = false, bool wait = false
)
{
if (followupTaskSvc.IsBackgroundWorker.Value && !force) return user;
if (KeyedLocker.IsInUse($"profileMentions:{user.Id}")) return user;
var success = false;
var task = followupTaskSvc.ExecuteTaskAsync("UpdateProfileMentionsInBackground", async provider =>
{
using (await KeyedLocker.LockAsync($"profileMentions:{user.Id}"))
{
var bgDbContext = provider.GetRequiredService<DatabaseContext>();
var bgMentionsResolver = provider.GetRequiredService<UserProfileMentionsResolver>();
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.ResolveMentionsAsync(actor, bgUser.Host);
var fields = actor.Attachments != null
? await actor.Attachments
.OfType<ASField>()
.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)).Mfm
})
.AwaitAllAsync()
: null;
var description = actor.MkSummary != null
? mentionsResolver.ResolveMentions(actor.MkSummary, bgUser.Host, mentions, splitDomainMapping)
: (await MfmConverter.FromHtmlAsync(actor.Summary, mentions)).Mfm;
bgUser.UserProfile.Mentions = mentions;
bgUser.UserProfile.Fields = fields?.ToArray() ?? [];
bgUser.UserProfile.Description = description;
}
else
{
bgUser.UserProfile.Mentions =
await bgMentionsResolver.ResolveMentionsAsync(bgUser.UserProfile.Fields,
bgUser.UserProfile.Description, bgUser.Host);
}
bgUser.UserProfile.MentionsResolved = true;
bgDbContext.Update(bgUser.UserProfile);
await bgDbContext.SaveChangesAsync();
success = true;
}
});
if (wait)
await task;
else
await task.SafeWaitAsync(TimeSpan.FromMilliseconds(500));
if (success)
await db.ReloadEntityRecursivelyAsync(user);
return user;
}
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataUsage", Justification = "Same as above")]
public async Task<User> VerifyProfileFieldsAsync(User user, string? profileUrl)
{
profileUrl ??= user.GetPublicUrl(instance.Value);
foreach (var userProfileField in user.UserProfile!.Fields)
{
var url = userProfileField.Value;
if (url.StartsWith('[') && url.EndsWith(')'))
{
var idx = url.IndexOf("](", StringComparison.Ordinal);
if (idx != -1 && url.Length >= idx + "https://".Length)
url = url[(idx + 2)..^1];
}
if (
!url.StartsWith("https://")
|| !Uri.TryCreate(url, UriKind.Absolute, out var uri)
|| uri is not { Scheme: "https" }
)
{
userProfileField.IsVerified = false;
continue;
}
try
{
const int maxLength = 1_000_000;
var res = await httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
if (
res is not
{
IsSuccessStatusCode: true,
Content.Headers: { ContentType.MediaType: "text/html", ContentLength: null or <= maxLength }
}
)
{
logger.LogDebug("Skipping profile field link verification of {url} for user {userId}: precondition failed",
uri, user.Id);
userProfileField.IsVerified = false;
continue;
}
var contentLength = res.Content.Headers.ContentLength;
var stream = await res.Content.ReadAsStreamAsync()
.ContinueWithResult(p => p.GetSafeStreamOrNullAsync(maxLength, contentLength));
if (stream == Stream.Null) throw new Exception("Response size limit exceeded");
var document = await new HtmlParser().ParseDocumentAsync(stream);
var headLinks = document.Head?.Children.Where(el => el.NodeName.ToLower() == "link").ToList() ?? [];
userProfileField.IsVerified =
headLinks.Concat(document.Links)
.Any(a => (a.GetAttribute("rel")?.Contains("me")
?? false)
&& a.GetAttribute("href") == profileUrl
|| a.GetAttribute("href") == user.GetUriOrPublicUri(instance.Value));
}
catch (Exception e)
{
logger.LogDebug("Failed to verify profile field url {url} for user {userId}: {e}", uri, user.Id, e);
userProfileField.IsVerified = false;
}
}
return user;
}
private List<string> ResolveHashtags(IMfmNode[]? parsedText, ASActor? actor = null)
{
List<string> tags = [];
if (parsedText != null)
{
tags = parsedText
.SelectMany(p => p.Children.Append(p))
.OfType<MfmHashtagNode>()
.Select(p => p.Hashtag.ToLowerInvariant())
.Select(p => p.Trim('#'))
.Distinct()
.ToList();
}
var extracted = actor?.Tags?.OfType<ASHashtag>()
.Select(p => p.Name?.ToLowerInvariant())
.Where(p => p != null)
.Cast<string>()
.Select(p => p.Trim('#'))
.Distinct()
.ToList();
if (extracted != null)
tags.AddRange(extracted);
if (tags.Count == 0) return [];
tags = tags.Distinct().ToList();
_ = followupTaskSvc.ExecuteTaskAsync("UpdateHashtagsTable", async provider =>
{
var bgDb = provider.GetRequiredService<DatabaseContext>();
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.GenerateSnowflakeId(), 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.GenerateSnowflakeId(),
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.GenerateSnowflakeId(),
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);
}
}
public async Task AddAliasAsync(User user, User alias)
{
if (user.IsRemoteUser) throw GracefulException.BadRequest("Cannot add alias for remote user");
if (user.Id == alias.Id) throw GracefulException.BadRequest("You cannot add an alias to yourself");
user.AlsoKnownAs ??= [];
var uri = alias.Uri ?? alias.GetPublicUri(instance.Value);
if (!user.AlsoKnownAs.Contains(uri)) user.AlsoKnownAs.Add(uri);
await UpdateLocalUserAsync(user, user.AvatarId, user.BannerId);
}
public async Task RemoveAliasAsync(User user, string aliasUri)
{
if (user.IsRemoteUser) throw GracefulException.BadRequest("Cannot manage aliases for remote user");
if (user.AlsoKnownAs is null or []) return;
if (!user.AlsoKnownAs.Contains(aliasUri)) return;
user.AlsoKnownAs.RemoveAll(p => p == aliasUri);
await UpdateLocalUserAsync(user, user.AvatarId, user.BannerId);
}
public async Task MoveToUserAsync(User source, User target)
{
if (source.IsRemoteUser) throw GracefulException.BadRequest("Cannot initiate move for remote user");
if (source.Id == target.Id) throw GracefulException.BadRequest("You cannot migrate to yourself");
target = await UpdateUserAsync(target, force: true);
if (target.AlsoKnownAs is null || !target.AlsoKnownAs.Contains(source.GetPublicUri(instance.Value)))
throw GracefulException.BadRequest("Target user has not added you as an account alias");
var sourceUri = source.Uri ?? source.GetPublicUri(instance.Value);
var targetUri = target.Uri ?? target.GetPublicUri(instance.Value);
if (source.MovedToUri is not null && source.MovedToUri != targetUri)
throw GracefulException.BadRequest("You can only initiate repeated migrations to the same target account");
source.MovedToUri = targetUri;
await db.SaveChangesAsync();
var move = activityRenderer.RenderMove(userRenderer.RenderLite(source), userRenderer.RenderLite(target));
await deliverSvc.DeliverToFollowersAsync(move, source, []);
await MoveRelationshipsAsync(source, target, sourceUri, targetUri);
}
public async Task UndoMoveAsync(User user)
{
if (user.MovedToUri is null) return;
user.MovedToUri = null;
await UpdateLocalUserAsync(user, user.AvatarId, user.BannerId);
}
private async Task<string?> UpdateUserHostAsync(User user)
{
if (user.IsLocalUser || 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.GetAcctUri(res);
if (acct == null)
{
logger.LogWarning("Updating split domain host failed for user {id}: acct was null", user.Id);
return user.Host;
}
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.GetAcctUri(res))
{
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];
}
public async Task MoveRelationshipsAsync(User source, User target, string sourceUri, string targetUri)
{
var followers = db.Followings
.Where(p => p.Followee == source && p.Follower.IsLocalUser)
.Select(p => p.Follower)
.AsChunkedAsyncEnumerable(50, p => p.Id, hook: p => p.PrecomputeRelationshipData(source));
await foreach (var follower in followers)
{
try
{
if (follower.Id == target.Id) continue;
await FollowUserAsync(follower, target);
// We need to transfer the precomputed properties to the source user for each follower so that the unfollow method works correctly
source.PrecomputedIsFollowedBy = follower.PrecomputedIsFollowing;
source.PrecomputedIsRequestedBy = follower.PrecomputedIsRequested;
await UnfollowUserAsync(follower, source);
}
catch (Exception e)
{
logger.LogWarning("Failed to process move ({sourceUri} -> {targetUri}) for follower {id}: {error}",
sourceUri, targetUri, follower.Id, e);
}
}
var blocks = db.Blockings
.Where(p => p.Blockee == source)
.Select(p => p.Blocker)
.AsChunkedAsyncEnumerable(50, p => p.Id, p => p.PrecomputeRelationshipData(source));
await foreach (var blocker in blocks)
{
try
{
if (blocker.Id == target.Id) continue;
// We need to transfer the precomputed properties to the target user for each blocker so that the block method works correctly
target.PrecomputedIsBlockedBy = blocker.PrecomputedIsBlocking;
await BlockUserAsync(blocker, target);
}
catch (Exception e)
{
logger.LogWarning("Failed to process move ({sourceUri} -> {targetUri}) for blocker {id}: {error}",
sourceUri, targetUri, blocker.Id, e);
}
}
var mutes = db.Mutings
.Where(p => p.Mutee == source && p.ExpiresAt == null)
.Select(p => p.Muter)
.AsChunkedAsyncEnumerable(50, p => p.Id, p => p.PrecomputeRelationshipData(source));
await foreach (var muter in mutes)
{
try
{
if (muter.Id == target.Id) continue;
// We need to transfer the precomputed properties to the target user for each muter so that the block method works correctly
target.PrecomputedIsMutedBy = muter.PrecomputedIsMuting;
await MuteUserAsync(muter, target, null);
}
catch (Exception e)
{
logger.LogWarning("Failed to process move ({sourceUri} -> {targetUri}) for muter {id}: {error}",
sourceUri, targetUri, muter.Id, e);
}
}
if (source.IsRemoteUser || target.IsRemoteUser) return;
var following = db.Followings
.Where(p => p.Follower == source)
.Select(p => p.Follower)
.AsChunkedAsyncEnumerable(50, p => p.Id, hook: p => p.PrecomputeRelationshipData(source));
await foreach (var followee in following)
{
try
{
await FollowUserAsync(target, followee);
await UnfollowUserAsync(source, followee);
}
catch (Exception e)
{
logger.LogWarning("Failed to process move ({sourceUri} -> {targetUri}) for followee {id}: {error}",
sourceUri, targetUri, followee.Id, e);
}
}
blocks = db.Blockings
.Where(p => p.Blocker == source)
.Select(p => p.Blockee)
.AsChunkedAsyncEnumerable(50, p => p.Id, p => p.PrecomputeRelationshipData(source));
await foreach (var blockee in blocks)
{
try
{
if (blockee.Id == target.Id) continue;
await BlockUserAsync(blockee, target);
}
catch (Exception e)
{
logger.LogWarning("Failed to process move ({sourceUri} -> {targetUri}) for blockee {id}: {error}",
sourceUri, targetUri, blockee.Id, e);
}
}
mutes = db.Mutings
.Where(p => p.Muter == source && p.ExpiresAt == null)
.Select(p => p.Mutee)
.AsChunkedAsyncEnumerable(50, p => p.Id, p => p.PrecomputeRelationshipData(source));
await foreach (var mutee in mutes)
{
try
{
if (mutee.Id == target.Id) continue;
await MuteUserAsync(mutee, target, null);
}
catch (Exception e)
{
logger.LogWarning("Failed to process move ({sourceUri} -> {targetUri}) for mutee {id}: {error}",
sourceUri, targetUri, mutee.Id, e);
}
}
}
public async Task SuspendUserAsync(User user)
{
if (user.IsSuspended) return;
user.IsSuspended = true;
await db.SaveChangesAsync();
}
public async Task UnsuspendUserAsync(User user)
{
if (!user.IsSuspended) return;
user.IsSuspended = false;
await db.SaveChangesAsync();
}
}