diff --git a/FEDERATION.md b/FEDERATION.md index 31bd8b4..4c7c597 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -104,7 +104,9 @@ A group is either a **community** or a **circle**. of a member's server, and that copy names the member (or the members on that server); anyone else gets 404. A Mastodon member's replies reach only the people they mention. - Announces from **remote** groups (Lemmy communities) are followed through to the activity: the object is fetched - from its own origin, never taken from the announce. + from its own origin, never taken from the announce. They are taken from a group someone here follows, and also when + they relay a vote on one of our posts in a thread of that group (Lemmy sends a vote to the community alone). A post + keeps the group its `audience` names, however it arrived. - A remote community's **moderation** reaches the posts it holds. A removal (`Announce{Delete}`) is believed once the post's origin answers it gone. A lock (`Announce{Lock}`, or `commentsEnabled: false` on the post) refuses replies, ours included, until `Undo{Lock}`. A ban of a persona (`Announce{Block}` with the community as `target`) shows as diff --git a/PrivaPub.Tests/Federation/InboxGapTests.cs b/PrivaPub.Tests/Federation/InboxGapTests.cs index 3472120..20c11f6 100644 --- a/PrivaPub.Tests/Federation/InboxGapTests.cs +++ b/PrivaPub.Tests/Federation/InboxGapTests.cs @@ -209,6 +209,36 @@ namespace PrivaPub.Tests.Federation Assert.False(await DB.Default.Find().Match(f => f.PostId == post.ID).ExecuteAnyAsync(token)); } + // Lemmy sends a vote to the community alone: a vote on our reply in a thread of a community nobody here follows still + // reaches us through it, and only through that community (found by the town's village) + [Fact] + public async Task A_vote_on_our_reply_in_an_unfollowed_communitys_thread_counts_through_that_community_only() + { + var token = TestContext.Current.CancellationToken; + var (_, alice) = await _harness.Persona("alice"); + var community = new RemoteActor(_harness.Peer, "cats", type: "Group"); + var other = new RemoteActor(_harness.Peer, "dogs", type: "Group"); + var poster = new RemoteActor(_harness.Peer, "poster"); + var voter = new RemoteActor(_harness.Peer, "voter"); + var page = PublicNote(poster, "

a thread

", community.Id); + page["type"] = "Page"; + page["name"] = "a thread"; + page["audience"] = community.Id; + _harness.Peer.Serve(new Uri(IdOf(page)).AbsolutePath, page.ToJsonString()); + var root = await _harness.RemotePosts.StoreContext(IdOf(page), 0, token); + Assert.Equal(community.Id, root.AudienceURI); + var reply = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "my answer", InReplyTo = root.ID }, token)).Post; + var like = new JsonObject { ["id"] = NewId(voter, "likes"), ["type"] = "Like", ["actor"] = voter.Id, ["object"] = reply.ObjectURI }; + + await _harness.Deliver(other, "/human-centipede", Activity(other, "Announce", like)); + Assert.Equal(("dropped", "not-followed"), (Processed("Announce").Outcome, Processed("Announce").Reason)); + await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", like)); + Assert.Equal("accepted", Processed("Announce").Outcome); + Assert.Equal(1, (await DB.Default.Find().OneAsync(reply.ID, token)).FavouritesCount); + await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(voter, "Undo", like))); + Assert.Equal(0, (await DB.Default.Find().OneAsync(reply.ID, token)).FavouritesCount); + } + // Lemmy's moderators lock a community's post and ban members, and the community relays both inside its Announce // (G-0006): a locked post takes no reply, a banned persona posts nothing there, until the Undo [Fact] diff --git a/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs b/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs index eb02e6b..6905ac8 100644 --- a/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs @@ -143,7 +143,8 @@ namespace PrivaPub.Federation.Inbox.Handlers { Arrival.About(Value(inner, "type")); if (group.AvatarType != AvatarType.Group - || !await _dbEntities.Followings.Match(f => f.TargetActorURI == group.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token)) + || !await _dbEntities.Followings.Match(f => f.TargetActorURI == group.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token) + && !await OurPostInItsThread(inner, group, token)) { Arrival.Drop("not-followed"); return; @@ -230,6 +231,28 @@ namespace PrivaPub.Federation.Inbox.Handlers } } + // A vote (or its undoing) on one of our posts in a thread the community holds: the community relays it to the post's + // server whether or not anyone here follows the community (Lemmy sends a vote to the community alone) + async Task OurPostInItsThread(JsonNode inner, ForeignAvatar group, CancellationToken token) + { + var target = Value(inner, "type") switch + { + "Like" or "Dislike" => Id(inner["object"]), + "Undo" when (inner["object"] as JsonObject)?["type"]?.GetValue() is "Like" or "Dislike" => Id(inner["object"]?["object"]), + _ => default + }; + var post = target == default + ? default + : await _dbEntities.Posts.Match(p => p.ObjectURI == target && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); + for (var depth = 0; post != default && depth < RemotePosts.MaxDepth; depth++) + { + if (post.AudienceURI == group.ActorURI) + return true; + post = string.IsNullOrEmpty(post.AnsweringToPostId) ? default : await _dbEntities.Posts.MatchID(post.AnsweringToPostId).ExecuteFirstAsync(token); + } + return false; + } + // A community's moderators lock one of its posts, or unlock it: no more replies, ours included. The community vouches // for what is done to its own posts. async Task Lock(string objectUri, ForeignAvatar group, bool locked, CancellationToken token) diff --git a/PrivaPub/Federation/Inbox/RemotePosts.cs b/PrivaPub/Federation/Inbox/RemotePosts.cs index 7032db0..91eda30 100644 --- a/PrivaPub/Federation/Inbox/RemotePosts.cs +++ b/PrivaPub/Federation/Inbox/RemotePosts.cs @@ -94,6 +94,11 @@ namespace PrivaPub.Federation.Inbox Cc = cc.ToList(), Url = note.Url, ContextURI = note.Context, + // the community it lives in (FEP-1b12), as the post itself names it: a post fetched for a thread knows its + // community as well as one its community announced + AudienceURI = note.Audience != default && note.Audience != author.ActorURI && !note.Audience.EndsWith("/followers", StringComparison.Ordinal) + ? note.Audience + : default, QuoteURI = note.QuoteUri, ObjectType = note.Type, Title = note.Title,