[backend/federation] Handle note likes & unlikes (ISH-68)

This commit is contained in:
Laura Hausmann 2024-02-14 19:09:46 +01:00
parent e53e61e3ed
commit 5113f83c9f
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
17 changed files with 6248 additions and 42 deletions

View file

@ -31,12 +31,13 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not
var res = await db.Notifications
.IncludeCommonProperties()
.Where(p => p.Notifiee == user)
.Where(p => p.Note != null)
.Where(p => p.Type == NotificationType.Follow
|| p.Type == NotificationType.Mention
|| p.Type == NotificationType.Reply
|| p.Type == NotificationType.Renote
|| p.Type == NotificationType.Quote
|| p.Type == NotificationType.Reaction
|| p.Type == NotificationType.Like
|| p.Type == NotificationType.PollEnded
|| p.Type == NotificationType.FollowRequestReceived)
.EnsureNoteVisibilityFor(p => p.Note, user)

View file

@ -21,6 +21,8 @@ public class NotificationRenderer(NoteRenderer noteRenderer, UserRenderer userRe
var notifier = accounts?.FirstOrDefault(p => p.Id == dbNotifier.Id) ??
await userRenderer.RenderAsync(dbNotifier);
//TODO: specially handle quotes
var res = new Notification {
Id = notification.Id,
Type = Notification.EncodeType(notification.Type),

View file

@ -21,7 +21,7 @@ public class Notification : IEntity {
NotificationType.Reply => "mention",
NotificationType.Renote => "renote",
NotificationType.Quote => "reblog",
NotificationType.Reaction => "favourite",
NotificationType.Like => "favourite",
NotificationType.PollEnded => "poll",
NotificationType.FollowRequestReceived => "follow_request",

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
public partial class AddLikeNotificationType : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:Enum:antenna_src_enum", "home,all,users,list,group,instances")
.Annotation("Npgsql:Enum:note_visibility_enum", "public,home,followers,specified")
.Annotation("Npgsql:Enum:notification_type_enum", "follow,mention,reply,renote,quote,like,reaction,pollVote,pollEnded,receiveFollowRequest,followRequestAccepted,groupInvited,app")
.Annotation("Npgsql:Enum:page_visibility_enum", "public,followers,specified")
.Annotation("Npgsql:Enum:relay_status_enum", "requesting,accepted,rejected")
.Annotation("Npgsql:Enum:user_profile_ffvisibility_enum", "public,followers,private")
.Annotation("Npgsql:PostgresExtension:pg_trgm", ",,")
.OldAnnotation("Npgsql:Enum:antenna_src_enum", "home,all,users,list,group,instances")
.OldAnnotation("Npgsql:Enum:note_visibility_enum", "public,home,followers,specified")
.OldAnnotation("Npgsql:Enum:notification_type_enum", "follow,mention,reply,renote,quote,reaction,pollVote,pollEnded,receiveFollowRequest,followRequestAccepted,groupInvited,app")
.OldAnnotation("Npgsql:Enum:page_visibility_enum", "public,followers,specified")
.OldAnnotation("Npgsql:Enum:relay_status_enum", "requesting,accepted,rejected")
.OldAnnotation("Npgsql:Enum:user_profile_ffvisibility_enum", "public,followers,private")
.OldAnnotation("Npgsql:PostgresExtension:pg_trgm", ",,");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:Enum:antenna_src_enum", "home,all,users,list,group,instances")
.Annotation("Npgsql:Enum:note_visibility_enum", "public,home,followers,specified")
.Annotation("Npgsql:Enum:notification_type_enum", "follow,mention,reply,renote,quote,reaction,pollVote,pollEnded,receiveFollowRequest,followRequestAccepted,groupInvited,app")
.Annotation("Npgsql:Enum:page_visibility_enum", "public,followers,specified")
.Annotation("Npgsql:Enum:relay_status_enum", "requesting,accepted,rejected")
.Annotation("Npgsql:Enum:user_profile_ffvisibility_enum", "public,followers,private")
.Annotation("Npgsql:PostgresExtension:pg_trgm", ",,")
.OldAnnotation("Npgsql:Enum:antenna_src_enum", "home,all,users,list,group,instances")
.OldAnnotation("Npgsql:Enum:note_visibility_enum", "public,home,followers,specified")
.OldAnnotation("Npgsql:Enum:notification_type_enum", "follow,mention,reply,renote,quote,like,reaction,pollVote,pollEnded,receiveFollowRequest,followRequestAccepted,groupInvited,app")
.OldAnnotation("Npgsql:Enum:page_visibility_enum", "public,followers,specified")
.OldAnnotation("Npgsql:Enum:relay_status_enum", "requesting,accepted,rejected")
.OldAnnotation("Npgsql:Enum:user_profile_ffvisibility_enum", "public,followers,private")
.OldAnnotation("Npgsql:PostgresExtension:pg_trgm", ",,");
}
}
}

