[backend/federation] Handle note likes & unlikes (ISH-68)
This commit is contained in:
parent
e53e61e3ed
commit
5113f83c9f
17 changed files with 6248 additions and 42 deletions
|
@ -31,12 +31,13 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not
|
||||||
var res = await db.Notifications
|
var res = await db.Notifications
|
||||||
.IncludeCommonProperties()
|
.IncludeCommonProperties()
|
||||||
.Where(p => p.Notifiee == user)
|
.Where(p => p.Notifiee == user)
|
||||||
|
.Where(p => p.Note != null)
|
||||||
.Where(p => p.Type == NotificationType.Follow
|
.Where(p => p.Type == NotificationType.Follow
|
||||||
|| p.Type == NotificationType.Mention
|
|| p.Type == NotificationType.Mention
|
||||||
|| p.Type == NotificationType.Reply
|
|| p.Type == NotificationType.Reply
|
||||||
|| p.Type == NotificationType.Renote
|
|| p.Type == NotificationType.Renote
|
||||||
|| p.Type == NotificationType.Quote
|
|| p.Type == NotificationType.Quote
|
||||||
|| p.Type == NotificationType.Reaction
|
|| p.Type == NotificationType.Like
|
||||||
|| p.Type == NotificationType.PollEnded
|
|| p.Type == NotificationType.PollEnded
|
||||||
|| p.Type == NotificationType.FollowRequestReceived)
|
|| p.Type == NotificationType.FollowRequestReceived)
|
||||||
.EnsureNoteVisibilityFor(p => p.Note, user)
|
.EnsureNoteVisibilityFor(p => p.Note, user)
|
||||||
|
|
|
@ -21,6 +21,8 @@ public class NotificationRenderer(NoteRenderer noteRenderer, UserRenderer userRe
|
||||||
var notifier = accounts?.FirstOrDefault(p => p.Id == dbNotifier.Id) ??
|
var notifier = accounts?.FirstOrDefault(p => p.Id == dbNotifier.Id) ??
|
||||||
await userRenderer.RenderAsync(dbNotifier);
|
await userRenderer.RenderAsync(dbNotifier);
|
||||||
|
|
||||||
|
//TODO: specially handle quotes
|
||||||
|
|
||||||
var res = new Notification {
|
var res = new Notification {
|
||||||
Id = notification.Id,
|
Id = notification.Id,
|
||||||
Type = Notification.EncodeType(notification.Type),
|
Type = Notification.EncodeType(notification.Type),
|
||||||
|
|
|
@ -21,7 +21,7 @@ public class Notification : IEntity {
|
||||||
NotificationType.Reply => "mention",
|
NotificationType.Reply => "mention",
|
||||||
NotificationType.Renote => "renote",
|
NotificationType.Renote => "renote",
|
||||||
NotificationType.Quote => "reblog",
|
NotificationType.Quote => "reblog",
|
||||||
NotificationType.Reaction => "favourite",
|
NotificationType.Like => "favourite",
|
||||||
NotificationType.PollEnded => "poll",
|
NotificationType.PollEnded => "poll",
|
||||||
NotificationType.FollowRequestReceived => "follow_request",
|
NotificationType.FollowRequestReceived => "follow_request",
|
||||||
|
|
||||||
|
|
6018
Iceshrimp.Backend/Core/Database/Migrations/20240214182733_AddLikeNotificationType.Designer.cs
generated
Normal file
6018
Iceshrimp.Backend/Core/Database/Migrations/20240214182733_AddLikeNotificationType.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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", ",,");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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, "antenna_src_enum", new[] { "home", "all", "users", "list", "group", "instances" });
|
||||||
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "note_visibility_enum", new[] { "public", "home", "followers", "specified" });
|
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, "page_visibility_enum", new[] { "public", "followers", "specified" });
|
||||||
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "relay_status_enum", new[] { "requesting", "accepted", "rejected" });
|
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "relay_status_enum", new[] { "requesting", "accepted", "rejected" });
|
||||||
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "user_profile_ffvisibility_enum", new[] { "public", "followers", "private" });
|
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "user_profile_ffvisibility_enum", new[] { "public", "followers", "private" });
|
||||||
|
|
|
@ -109,6 +109,7 @@ public class Notification : IEntity {
|
||||||
[PgName("reply")] Reply,
|
[PgName("reply")] Reply,
|
||||||
[PgName("renote")] Renote,
|
[PgName("renote")] Renote,
|
||||||
[PgName("quote")] Quote,
|
[PgName("quote")] Quote,
|
||||||
|
[PgName("like")] Like,
|
||||||
[PgName("reaction")] Reaction,
|
[PgName("reaction")] Reaction,
|
||||||
[PgName("pollVote")] PollVote,
|
[PgName("pollVote")] PollVote,
|
||||||
[PgName("pollEnded")] PollEnded,
|
[PgName("pollEnded")] PollEnded,
|
||||||
|
|
8
Iceshrimp.Backend/Core/Events/NoteInteraction.cs
Normal file
8
Iceshrimp.Backend/Core/Events/NoteInteraction.cs
Normal 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;
|
||||||
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Iceshrimp.Backend.Core.Configuration;
|
using Iceshrimp.Backend.Core.Configuration;
|
||||||
using Iceshrimp.Backend.Core.Database;
|
using Iceshrimp.Backend.Core.Database;
|
||||||
using Iceshrimp.Backend.Core.Database.Tables;
|
using Iceshrimp.Backend.Core.Database.Tables;
|
||||||
|
@ -12,6 +13,8 @@ using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace Iceshrimp.Backend.Core.Federation.ActivityPub;
|
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(
|
public class ActivityHandlerService(
|
||||||
ILogger<ActivityHandlerService> logger,
|
ILogger<ActivityHandlerService> logger,
|
||||||
NoteService noteSvc,
|
NoteService noteSvc,
|
||||||
|
@ -31,7 +34,8 @@ public class ActivityHandlerService(
|
||||||
throw GracefulException.UnprocessableEntity("Instance is blocked");
|
throw GracefulException.UnprocessableEntity("Instance is blocked");
|
||||||
if (activity.Object == null)
|
if (activity.Object == null)
|
||||||
throw GracefulException.UnprocessableEntity("Activity object is null");
|
throw GracefulException.UnprocessableEntity("Activity object is null");
|
||||||
if (activity.Object.IsUnresolved)
|
|
||||||
|
// Resolve object & children
|
||||||
activity.Object = await resolver.ResolveObject(activity.Object) ??
|
activity.Object = await resolver.ResolveObject(activity.Object) ??
|
||||||
throw GracefulException.UnprocessableEntity("Failed to resolve 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");
|
throw GracefulException.UnprocessableEntity("Delete activity object is unknown or invalid");
|
||||||
}
|
}
|
||||||
case ASFollow: {
|
case ASFollow: {
|
||||||
if (activity.Object is not { } obj)
|
if (activity.Object is not ASActor obj)
|
||||||
throw GracefulException.UnprocessableEntity("Follow activity object is invalid");
|
throw GracefulException.UnprocessableEntity("Follow activity object is invalid");
|
||||||
await FollowAsync(obj, activity.Actor, activity.Id);
|
await FollowAsync(obj, activity.Actor, activity.Id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case ASUnfollow: {
|
case ASUnfollow: {
|
||||||
if (activity.Object is not { } obj)
|
if (activity.Object is not ASActor obj)
|
||||||
throw GracefulException.UnprocessableEntity("Unfollow activity object is invalid");
|
throw GracefulException.UnprocessableEntity("Unfollow activity object is invalid");
|
||||||
await UnfollowAsync(obj, activity.Actor);
|
await UnfollowAsync(obj, activity.Actor);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case ASAccept: {
|
case ASAccept: {
|
||||||
if (activity.Object is not { } obj)
|
if (activity.Object is not ASFollow obj)
|
||||||
throw GracefulException.UnprocessableEntity("Accept activity object is invalid");
|
throw GracefulException.UnprocessableEntity("Accept activity object is invalid");
|
||||||
await AcceptAsync(obj, activity.Actor);
|
await AcceptAsync(obj, activity.Actor);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case ASReject: {
|
case ASReject: {
|
||||||
if (activity.Object is not { } obj)
|
if (activity.Object is not ASFollow obj)
|
||||||
throw GracefulException.UnprocessableEntity("Reject activity object is invalid");
|
throw GracefulException.UnprocessableEntity("Reject activity object is invalid");
|
||||||
await RejectAsync(obj, activity.Actor);
|
await RejectAsync(obj, activity.Actor);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case ASUndo: {
|
case ASUndo: {
|
||||||
//TODO: what other types of undo objects are there?
|
switch (activity.Object) {
|
||||||
if (activity.Object is not ASActivity { Type: ASActivity.Types.Follow, Object: not null } undoActivity)
|
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");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
default: {
|
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 follower = await userResolver.ResolveAsync(followerActor.Id);
|
||||||
var followee = await userResolver.ResolveAsync(followeeActor.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
|
//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 follower = await userResolver.ResolveAsync(followerActor.Id);
|
||||||
var followee = await userResolver.ResolveAsync(followeeActor.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/";
|
var prefix = $"https://{config.Value.WebDomain}/follows/";
|
||||||
if (!obj.Id.StartsWith(prefix))
|
if (!obj.Id.StartsWith(prefix))
|
||||||
throw GracefulException.UnprocessableEntity($"Object id '{obj.Id}' not a valid follow request id");
|
throw GracefulException.UnprocessableEntity($"Object id '{obj.Id}' not a valid follow request id");
|
||||||
|
@ -220,8 +235,8 @@ public class ActivityHandlerService(
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RejectAsync(ASObject obj, ASObject actor) {
|
private async Task RejectAsync(ASFollow follow, ASActor actor) {
|
||||||
if (obj is not ASFollow { Actor: not null } follow)
|
if (follow is not { Actor: not null })
|
||||||
throw GracefulException.UnprocessableEntity("Refusing to reject object with invalid follow object");
|
throw GracefulException.UnprocessableEntity("Refusing to reject object with invalid follow object");
|
||||||
|
|
||||||
var resolvedActor = await userResolver.ResolveAsync(actor.Id);
|
var resolvedActor = await userResolver.ResolveAsync(actor.Id);
|
||||||
|
|
|
@ -9,9 +9,15 @@ namespace Iceshrimp.Backend.Core.Federation.ActivityPub;
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor",
|
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor",
|
||||||
Justification = "We need IOptionsSnapshot for config hot reload")]
|
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
|
//TODO: we need some level of caching here
|
||||||
public async Task<bool> ShouldBlockAsync(params string[] hosts) {
|
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)
|
hosts = hosts.Select(p => p.StartsWith("http://") || p.StartsWith("https://") ? new Uri(p).Host : p)
|
||||||
.Select(p => p.ToPunycode())
|
.Select(p => p.ToPunycode())
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
|
using Iceshrimp.Backend.Core.Configuration;
|
||||||
using Iceshrimp.Backend.Core.Database;
|
using Iceshrimp.Backend.Core.Database;
|
||||||
using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
|
using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace Iceshrimp.Backend.Core.Federation.ActivityPub;
|
namespace Iceshrimp.Backend.Core.Federation.ActivityPub;
|
||||||
|
|
||||||
|
@ -8,15 +10,26 @@ public class ObjectResolver(
|
||||||
ILogger<ObjectResolver> logger,
|
ILogger<ObjectResolver> logger,
|
||||||
ActivityFetcherService fetchSvc,
|
ActivityFetcherService fetchSvc,
|
||||||
DatabaseContext db,
|
DatabaseContext db,
|
||||||
FederationControlService federationCtrl
|
FederationControlService federationCtrl,
|
||||||
|
IOptions<Config.InstanceSection> config
|
||||||
) {
|
) {
|
||||||
public async Task<ASObject?> ResolveObject(ASObjectBase baseObj) {
|
public async Task<ASObject?> ResolveObject(ASObjectBase baseObj, int recurse = 5) {
|
||||||
if (baseObj is ASObject obj) return obj;
|
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) {
|
if (baseObj.Id == null) {
|
||||||
logger.LogDebug("Refusing to resolve object with null id property");
|
logger.LogDebug("Refusing to resolve object with null id property");
|
||||||
return null;
|
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)) {
|
if (await federationCtrl.ShouldBlockAsync(baseObj.Id)) {
|
||||||
logger.LogDebug("Instance is blocked");
|
logger.LogDebug("Instance is blocked");
|
||||||
return null;
|
return null;
|
||||||
|
|
|
@ -55,6 +55,10 @@ public class ASUndo : ASActivity {
|
||||||
public ASUndo() => Type = Types.Undo;
|
public ASUndo() => Type = Types.Undo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class ASLike : ASActivity {
|
||||||
|
public ASLike() => Type = Types.Like;
|
||||||
|
}
|
||||||
|
|
||||||
//TODO: add the rest
|
//TODO: add the rest
|
||||||
|
|
||||||
public sealed class ASActivityConverter : ASSerializer.ListSingleObjectConverter<ASActivity>;
|
public sealed class ASActivityConverter : ASSerializer.ListSingleObjectConverter<ASActivity>;
|
|
@ -8,7 +8,12 @@ using JR = Newtonsoft.Json.JsonRequiredAttribute;
|
||||||
namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
|
namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
|
||||||
|
|
||||||
public class ASObject : ASObjectBase {
|
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")]
|
[J("@type")]
|
||||||
[JC(typeof(StringListSingleConverter))]
|
[JC(typeof(StringListSingleConverter))]
|
||||||
|
@ -34,7 +39,7 @@ public class ASObject : ASObjectBase {
|
||||||
ASActivity.Types.Accept => token.ToObject<ASAccept>(),
|
ASActivity.Types.Accept => token.ToObject<ASAccept>(),
|
||||||
ASActivity.Types.Reject => token.ToObject<ASReject>(),
|
ASActivity.Types.Reject => token.ToObject<ASReject>(),
|
||||||
ASActivity.Types.Undo => token.ToObject<ASUndo>(),
|
ASActivity.Types.Undo => token.ToObject<ASUndo>(),
|
||||||
ASActivity.Types.Like => token.ToObject<ASActivity>(),
|
ASActivity.Types.Like => token.ToObject<ASLike>(),
|
||||||
_ => token.ToObject<ASObject>()
|
_ => token.ToObject<ASObject>()
|
||||||
},
|
},
|
||||||
JTokenType.Array => Deserialize(token.First()),
|
JTokenType.Array => Deserialize(token.First()),
|
||||||
|
|
|
@ -1,11 +1,28 @@
|
||||||
using Iceshrimp.Backend.Core.Database.Tables;
|
using Iceshrimp.Backend.Core.Database.Tables;
|
||||||
|
using Iceshrimp.Backend.Core.Events;
|
||||||
|
|
||||||
namespace Iceshrimp.Backend.Core.Services;
|
namespace Iceshrimp.Backend.Core.Services;
|
||||||
|
|
||||||
public class EventService {
|
public class EventService {
|
||||||
public event EventHandler<Note> NotePublished;
|
public event EventHandler<Note>? NotePublished;
|
||||||
public event EventHandler<string> NoteDeleted;
|
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 RaiseNotePublished(object? sender, Note note) => NotePublished?.Invoke(sender, note);
|
||||||
public void RaiseNoteDeleted(object? sender, Note note) => NoteDeleted.Invoke(sender, note.Id);
|
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 });
|
||||||
}
|
}
|
|
@ -303,7 +303,7 @@ public class NoteService(
|
||||||
return result.Where(p => p != null).Cast<DriveFile>().ToList();
|
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?
|
//TODO: is this enough to prevent DoS attacks?
|
||||||
if (_recursionLimit-- <= 0)
|
if (_recursionLimit-- <= 0)
|
||||||
throw GracefulException.UnprocessableEntity("Refusing to resolve threads this long");
|
throw GracefulException.UnprocessableEntity("Refusing to resolve threads this long");
|
||||||
|
@ -312,13 +312,16 @@ public class NoteService(
|
||||||
_resolverHistory.Add(uri);
|
_resolverHistory.Add(uri);
|
||||||
|
|
||||||
var note = uri.StartsWith($"https://{config.Value.WebDomain}/notes/")
|
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))
|
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;
|
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?
|
//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]) {
|
if (fetchedNote?.AttributedTo is not [{ Id: not null } attrTo]) {
|
||||||
logger.LogDebug("Invalid Note.AttributedTo, skipping");
|
logger.LogDebug("Invalid Note.AttributedTo, skipping");
|
||||||
return null;
|
return null;
|
||||||
|
@ -340,4 +343,48 @@ public class NoteService(
|
||||||
return null;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -7,7 +7,8 @@ namespace Iceshrimp.Backend.Core.Services;
|
||||||
|
|
||||||
public class NotificationService(
|
public class NotificationService(
|
||||||
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor")]
|
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor")]
|
||||||
DatabaseContext db
|
DatabaseContext db,
|
||||||
|
EventService eventSvc
|
||||||
) {
|
) {
|
||||||
public async Task GenerateMentionNotifications(Note note, IReadOnlyCollection<string> mentionedLocalUserIds) {
|
public async Task GenerateMentionNotifications(Note note, IReadOnlyCollection<string> mentionedLocalUserIds) {
|
||||||
if (mentionedLocalUserIds.Count == 0) return;
|
if (mentionedLocalUserIds.Count == 0) return;
|
||||||
|
@ -21,10 +22,12 @@ public class NotificationService(
|
||||||
NotifierId = note.UserId,
|
NotifierId = note.UserId,
|
||||||
NotifieeId = p,
|
NotifieeId = p,
|
||||||
Type = Notification.NotificationType.Mention
|
Type = Notification.NotificationType.Mention
|
||||||
});
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
await db.AddRangeAsync(notifications);
|
await db.AddRangeAsync(notifications);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
eventSvc.RaiseNotifications(this, notifications);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task GenerateReplyNotifications(Note note, IReadOnlyCollection<string> mentionedLocalUserIds) {
|
public async Task GenerateReplyNotifications(Note note, IReadOnlyCollection<string> mentionedLocalUserIds) {
|
||||||
|
@ -43,9 +46,29 @@ public class NotificationService(
|
||||||
NotifierId = note.UserId,
|
NotifierId = note.UserId,
|
||||||
NotifieeId = p,
|
NotifieeId = p,
|
||||||
Type = Notification.NotificationType.Reply
|
Type = Notification.NotificationType.Reply
|
||||||
});
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
await db.AddRangeAsync(notifications);
|
await db.AddRangeAsync(notifications);
|
||||||
await db.SaveChangesAsync();
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -57,8 +57,4 @@
|
||||||
<None Remove="migrate.sql"/>
|
<None Remove="migrate.sql"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Folder Include="Core\Events\" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
Loading…
Add table
Reference in a new issue