[backend/federation] Refactor NoteServer to take a User param instead of an ASActor to save DB roundtrips

This commit is contained in:
Laura Hausmann 2024-02-22 00:14:25 +01:00
parent 19ffbe7814
commit c35d503c12
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
3 changed files with 65 additions and 76 deletions

View file

@ -65,7 +65,7 @@ public class ActivityHandlerService(
//TODO: should we handle other types of creates?
if (activity.Object is not ASNote note)
throw GracefulException.UnprocessableEntity("Create activity object is invalid");
await noteSvc.ProcessNoteAsync(note, activity.Actor);
await noteSvc.ProcessNoteAsync(note, resolvedActor);
return;
}
case ASDelete:
@ -86,7 +86,7 @@ public class ActivityHandlerService(
throw GracefulException.UnprocessableEntity("Delete activity object is invalid");
if (await db.Notes.AnyAsync(p => p.Uri == tombstone.Id))
{
await noteSvc.DeleteNoteAsync(tombstone, activity.Actor);
await noteSvc.DeleteNoteAsync(tombstone, resolvedActor);
return;
}
@ -105,28 +105,28 @@ public class ActivityHandlerService(
{
if (activity.Object is not ASActor obj)
throw GracefulException.UnprocessableEntity("Follow activity object is invalid");
await FollowAsync(obj, activity.Actor, activity.Id);
await FollowAsync(obj, activity.Actor, resolvedActor, activity.Id);
return;
}
case ASUnfollow:
{
if (activity.Object is not ASActor obj)
throw GracefulException.UnprocessableEntity("Unfollow activity object is invalid");
await UnfollowAsync(obj, activity.Actor);
await UnfollowAsync(obj, resolvedActor);
return;
}
case ASAccept:
{
if (activity.Object is not ASFollow obj)
throw GracefulException.UnprocessableEntity("Accept activity object is invalid");
await AcceptAsync(obj, activity.Actor);
await AcceptAsync(obj, resolvedActor);
return;
}
case ASReject:
{
if (activity.Object is not ASFollow obj)
throw GracefulException.UnprocessableEntity("Reject activity object is invalid");
await RejectAsync(obj, activity.Actor);
await RejectAsync(obj, resolvedActor);
return;
}
case ASUndo:
@ -134,10 +134,10 @@ public class ActivityHandlerService(
switch (activity.Object)
{
case ASFollow { Object: ASActor followee }:
await UnfollowAsync(followee, activity.Actor);
await UnfollowAsync(followee, resolvedActor);
return;
case ASLike { Object: ASNote likedNote }:
await noteSvc.UnlikeNoteAsync(likedNote, activity.Actor);
await noteSvc.UnlikeNoteAsync(likedNote, resolvedActor);
return;
default:
throw GracefulException.UnprocessableEntity("Undo activity object is invalid");
@ -147,7 +147,7 @@ public class ActivityHandlerService(
{
if (activity.Object is not ASNote note)
throw GracefulException.UnprocessableEntity("Like activity object is invalid");
await noteSvc.LikeNoteAsync(note, activity.Actor);
await noteSvc.LikeNoteAsync(note, resolvedActor);
return;
}
case ASUpdate:
@ -160,7 +160,7 @@ public class ActivityHandlerService(
await userSvc.UpdateUserAsync(resolvedActor, actor);
return;
case ASNote note:
await noteSvc.ProcessNoteUpdateAsync(note, activity.Actor, resolvedActor);
await noteSvc.ProcessNoteUpdateAsync(note, resolvedActor);
return;
default:
throw GracefulException.UnprocessableEntity("Update activity object is invalid");
@ -233,7 +233,8 @@ public class ActivityHandlerService(
throw GracefulException.UnprocessableEntity("Invalid or unsupported announce object");
var dbNote = await noteSvc.ResolveNoteAsync(note.Id, note.VerifiedFetch ? note : null);
var renote = await noteSvc.CreateNoteAsync(resolvedActor, announce.GetVisibility(activity.Actor), renote: dbNote);
var renote = await noteSvc.CreateNoteAsync(resolvedActor, announce.GetVisibility(activity.Actor),
renote: dbNote);
await notificationSvc.GenerateRenoteNotification(renote);
return;
}
@ -244,9 +245,8 @@ public class ActivityHandlerService(
[SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall",
Justification = "Projectable functions can very much be translated to SQL")]
private async Task FollowAsync(ASActor followeeActor, ASActor followerActor, string requestId)
private async Task FollowAsync(ASActor followeeActor, ASActor followerActor, User follower, string requestId)
{
var follower = await userResolver.ResolveAsync(followerActor.Id);
var followee = await userResolver.ResolveAsync(followeeActor.Id);
if (followee.Host != null) throw new Exception("Cannot process follow for remote followee");
@ -324,10 +324,9 @@ public class ActivityHandlerService(
}
}
private async Task UnfollowAsync(ASActor followeeActor, ASActor followerActor)
private async Task UnfollowAsync(ASActor followeeActor, User follower)
{
//TODO: send reject? or do we not want to copy that part of the old ap core
var follower = await userResolver.ResolveAsync(followerActor.Id);
var followee = await userResolver.ResolveAsync(followeeActor.Id);
await db.FollowRequests.Where(p => p.Follower == follower && p.Followee == followee).ExecuteDeleteAsync();
@ -350,33 +349,32 @@ public class ActivityHandlerService(
}
}
private async Task AcceptAsync(ASFollow obj, ASActor actor)
private async Task AcceptAsync(ASFollow obj, User actor)
{
var prefix = $"https://{config.Value.WebDomain}/follows/";
if (!obj.Id.StartsWith(prefix))
throw GracefulException.UnprocessableEntity($"Object id '{obj.Id}' not a valid follow request id");
var resolvedActor = await userResolver.ResolveAsync(actor.Id);
var ids = obj.Id[prefix.Length..].TrimEnd('/').Split("/");
if (ids.Length != 2 || ids[1] != resolvedActor.Id)
var ids = obj.Id[prefix.Length..].TrimEnd('/').Split("/");
if (ids.Length != 2 || ids[1] != actor.Id)
throw GracefulException
.UnprocessableEntity($"Actor id '{resolvedActor.Id}' doesn't match followee id '{ids[1]}'");
.UnprocessableEntity($"Actor id '{actor.Id}' doesn't match followee id '{ids[1]}'");
var request = await db.FollowRequests
.Include(p => p.Follower.UserProfile)
.Include(p => p.Followee.UserProfile)
.FirstOrDefaultAsync(p => p.Followee == resolvedActor && p.FollowerId == ids[0]);
.FirstOrDefaultAsync(p => p.Followee == actor && p.FollowerId == ids[0]);
if (request == null)
throw GracefulException
.UnprocessableEntity($"No follow request matching follower '{ids[0]}' and followee '{resolvedActor.Id}' found");
.UnprocessableEntity($"No follow request matching follower '{ids[0]}' and followee '{actor.Id}' found");
var following = new Following
{
Id = IdHelpers.GenerateSlowflakeId(),
CreatedAt = DateTime.UtcNow,
Follower = request.Follower,
Followee = resolvedActor,
Followee = actor,
FollowerHost = request.FollowerHost,
FolloweeHost = request.FolloweeHost,
FollowerInbox = request.FollowerInbox,
@ -385,7 +383,7 @@ public class ActivityHandlerService(
FolloweeSharedInbox = request.FolloweeSharedInbox
};
resolvedActor.FollowersCount++;
actor.FollowersCount++;
request.Follower.FollowingCount++;
db.Remove(request);
@ -394,23 +392,22 @@ public class ActivityHandlerService(
await notificationSvc.GenerateFollowRequestAcceptedNotification(request);
}
private async Task RejectAsync(ASFollow follow, ASActor actor)
private async Task RejectAsync(ASFollow follow, User actor)
{
if (follow is not { Actor: not null })
throw GracefulException.UnprocessableEntity("Refusing to reject object with invalid follow object");
var resolvedActor = await userResolver.ResolveAsync(actor.Id);
var resolvedFollower = await userResolver.ResolveAsync(follow.Actor.Id);
if (resolvedFollower is not { Host: null })
throw GracefulException.UnprocessableEntity("Refusing to reject remote follow");
await db.FollowRequests.Where(p => p.Followee == resolvedActor && p.Follower == resolvedFollower)
await db.FollowRequests.Where(p => p.Followee == actor && p.Follower == resolvedFollower)
.ExecuteDeleteAsync();
var count = await db.Followings.Where(p => p.Followee == resolvedActor && p.Follower == resolvedFollower)
var count = await db.Followings.Where(p => p.Followee == actor && p.Follower == resolvedFollower)
.ExecuteDeleteAsync();
if (count > 0)
{
resolvedActor.FollowersCount -= count;
actor.FollowersCount -= count;
resolvedFollower.FollowingCount -= count;
await db.SaveChangesAsync();
}
@ -418,7 +415,7 @@ public class ActivityHandlerService(
await db.Notifications
.Where(p => p.Type == Notification.NotificationType.FollowRequestAccepted)
.Where(p => p.Notifiee == resolvedFollower &&
p.Notifier == resolvedActor)
p.Notifier == actor)
.ExecuteDeleteAsync();
}
}
}

View file

@ -61,25 +61,28 @@ public class ASNote : ASObject
public bool VerifiedFetch = false;
public Note.NoteVisibility GetVisibility(ASActor actor)
public Note.NoteVisibility GetVisibility(User actor)
{
if (actor.Host == null) throw new Exception("Can't get recipients for local actor");
if (To.Any(p => p.Id == $"{Constants.ActivityStreamsNs}#Public"))
return Note.NoteVisibility.Public;
if (Cc.Any(p => p.Id == $"{Constants.ActivityStreamsNs}#Public"))
return Note.NoteVisibility.Home;
if (To.Any(p => p.Id is not null && p.Id == (actor.Followers?.Id ?? actor.Id + "/followers")))
if (To.Any(p => p.Id is not null && p.Id == (actor.FollowersUri ?? actor.Uri + "/followers")))
return Note.NoteVisibility.Followers;
return Note.NoteVisibility.Specified;
}
public List<string> GetRecipients(ASActor actor)
public List<string> GetRecipients(User actor)
{
if (actor.Host == null) throw new Exception("Can't get recipients for local actor");
return To.Concat(Cc)
.Select(p => p.Id)
.Distinct()
.Where(p => p != $"{Constants.ActivityStreamsNs}#Public" &&
p != (actor.Followers?.Id ?? actor.Id + "/followers"))
p != (actor.FollowersUri ?? actor.Uri + "/followers"))
.Where(p => p != null)
.Select(p => p!)
.ToList();

View file

@ -228,7 +228,7 @@ public class NoteService(
await deliverSvc.DeliverToFollowersAsync(activity, note.User, recipients);
}
public async Task DeleteNoteAsync(ASTombstone note, ASActor actor)
public async Task DeleteNoteAsync(ASTombstone note, User actor)
{
// ReSharper disable once EntityFramework.NPlusOne.IncompleteDataQuery (it doesn't know about IncludeCommonProperties())
var dbNote = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Uri == note.Id);
@ -238,24 +238,22 @@ public class NoteService(
return;
}
var user = await userResolver.ResolveAsync(actor.Id);
// ReSharper disable once EntityFramework.NPlusOne.IncompleteDataUsage (same reason as above)
if (dbNote.User != user)
if (dbNote.User != actor)
{
logger.LogDebug("Note '{id}' isn't owned by actor requesting its deletion, skipping", note.Id);
return;
}
logger.LogDebug("Deleting note '{id}' owned by {userId}", note.Id, user.Id);
logger.LogDebug("Deleting note '{id}' owned by {userId}", note.Id, actor.Id);
user.NotesCount--;
actor.NotesCount--;
db.Remove(dbNote);
eventSvc.RaiseNoteDeleted(this, dbNote);
await db.SaveChangesAsync();
}
public async Task<Note> ProcessNoteAsync(ASNote note, ASActor actor)
public async Task<Note> ProcessNoteAsync(ASNote note, User actor)
{
var dbHit = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Uri == note.Id);
@ -269,15 +267,12 @@ public class NoteService(
_resolverHistory.Add(note.Id);
logger.LogDebug("Creating note: {id}", note.Id);
var user = await userResolver.ResolveAsync(actor.Id);
logger.LogDebug("Resolved user to {userId}", user.Id);
// Validate note
if (note.AttributedTo is not { Count: 1 } || note.AttributedTo[0].Id != user.Uri)
if (note.AttributedTo is not { Count: 1 } || note.AttributedTo[0].Id != actor.Uri)
throw GracefulException.UnprocessableEntity("User.Uri doesn't match Note.AttributedTo");
if (user.Uri == null)
if (actor.Uri == null)
throw GracefulException.UnprocessableEntity("User.Uri is null");
if (new Uri(note.Id).IdnHost != new Uri(user.Uri).IdnHost)
if (new Uri(note.Id).IdnHost != new Uri(actor.Uri).IdnHost)
throw GracefulException.UnprocessableEntity("User.Uri host doesn't match Note.Id host");
if (!note.Id.StartsWith("https://"))
throw GracefulException.UnprocessableEntity("Note.Id schema is invalid");
@ -285,7 +280,7 @@ public class NoteService(
throw GracefulException.UnprocessableEntity("Note.Url schema is invalid");
if (note.PublishedAt is null or { Year: < 2007 } || note.PublishedAt > DateTime.Now + TimeSpan.FromDays(3))
throw GracefulException.UnprocessableEntity("Note.PublishedAt is nonsensical");
if (user.IsSuspended)
if (actor.IsSuspended)
throw GracefulException.Forbidden("User is suspended");
//TODO: resolve anything related to the note as well (attachments, emoji, etc)
@ -303,9 +298,9 @@ public class NoteService(
Url = note.Url?.Id, //FIXME: this doesn't seem to work yet
Text = note.MkContent ?? await mfmConverter.FromHtmlAsync(note.Content, mentions),
Cw = note.Summary,
UserId = user.Id,
UserId = actor.Id,
CreatedAt = createdAt,
UserHost = user.Host,
UserHost = actor.Host,
Visibility = note.GetVisibility(actor),
Reply = note.InReplyTo?.Id != null ? await ResolveNoteAsync(note.InReplyTo.Id) : null
};
@ -343,14 +338,14 @@ public class NoteService(
}
var sensitive = (note.Sensitive ?? false) || dbNote.Cw != null;
var files = await ProcessAttachmentsAsync(note.Attachments, user, sensitive);
var files = await ProcessAttachmentsAsync(note.Attachments, actor, sensitive);
if (files.Count != 0)
{
dbNote.FileIds = files.Select(p => p.Id).ToList();
dbNote.AttachedFileTypes = files.Select(p => p.Type).ToList();
}
user.NotesCount++;
actor.NotesCount++;
if (dbNote.Reply != null) dbNote.Reply.RepliesCount++;
await db.Notes.AddAsync(dbNote);
await db.SaveChangesAsync();
@ -364,13 +359,12 @@ public class NoteService(
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataUsage",
Justification = "Inspection doesn't understand IncludeCommonProperties()")]
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataQuery", Justification = "See above")]
public async Task<Note> ProcessNoteUpdateAsync(ASNote note, ASActor actor, User? resolvedActor = null)
public async Task<Note> ProcessNoteUpdateAsync(ASNote note, User actor)
{
var dbNote = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Uri == note.Id);
if (dbNote == null) return await ProcessNoteAsync(note, actor);
resolvedActor ??= await userResolver.ResolveAsync(actor.Id);
if (dbNote.User != resolvedActor)
if (dbNote.User != actor)
throw GracefulException.UnprocessableEntity("Refusing to update note of user other than actor");
if (dbNote.User.IsSuspended)
throw GracefulException.Forbidden("User is suspended");
@ -425,7 +419,7 @@ public class NoteService(
//TODO: handle updated alt text et al
var sensitive = (note.Sensitive ?? false) || dbNote.Cw != null;
var files = await ProcessAttachmentsAsync(note.Attachments, resolvedActor, sensitive);
var files = await ProcessAttachmentsAsync(note.Attachments, actor, sensitive);
dbNote.FileIds = files.Select(p => p.Id).ToList();
dbNote.AttachedFileTypes = files.Select(p => p.Type).ToList();
@ -574,8 +568,7 @@ public class NoteService(
if (res != null) return res;
}
//TODO: we don't need to fetch the actor every time, we can use userResolver here
var actor = await fetchSvc.FetchActorAsync(attrTo.Id);
var actor = await userResolver.ResolveAsync(attrTo.Id);
try
{
@ -615,38 +608,34 @@ public class NoteService(
}
}
public async Task UnlikeNoteAsync(Note note, User user)
public async Task UnlikeNoteAsync(Note note, User actor)
{
var count = await db.NoteLikes.Where(p => p.Note == note && p.User == user).ExecuteDeleteAsync();
var count = await db.NoteLikes.Where(p => p.Note == note && p.User == actor).ExecuteDeleteAsync();
if (count == 0) return;
if (user.Host == null && note.UserHost != null)
if (actor.Host == null && note.UserHost != null)
{
var activity = activityRenderer.RenderUndo(userRenderer.RenderLite(user),
activityRenderer.RenderLike(note, user));
await deliverSvc.DeliverToFollowersAsync(activity, user, [note.User]);
var activity = activityRenderer.RenderUndo(userRenderer.RenderLite(actor),
activityRenderer.RenderLike(note, actor));
await deliverSvc.DeliverToFollowersAsync(activity, actor, [note.User]);
}
eventSvc.RaiseNoteUnliked(this, note, user);
eventSvc.RaiseNoteUnliked(this, note, actor);
await db.Notifications
.Where(p => p.Type == Notification.NotificationType.Like &&
p.Notifiee == note.User &&
p.Notifier == user)
p.Notifier == actor)
.ExecuteDeleteAsync();
}
public async Task LikeNoteAsync(ASNote note, ASActor actor)
public async Task LikeNoteAsync(ASNote note, User actor)
{
var dbNote = await ResolveNoteAsync(note) ?? throw new Exception("Cannot register like for unknown note");
var user = await userResolver.ResolveAsync(actor.Id);
await LikeNoteAsync(dbNote, user);
await LikeNoteAsync(dbNote, actor);
}
public async Task UnlikeNoteAsync(ASNote note, ASActor actor)
public async Task UnlikeNoteAsync(ASNote note, User actor)
{
var dbNote = await ResolveNoteAsync(note) ?? throw new Exception("Cannot unregister like for unknown note");
var user = await userResolver.ResolveAsync(actor.Id);
await UnlikeNoteAsync(dbNote, user);
await UnlikeNoteAsync(dbNote, actor);
}
}
}