1668 lines
62 KiB
C#
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();
|
|
}
|
|
}
|