View file

@ -26,7 +26,7 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "antenna_src_enum", new[] { "home", "all", "users", "list", "group", "instances" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "note_visibility_enum", new[] { "public", "home", "followers", "specified" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "notification_type_enum", new[] { "follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "notification_type_enum", new[] { "follow", "mention", "reply", "renote", "quote", "like", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "page_visibility_enum", new[] { "public", "followers", "specified" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "relay_status_enum", new[] { "requesting", "accepted", "rejected" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "user_profile_ffvisibility_enum", new[] { "public", "followers", "private" });

View file

@ -109,6 +109,7 @@ public class Notification : IEntity {
[PgName("reply")] Reply,
[PgName("renote")] Renote,
[PgName("quote")] Quote,
[PgName("like")] Like,
[PgName("reaction")] Reaction,
[PgName("pollVote")] PollVote,
[PgName("pollEnded")] PollEnded,

View file

@ -0,0 +1,8 @@
using Iceshrimp.Backend.Core.Database.Tables;
namespace Iceshrimp.Backend.Core.Events;
public class NoteInteraction {
public required Note Note;
public required User User;
}

View file

@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
@ -12,6 +13,8 @@ using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Core.Federation.ActivityPub;
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameter",
Justification = "We want to enforce AS types, so we can't use the base type here")]
public class ActivityHandlerService(
ILogger<ActivityHandlerService> logger,
NoteService noteSvc,
@ -31,7 +34,8 @@ public class ActivityHandlerService(
throw GracefulException.UnprocessableEntity("Instance is blocked");
if (activity.Object == null)
throw GracefulException.UnprocessableEntity("Activity object is null");
if (activity.Object.IsUnresolved)
// Resolve object & children
activity.Object = await resolver.ResolveObject(activity.Object) ??
throw GracefulException.UnprocessableEntity("Failed to resolve activity object");
@ -64,34 +68,45 @@ public class ActivityHandlerService(
throw GracefulException.UnprocessableEntity("Delete activity object is unknown or invalid");
}
case ASFollow: {
if (activity.Object is not { } obj)
if (activity.Object is not ASActor obj)
throw GracefulException.UnprocessableEntity("Follow activity object is invalid");
await FollowAsync(obj, activity.Actor, activity.Id);
return;
}
case ASUnfollow: {
if (activity.Object is not { } obj)
if (activity.Object is not ASActor obj)
throw GracefulException.UnprocessableEntity("Unfollow activity object is invalid");
await UnfollowAsync(obj, activity.Actor);
return;
}
case ASAccept: {
if (activity.Object is not { } obj)
if (activity.Object is not ASFollow obj)
throw GracefulException.UnprocessableEntity("Accept activity object is invalid");
await AcceptAsync(obj, activity.Actor);
return;
}
case ASReject: {
if (activity.Object is not { } obj)
if (activity.Object is not ASFollow obj)
throw GracefulException.UnprocessableEntity("Reject activity object is invalid");
await RejectAsync(obj, activity.Actor);
return;
}
case ASUndo: {
//TODO: what other types of undo objects are there?
if (activity.Object is not ASActivity { Type: ASActivity.Types.Follow, Object: not null } undoActivity)
switch (activity.Object) {
case ASFollow { Object: ASActor followee }:
await UnfollowAsync(followee, activity.Actor);
return;
case ASLike { Object: ASNote likedNote }:
await noteSvc.UnlikeNoteAsync(likedNote, activity.Actor);
return;
default:
throw new NotImplementedException("Undo activity object is invalid");
await UnfollowAsync(undoActivity.Object, activity.Actor);
}
}
case ASLike: {
if (activity.Object is not ASNote note)
throw GracefulException.UnprocessableEntity("Like activity object is invalid");
await noteSvc.LikeNoteAsync(note, activity.Actor);
return;
}
default: {
@ -100,7 +115,7 @@ public class ActivityHandlerService(
}
}
private async Task FollowAsync(ASObject followeeActor, ASObject followerActor, string requestId) {
private async Task FollowAsync(ASActor followeeActor, ASActor followerActor, string requestId) {
var follower = await userResolver.ResolveAsync(followerActor.Id);
var followee = await userResolver.ResolveAsync(followeeActor.Id);
@ -163,7 +178,7 @@ public class ActivityHandlerService(
}
}
private async Task UnfollowAsync(ASObject followeeActor, ASObject followerActor) {
private async Task UnfollowAsync(ASActor followeeActor, ASActor followerActor) {
//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);
@ -180,7 +195,7 @@ public class ActivityHandlerService(
}
}
private async Task AcceptAsync(ASObject obj, ASObject actor) {
private async Task AcceptAsync(ASFollow obj, ASActor 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");
@ -220,8 +235,8 @@ public class ActivityHandlerService(
await db.SaveChangesAsync();
}
private async Task RejectAsync(ASObject obj, ASObject actor) {
if (obj is not ASFollow { Actor: not null } follow)
private async Task RejectAsync(ASFollow follow, ASActor 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);

View file

@ -9,9 +9,15 @@ namespace Iceshrimp.Backend.Core.Federation.ActivityPub;
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor",
Justification = "We need IOptionsSnapshot for config hot reload")]
public class FederationControlService(IOptionsSnapshot<Config.SecuritySection> options, DatabaseContext db) {
public class FederationControlService(
IOptionsSnapshot<Config.SecuritySection> options,
IOptions<Config.InstanceSection> instance,
DatabaseContext db
) {
//TODO: we need some level of caching here
public async Task<bool> ShouldBlockAsync(params string[] hosts) {
if (hosts.All(p => p == instance.Value.WebDomain || p == instance.Value.AccountDomain)) return false;
hosts = hosts.Select(p => p.StartsWith("http://") || p.StartsWith("https://") ? new Uri(p).Host : p)
.Select(p => p.ToPunycode())
.ToArray();

View file

@ -1,6 +1,8 @@
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Core.Federation.ActivityPub;
@ -8,15 +10,26 @@ public class ObjectResolver(
ILogger<ObjectResolver> logger,
ActivityFetcherService fetchSvc,
DatabaseContext db,
FederationControlService federationCtrl
FederationControlService federationCtrl,
IOptions<Config.InstanceSection> config
) {
public async Task<ASObject?> ResolveObject(ASObjectBase baseObj) {
if (baseObj is ASObject obj) return obj;
public async Task<ASObject?> ResolveObject(ASObjectBase baseObj, int recurse = 5) {
if (baseObj is ASActivity { Object.IsUnresolved: true } activity && recurse > 0) {
activity.Object = await ResolveObject(activity.Object, --recurse);
return await ResolveObject(activity, recurse);
}
if (baseObj is ASObject { IsUnresolved: false } obj)
return obj;
if (baseObj.Id == null) {
logger.LogDebug("Refusing to resolve object with null id property");
return null;
}
if (baseObj.Id.StartsWith($"https://{config.Value.WebDomain}/notes/"))
return new ASNote { Id = baseObj.Id };
if (baseObj.Id.StartsWith($"https://{config.Value.WebDomain}/users/"))
return new ASActor { Id = baseObj.Id };
if (await federationCtrl.ShouldBlockAsync(baseObj.Id)) {
logger.LogDebug("Instance is blocked");
return null;

View file

@ -55,6 +55,10 @@ public class ASUndo : ASActivity {
public ASUndo() => Type = Types.Undo;
}
public class ASLike : ASActivity {
public ASLike() => Type = Types.Like;
}
//TODO: add the rest
public sealed class ASActivityConverter : ASSerializer.ListSingleObjectConverter<ASActivity>;

View file

@ -8,7 +8,12 @@ using JR = Newtonsoft.Json.JsonRequiredAttribute;
namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
public class ASObject : ASObjectBase {
[J("@id")] [JR] public new required string Id { get; set; }
[J("@id")]
[JR]
public new required string Id {
get => base.Id ?? throw new NullReferenceException("base.Id should never be null on a required property");
set => base.Id = value;
}
[J("@type")]
[JC(typeof(StringListSingleConverter))]
@ -34,7 +39,7 @@ public class ASObject : ASObjectBase {
ASActivity.Types.Accept => token.ToObject<ASAccept>(),
ASActivity.Types.Reject => token.ToObject<ASReject>(),
ASActivity.Types.Undo => token.ToObject<ASUndo>(),
ASActivity.Types.Like => token.ToObject<ASActivity>(),
ASActivity.Types.Like => token.ToObject<ASLike>(),
_ => token.ToObject<ASObject>()
},
JTokenType.Array => Deserialize(token.First()),

View file

@ -1,11 +1,28 @@
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Events;
namespace Iceshrimp.Backend.Core.Services;
public class EventService {
public event EventHandler<Note> NotePublished;
public event EventHandler<string> NoteDeleted;
public event EventHandler<Note>? NotePublished;
public event EventHandler<string>? NoteDeleted;
public event EventHandler<NoteInteraction>? NoteLiked;
public event EventHandler<NoteInteraction>? NoteUnliked;
public event EventHandler<Notification>? Notification;
public void RaiseNotePublished(object? sender, Note note) => NotePublished.Invoke(sender, note);
public void RaiseNoteDeleted(object? sender, Note note) => NoteDeleted.Invoke(sender, note.Id);
public void RaiseNotePublished(object? sender, Note note) => NotePublished?.Invoke(sender, note);
public void RaiseNoteDeleted(object? sender, Note note) => NoteDeleted?.Invoke(sender, note.Id);
public void RaiseNotification(object? sender, Notification notification) =>
Notification?.Invoke(sender, notification);
public void RaiseNotifications(object? sender, IEnumerable<Notification> notifications) {
foreach (var notification in notifications) Notification?.Invoke(sender, notification);
}
public void RaiseNoteLiked(object? sender, Note note, User user) =>
NoteLiked?.Invoke(sender, new NoteInteraction { Note = note, User = user });
public void RaiseNoteUnliked(object? sender, Note note, User user) =>
NoteUnliked?.Invoke(sender, new NoteInteraction { Note = note, User = user });
}

View file

@ -303,7 +303,7 @@ public class NoteService(
return result.Where(p => p != null).Cast<DriveFile>().ToList();
}
public async Task<Note?> ResolveNoteAsync(string uri) {
public async Task<Note?> ResolveNoteAsync(string uri, ASNote? fetchedNote = null) {
//TODO: is this enough to prevent DoS attacks?
if (_recursionLimit-- <= 0)
throw GracefulException.UnprocessableEntity("Refusing to resolve threads this long");
@ -312,13 +312,16 @@ public class NoteService(
_resolverHistory.Add(uri);
var note = uri.StartsWith($"https://{config.Value.WebDomain}/notes/")
? await db.Notes.FirstOrDefaultAsync(p => p.Id ==
? await db.Notes.IncludeCommonProperties()
.FirstOrDefaultAsync(p => p.Id ==
uri.Substring($"https://{config.Value.WebDomain}/notes/".Length))
: await db.Notes.FirstOrDefaultAsync(p => p.Uri == uri);
: await db.Notes.IncludeCommonProperties()
.FirstOrDefaultAsync(p => p.Uri == uri);
if (note != null) return note;
//TODO: should we fall back to a regular user's keypair if fetching with instance actor fails & a local user is following the actor?
var fetchedNote = await fetchSvc.FetchNoteAsync(uri);
fetchedNote ??= await fetchSvc.FetchNoteAsync(uri);
if (fetchedNote?.AttributedTo is not [{ Id: not null } attrTo]) {
logger.LogDebug("Invalid Note.AttributedTo, skipping");
return null;
@ -340,4 +343,48 @@ public class NoteService(
return null;
}
}
public async Task<Note?> ResolveNoteAsync(ASNote note) {
return await ResolveNoteAsync(note.Id, note);
}
public async Task LikeNoteAsync(Note note, User user) {
if (!await db.NoteLikes.AnyAsync(p => p.Note == note && p.User == user)) {
var like = new NoteLike {
Id = IdHelpers.GenerateSlowflakeId(),
CreatedAt = DateTime.UtcNow,
User = user,
Note = note
};
await db.NoteLikes.AddAsync(like);
await db.SaveChangesAsync();
eventSvc.RaiseNoteLiked(this, note, user);
await notificationSvc.GenerateLikeNotification(note, user);
}
}
public async Task UnlikeNoteAsync(Note note, User user) {
var count = await db.NoteLikes.Where(p => p.Note == note && p.User == user).ExecuteDeleteAsync();
if (count == 0) return;
eventSvc.RaiseNoteUnliked(this, note, user);
await db.Notifications
.Where(p => p.Type == Notification.NotificationType.Like && p.Notifiee == note.User &&
p.Notifier == user)
.ExecuteDeleteAsync();
}
public async Task LikeNoteAsync(ASNote note, ASActor 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);
}
public async Task UnlikeNoteAsync(ASNote note, ASActor 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);
}
}

View file

@ -7,7 +7,8 @@ namespace Iceshrimp.Backend.Core.Services;
public class NotificationService(
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor")]
DatabaseContext db
DatabaseContext db,
EventService eventSvc
) {
public async Task GenerateMentionNotifications(Note note, IReadOnlyCollection<string> mentionedLocalUserIds) {
if (mentionedLocalUserIds.Count == 0) return;
@ -21,10 +22,12 @@ public class NotificationService(
NotifierId = note.UserId,
NotifieeId = p,
Type = Notification.NotificationType.Mention
});
})
.ToList();
await db.AddRangeAsync(notifications);
await db.SaveChangesAsync();
eventSvc.RaiseNotifications(this, notifications);
}
public async Task GenerateReplyNotifications(Note note, IReadOnlyCollection<string> mentionedLocalUserIds) {
@ -43,9 +46,29 @@ public class NotificationService(
NotifierId = note.UserId,
NotifieeId = p,
Type = Notification.NotificationType.Reply
});
})
.ToList();
await db.AddRangeAsync(notifications);
await db.SaveChangesAsync();
eventSvc.RaiseNotifications(this, notifications);
}
public async Task GenerateLikeNotification(Note note, User user) {
if (note.UserHost != null) return;
if (note.User == user) return;
var notification = new Notification {
Id = IdHelpers.GenerateSlowflakeId(),
CreatedAt = DateTime.UtcNow,
Note = note,
Notifiee = note.User,
Notifier = user,
Type = Notification.NotificationType.Like
};
await db.AddAsync(notification);
await db.SaveChangesAsync();
eventSvc.RaiseNotification(this, notification);
}
}

View file

@ -57,8 +57,4 @@
<None Remove="migrate.sql"/>
</ItemGroup>
<ItemGroup>
<Folder Include="Core\Events\" />
</ItemGroup>
</Project>