diff --git a/PrivaPub.Tests/Federation/InboxScenarioTests.cs b/PrivaPub.Tests/Federation/InboxScenarioTests.cs index d27236d..b6145d6 100644 --- a/PrivaPub.Tests/Federation/InboxScenarioTests.cs +++ b/PrivaPub.Tests/Federation/InboxScenarioTests.cs @@ -53,7 +53,7 @@ namespace PrivaPub.Tests.Federation _processor = new InboxProcessor(remote, new IActivityHandler[] { new FollowHandler(db, _local, remote, delivery), - new UndoHandler(db, _local, remote, delivery), + new UndoHandler(db, _local), new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue)), new DeleteHandler(db, _local, remote, delivery), new UpdateHandler(db, _local, remote) diff --git a/PrivaPub.Tests/Federation/InteractionTests.cs b/PrivaPub.Tests/Federation/InteractionTests.cs new file mode 100644 index 0000000..9d327d8 --- /dev/null +++ b/PrivaPub.Tests/Federation/InteractionTests.cs @@ -0,0 +1,137 @@ +using MongoDB.Entities; + +using PrivaPub.ClientModels.Post; +using PrivaPub.ClientModels.Social; +using PrivaPub.Federation.Objects; +using PrivaPub.Models.Post; +using PrivaPub.Models.Social; +using PrivaPub.Tests.Support; + +using System.Text.Json.Nodes; + +namespace PrivaPub.Tests.Federation +{ + [Trait("Category", "Integration")] + public sealed class InteractionTests : IAsyncLifetime + { + Harness _harness; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _harness = await Harness.Start(); + } + + public async ValueTask DisposeAsync() + { + if (_harness != default) + await _harness.DisposeAsync(); + } + + async Task<(string Root, Post Post)> LocalPost(string visibility = default) + { + var (root, alice) = await _harness.Persona("alice"); + var view = (ViewPost)(await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "like me", Visibility = visibility }, + TestContext.Current.CancellationToken)).Data; + return (root, await DB.Default.Find().OneAsync(view.Id)); + } + + static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); + + [Fact] + public async Task A_like_counts_once_notifies_and_can_be_undone() + { + var token = TestContext.Current.CancellationToken; + var (_, post) = await LocalPost(); + var carol = new RemoteActor(_harness.Peer, "carol"); + var like = new JsonObject { ["id"] = $"{Origin(carol)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = carol.Id, ["object"] = post.ObjectURI }; + + await _harness.Deliver(carol, "/human-centipede", like); + await _harness.Deliver(carol, "/human-centipede", new JsonObject { ["id"] = $"{Origin(carol)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = carol.Id, ["object"] = post.ObjectURI }); + + Assert.Equal(1, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); + Assert.Equal(NotificationType.Favourite, (await DB.Default.Find().Match(n => n.AvatarId == post.GroupUserId).ExecuteSingleAsync(token)).Type); + + await _harness.Deliver(carol, "/human-centipede", new JsonObject + { + ["id"] = $"{Origin(carol)}/undos/{Guid.NewGuid():N}", ["type"] = "Undo", ["actor"] = carol.Id, ["object"] = like + }); + Assert.Equal(0, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); + } + + [Fact] + public async Task A_stranger_cannot_like_a_followers_only_post() + { + var token = TestContext.Current.CancellationToken; + var (_, post) = await LocalPost("followersonly"); + var mallory = new RemoteActor(_harness.Peer, "mallory"); + + await _harness.Deliver(mallory, "/human-centipede", new JsonObject + { + ["id"] = $"{Origin(mallory)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = mallory.Id, ["object"] = post.ObjectURI + }); + + Assert.Equal(0, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); + } + + [Fact] + public async Task A_boost_of_a_local_post_counts_notifies_and_can_be_undone() + { + var token = TestContext.Current.CancellationToken; + var (_, post) = await LocalPost(); + var carol = new RemoteActor(_harness.Peer, "carol"); + var announce = new JsonObject + { + ["id"] = $"{Origin(carol)}/announces/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = carol.Id, ["object"] = post.ObjectURI, + ["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(carol.Id + "/followers") + }; + + await _harness.Deliver(carol, "/human-centipede", announce); + + Assert.Equal(1, (await DB.Default.Find().OneAsync(post.ID, token)).ReblogsCount); + Assert.Equal(NotificationType.Reblog, (await DB.Default.Find().Match(n => n.AvatarId == post.GroupUserId).ExecuteSingleAsync(token)).Type); + + await _harness.Deliver(carol, "/human-centipede", new JsonObject + { + ["id"] = $"{Origin(carol)}/undos/{Guid.NewGuid():N}", ["type"] = "Undo", ["actor"] = carol.Id, ["object"] = announce["id"]!.GetValue() + }); + Assert.Equal(0, (await DB.Default.Find().OneAsync(post.ID, token)).ReblogsCount); + Assert.False(await DB.Default.Find().Match(p => p.ObjectURI == announce["id"]!.GetValue()).ExecuteAnyAsync(token)); + } + + [Fact] + public async Task A_followed_account_boosting_a_remote_post_puts_it_in_home() + { + var token = TestContext.Current.CancellationToken; + var (bobRoot, bob) = await _harness.Persona("bob"); + var carol = new RemoteActor(_harness.Peer, "carol"); + var dave = new RemoteActor(_harness.Peer, "dave"); + var stranger = new RemoteActor(_harness.Peer, "stranger"); + await _harness.Follows.Follow(bobRoot, new FollowForm { AvatarId = bob.Id, Target = carol.Id }, token); + await DB.Default.Update().Match(f => f.AvatarId == bob.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token); + var noteId = $"{Origin(dave)}/notes/{Guid.NewGuid():N}"; + _harness.Peer.Serve(new Uri(noteId).AbsolutePath, new JsonObject + { + ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = dave.Id, ["content"] = "

worth sharing

", ["to"] = new JsonArray(Addressing.Public) + }.ToJsonString()); + + await _harness.Deliver(stranger, "/human-centipede", new JsonObject + { + ["id"] = $"{Origin(stranger)}/announces/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = stranger.Id, ["object"] = noteId, + ["to"] = new JsonArray(Addressing.Public) + }); + Assert.False(await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteAnyAsync(token)); + + await _harness.Deliver(carol, "/human-centipede", new JsonObject + { + ["id"] = $"{Origin(carol)}/announces/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = carol.Id, ["object"] = noteId, + ["to"] = new JsonArray(Addressing.Public) + }); + + var home = (List)(await _harness.Timelines.Home(bobRoot, bob.Id, default, 20, token)).Data; + var boosted = Assert.Single(home); + Assert.Equal("

worth sharing

", boosted.ReblogOf.ContentHtml); + Assert.Equal(carol.Id, boosted.AuthorActorURI); + } + } +} diff --git a/PrivaPub.Tests/Support/Harness.cs b/PrivaPub.Tests/Support/Harness.cs index 656ec9f..a6b4bda 100644 --- a/PrivaPub.Tests/Support/Harness.cs +++ b/PrivaPub.Tests/Support/Harness.cs @@ -45,7 +45,9 @@ namespace PrivaPub.Tests.Support new FollowHandler(Db, Local, Remote, Delivery), new AcceptHandler(Db, Local), new RejectHandler(Db, Local), - new UndoHandler(Db, Local, Remote, Delivery), + new UndoHandler(Db, Local), + new LikeHandler(Db), + new AnnounceHandler(Db, Local, RemotePosts, Fanout), new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts), new DeleteHandler(Db, Local, Remote, Delivery), new UpdateHandler(Db, Local, Remote) diff --git a/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs b/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs new file mode 100644 index 0000000..823d1b9 --- /dev/null +++ b/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs @@ -0,0 +1,106 @@ +using MongoDB.Driver; +using MongoDB.Entities; + +using PrivaPub.Domain.Social; +using PrivaPub.Domain.Timelines; +using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Objects; +using PrivaPub.Infrastructure.Ids; +using PrivaPub.Models.Post; +using PrivaPub.Models.Social; +using PrivaPub.Models.User; +using PrivaPub.StaticServices; + +using System.Globalization; +using System.Text.Json.Nodes; + +using static PrivaPub.Federation.Objects.ActivityJson; + +using PostEntity = PrivaPub.Models.Post.Post; + +namespace PrivaPub.Federation.Inbox.Handlers +{ + public class AnnounceHandler : IActivityHandler + { + readonly DbEntities _dbEntities; + readonly ILocalActorService _localActors; + readonly IRemotePosts _remotePosts; + readonly IFanout _fanout; + + public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout) + { + _dbEntities = dbEntities; + _localActors = localActors; + _remotePosts = remotePosts; + _fanout = fanout; + } + + public string Type => "Announce"; + + public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) + { + var inner = activity["object"]; + if (inner is JsonObject && Value(inner, "type") is "Create" or "Update" or "Delete" or "Like" or "Dislike" or "Undo" or "Add" or "Remove" or "Block") + return; + var objectUri = Id(inner); + var announceId = Id(activity); + if (objectUri == default || announceId == default) + return; + if (await _dbEntities.Posts.Match(p => p.ObjectURI == announceId).ExecuteAnyAsync(token)) + return; + + var isLocal = objectUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase); + var original = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token); + var followed = await _dbEntities.Followings.Match(f => f.TargetActorURI == actor.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token); + if (original is not { IsFederatedCopy: false } && !followed) + return; + if (original == default && !isLocal) + original = await _remotePosts.StoreContext(objectUri, 0, token); + if (original == default || original.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted)) + return; + + var to = Strings(activity["to"]); + var cc = Strings(activity["cc"]); + var published = DateTimeOffset.TryParse(Value(activity, "published"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var at) + ? at.UtcDateTime + : DateTime.UtcNow; + var reblog = new PostEntity + { + ID = PrivacyIds.At(published), + ObjectURI = announceId, + ActivityURI = announceId, + ActorURI = actor.ActorURI, + AuthorAccountId = actor.ID, + ReblogOfPostId = original.ID, + Visibility = Addressing.Classify(to, cc, actor.FollowersURL), + To = to, + Cc = cc, + IsFederatedCopy = true, + CreationDate = published, + UpdateDate = published + }; + if (reblog.Visibility == PostVisibility.Direct) + return; + try + { + await DB.Default.SaveAsync(reblog, token); + } + catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + return; + } + + await DB.Default.Update().MatchID(original.ID).Modify(b => b.Inc(p => p.ReblogsCount, 1)).ExecuteAsync(token); + if (!original.IsFederatedCopy) + await Notifications.Add(original.GroupUserId, NotificationType.Reblog, actor.ID, actor.ActorURI, original.ID, token); + await _fanout.Distribute(reblog, token); + } + + static List Strings(JsonNode node) => node switch + { + JsonArray array => array.Select(Id).Where(id => id != default).ToList(), + JsonNode single when Id(single) is { } id => new List { id }, + _ => new List() + }; + } +} diff --git a/PrivaPub/Federation/Inbox/Handlers/LikeHandler.cs b/PrivaPub/Federation/Inbox/Handlers/LikeHandler.cs new file mode 100644 index 0000000..f583fe7 --- /dev/null +++ b/PrivaPub/Federation/Inbox/Handlers/LikeHandler.cs @@ -0,0 +1,56 @@ +using MongoDB.Driver; +using MongoDB.Entities; + +using PrivaPub.Domain.Social; +using PrivaPub.Models.Post; +using PrivaPub.Models.Social; +using PrivaPub.Models.User; +using PrivaPub.StaticServices; + +using System.Text.Json.Nodes; + +using static PrivaPub.Federation.Objects.ActivityJson; + +using PostEntity = PrivaPub.Models.Post.Post; + +namespace PrivaPub.Federation.Inbox.Handlers +{ + public class LikeHandler : IActivityHandler + { + readonly DbEntities _dbEntities; + + public LikeHandler(DbEntities dbEntities) + { + _dbEntities = dbEntities; + } + + public string Type => "Like"; + + public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) + { + var objectUri = Id(activity["object"]); + var post = objectUri == default + ? default + : await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token); + if (post == default || !MaySee(post, actor)) + return; + + try + { + await DB.Default.SaveAsync(new Favourite { AccountId = actor.ID, ActorURI = actor.ActorURI, PostId = post.ID, ActivityURI = Id(activity) }, token); + } + catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + return; + } + await DB.Default.Update().MatchID(post.ID).Modify(b => b.Inc(p => p.FavouritesCount, 1)).ExecuteAsync(token); + if (!post.IsFederatedCopy) + await Notifications.Add(post.GroupUserId, NotificationType.Favourite, actor.ID, actor.ActorURI, post.ID, token); + } + + public static bool MaySee(PostEntity post, ForeignAvatar actor) => + post.Visibility is PostVisibility.Public or PostVisibility.Unlisted + || post.Visibility is PostVisibility.FollowersOnly or PostVisibility.Direct + && (post.To.Contains(actor.ActorURI) || post.Cc.Contains(actor.ActorURI) || post.Mentions.Any(m => m.ActorURI == actor.ActorURI)); + } +} diff --git a/PrivaPub/Federation/Inbox/Handlers/UndoHandler.cs b/PrivaPub/Federation/Inbox/Handlers/UndoHandler.cs index 5426384..27da40a 100644 --- a/PrivaPub/Federation/Inbox/Handlers/UndoHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/UndoHandler.cs @@ -1,12 +1,8 @@ using MongoDB.Entities; using PrivaPub.Federation.Actors; -using PrivaPub.Federation.Objects; -using PrivaPub.Federation.Outbox; -using PrivaPub.Federation.Rendering; using PrivaPub.Models.Federation; -using PrivaPub.Models.Group; -using PrivaPub.Models.Post; +using PrivaPub.Models.Social; using PrivaPub.Models.User; using PrivaPub.StaticServices; @@ -23,38 +19,37 @@ namespace PrivaPub.Federation.Inbox.Handlers { readonly DbEntities _dbEntities; readonly ILocalActorService _localActors; - readonly IRemoteActorService _remoteActors; - readonly IDeliveryService _delivery; - public UndoHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery) + public UndoHandler(DbEntities dbEntities, ILocalActorService localActors) { _dbEntities = dbEntities; _localActors = localActors; - _remoteActors = remoteActors; - _delivery = delivery; } public string Type => "Undo"; public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) { - var undo = activity; - - var inner = undo["object"]; + var inner = activity["object"]; var innerType = inner is JsonObject ? Value(inner, "type") : default; var innerId = Id(inner); - if (inner is JsonObject && Id(inner["actor"]) != actor.ActorURI) - return; - if (innerType is not (null or "Follow")) - return; + if (innerType is null or "Follow") + await UndoFollow(inner, innerId, actor, token); + if (innerType is null or "Like") + await UndoLike(inner, innerId, actor, token); + if (innerType is null or "Announce") + await UndoAnnounce(innerId, actor, token); + } + async Task UndoFollow(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token) + { var followers = await _dbEntities.Followers.Match(f => f.ActorURI == actor.ActorURI).ExecuteAsync(token); var targetUri = inner is JsonObject ? Id(inner["object"]) : default; + var target = targetUri == default ? default : await _localActors.FindByUri(targetUri, token); foreach (var follower in followers) { var matchesActivity = innerId != default && follower.FollowActivityURI == innerId; - var target = targetUri == default ? default : await _localActors.FindByUri(targetUri, token); var matchesTarget = target != default && target.Id == follower.LocalActorId && target.Kind == follower.LocalActorKind; if (!matchesActivity && !matchesTarget) continue; @@ -63,7 +58,38 @@ namespace PrivaPub.Federation.Inbox.Handlers if (follower.LocalActorKind == LocalActorKind.Group) await RemoveForeignMember(follower.LocalActorId, actor.ActorURI, token); } - return; - } + } + + async Task UndoLike(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token) + { + var favourite = innerId == default + ? default + : await _dbEntities.Favourites.Match(f => f.ActivityURI == innerId && f.ActorURI == actor.ActorURI).ExecuteFirstAsync(token); + if (favourite == default && inner is JsonObject && Id(inner["object"]) is { } objectUri) + { + var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token); + favourite = post == default + ? default + : await _dbEntities.Favourites.Match(f => f.PostId == post.ID && f.ActorURI == actor.ActorURI).ExecuteFirstAsync(token); + } + if (favourite == default) + return; + await DB.Default.DeleteAsync(favourite.ID); + await DB.Default.Update().MatchID(favourite.PostId).Modify(b => b.Inc(p => p.FavouritesCount, -1)).ExecuteAsync(token); + } + + async Task UndoAnnounce(string innerId, ForeignAvatar actor, CancellationToken token) + { + if (innerId == default) + return; + var reblog = await _dbEntities.Posts + .Match(p => p.ObjectURI == innerId && p.ActorURI == actor.ActorURI && p.ReblogOfPostId != null) + .ExecuteFirstAsync(token); + if (reblog == default) + return; + await DB.Default.DeleteAsync(reblog.ID); + await DB.Default.DeleteAsync(e => e.PostId == reblog.ID); + await DB.Default.Update().MatchID(reblog.ReblogOfPostId).Modify(b => b.Inc(p => p.ReblogsCount, -1)).ExecuteAsync(token); + } } } diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index 0245530..49524c3 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -65,6 +65,8 @@ namespace PrivaPub.Middleware .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton()