diff --git a/PrivaPub.Tests/Federation/InboxGapTests.cs b/PrivaPub.Tests/Federation/InboxGapTests.cs index 0225896..cb4efe4 100644 --- a/PrivaPub.Tests/Federation/InboxGapTests.cs +++ b/PrivaPub.Tests/Federation/InboxGapTests.cs @@ -147,7 +147,7 @@ namespace PrivaPub.Tests.Federation } [Fact] - public async Task A_followed_community_announcing_a_vote_or_its_undo_is_recorded_and_changes_nothing_yet() + public async Task A_vote_a_followed_community_relays_counts_and_its_undo_takes_it_back() { var token = TestContext.Current.CancellationToken; var (root, alice) = await _harness.Persona("alice"); @@ -168,20 +168,64 @@ namespace PrivaPub.Tests.Federation await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", like)); var liked = Processed("Announce"); + Assert.Equal(1, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(voter, "Undo", like))); var unliked = Processed("Announce"); await _harness.Deliver(stranger, "/human-centipede", Activity(stranger, "Announce", like)); var unfollowed = Processed("Announce"); - //votes relayed by a community are a documented gap (docs/INTEROP.md, Lemmy: "Announces of activities other than Create") - Assert.Equal(("dropped", "unsupported", "Like"), (liked.Outcome, liked.Reason, liked.Object)); - Assert.Equal(("dropped", "unsupported", "Undo"), (unliked.Outcome, unliked.Reason, unliked.Object)); + //Lemmy relays its members' votes inside the community's Announce (G-0003): a voter on the community's own server + //is vouched for by it, and the vote is handled as if the voter had sent it; a community nobody follows is ignored + Assert.Equal("accepted", liked.Outcome); + Assert.Equal("accepted", unliked.Outcome); Assert.Equal(("dropped", "not-followed"), (unfollowed.Outcome, unfollowed.Reason)); var after = await DB.Default.Find().OneAsync(post.ID, token); Assert.Equal(0, after.FavouritesCount); Assert.False(await DB.Default.Find().Match(f => f.PostId == post.ID).ExecuteAnyAsync(token)); } + // a voter on another server than the community is believed for the community's own posts, as Lemmy trusts it; for + // anything else only once its vote is fetched from its own origin + [Fact] + public async Task A_relayed_vote_from_another_server_counts_on_the_communitys_posts_and_elsewhere_only_once_fetched() + { + var token = TestContext.Current.CancellationToken; + var (root, alice) = await _harness.Persona("alice"); + var community = new RemoteActor(_harness.Peer, "cats", type: "Group"); + var poster = new RemoteActor(_harness.Peer, "poster"); + var voter = new RemoteActor(_harness.Peer, "voter", origin: _harness.Peer.B); + var forger = new RemoteActor(_harness.Peer, "forger", origin: _harness.Peer.B); + await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token); + await DB.Default.Update().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token); + var page = PublicNote(poster, "

a post

", community.Id); + page["type"] = "Page"; + page["name"] = "a title"; + _harness.Peer.Serve(new Uri(IdOf(page)).AbsolutePath, page.ToJsonString()); + await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, page))); + var post = await DB.Default.Find().Match(p => p.ObjectURI == IdOf(page)).ExecuteSingleAsync(token); + var other = await OwnPost(alice, token); + var like = new JsonObject { ["id"] = NewId(voter, "likes"), ["type"] = "Like", ["actor"] = voter.Id, ["object"] = post.ObjectURI }; + var forged = new JsonObject { ["id"] = NewId(forger, "likes"), ["type"] = "Like", ["actor"] = forger.Id, ["object"] = other.ObjectURI }; + + await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", like)); + await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", forged)); + + Assert.Equal(1, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); + Assert.True(await DB.Default.Find().Match(f => f.PostId == post.ID && f.ActorURI == voter.Id).ExecuteAnyAsync(token)); + Assert.Equal(("dropped", "fetch-failed"), (Processed("Announce").Outcome, Processed("Announce").Reason)); + Assert.Equal(0, (await DB.Default.Find().OneAsync(other.ID, token)).FavouritesCount); + } + + // a public post of a persona's own, outside any community + static async Task OwnPost(PrivaPub.Federation.Actors.LocalActor author, CancellationToken token) + { + var post = new Post { GroupUserId = author.Id, AuthorAccountId = author.Id, Text = "not the community's" }; + post.ID = (string)post.GenerateNewID(); + post.ObjectURI = author.PostUri(post.ID); + await DB.Default.SaveAsync(post, token); + return post; + } + [Fact] public async Task A_locked_persona_holds_a_follow_until_it_decides_and_answers_with_the_original_follow() { diff --git a/PrivaPub.Tests/Support/Harness.cs b/PrivaPub.Tests/Support/Harness.cs index 9c87bef..8c181b1 100644 --- a/PrivaPub.Tests/Support/Harness.cs +++ b/PrivaPub.Tests/Support/Harness.cs @@ -70,6 +70,7 @@ namespace PrivaPub.Tests.Support new FlagHandler(Db, Local), new BlockHandler(Db, Local) }; + ((AnnounceHandler)Handlers.First(h => h is AnnounceHandler)).Relays = Handlers; Processor = new InboxProcessor(Remote, Handlers, NullLogger.Instance, Ledger); Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer(), NullLogger.Instance); Content = new ContentRenderer(Local, Remote); diff --git a/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs b/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs index d895f0e..44d9bce 100644 --- a/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs @@ -31,10 +31,16 @@ namespace PrivaPub.Federation.Inbox.Handlers readonly IObjectRecords _records; readonly IQuoteService _quotes; + readonly IServiceProvider _services; + + // the handlers a community's relayed votes go to (Like, Dislike, Undo); resolved when first needed, since this + // handler is one of them; tests set it + public IEnumerable Relays { get; set; } public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout, IRemoteActorService remoteActors, - IObjectRecords records, IQuoteService quotes) + IObjectRecords records, IQuoteService quotes, IServiceProvider services = default) { + _services = services; _records = records; _quotes = quotes; _remoteActors = remoteActors; @@ -199,12 +205,59 @@ namespace PrivaPub.Federation.Inbox.Handlers Arrival.Accept("removed"); await RemoteDeletes.Remove(deleted, objectUri, token); break; + case "Like" or "Dislike" or "Undo": + await Relayed(inner, group, token); + break; default: Arrival.Drop("unsupported"); break; } } + // A vote, or its undoing, that a community relays (Lemmy, PieFed and Mbin send them so). The community vouches for + // what accounts on its own server do and for what is done to its own posts, as Lemmy trusts it (refetching every vote + // would not scale); anything else is believed only once fetched from its own origin. It is then handled as if its + // actor had delivered it. + async Task Relayed(JsonNode inner, ForeignAvatar group, CancellationToken token) + { + var actorUri = Id(inner["actor"]); + var type = Value(inner, "type"); + var handlers = Relays ?? _services?.GetService(typeof(IEnumerable)) as IEnumerable; + var handler = handlers?.FirstOrDefault(h => h.Type == type); + if (actorUri == default || handler == default) + { + Arrival.Drop("unparseable"); + return; + } + var activity = inner; + var target = Id(Value(inner, "type") == "Undo" ? (inner["object"] as JsonObject)?["object"] : inner["object"]); + var ownPost = target != default + && await _dbEntities.Posts.Match(p => p.ObjectURI == target && p.AudienceURI == group.ActorURI).ExecuteAnyAsync(token); + if (!Origin.Same(actorUri, group.ActorURI) && !ownPost) + { + var id = Id(inner); + if (id == default || !Origin.Same(id, actorUri)) + { + Arrival.Drop("misattributed"); + return; + } + using var fetched = await _remoteActors.FetchObject(id, token); + activity = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText()); + if (activity == default || Value(activity, "type") != type || Id(activity["actor"]) != actorUri) + { + Arrival.Drop(activity == default ? "fetch-failed" : "misattributed"); + return; + } + } + var actor = await _remoteActors.GetActor(actorUri, refresh: false, token); + if (actor == default) + { + Arrival.Drop("unknown-actor"); + return; + } + await handler.Handle(activity, actor, token); + } + static List Strings(JsonNode node) => node switch { JsonArray array => array.Select(Id).Where(id => id != default).ToList(), diff --git a/docs/INTEROP.md b/docs/INTEROP.md index 2cd16e1..35c9342 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -467,7 +467,7 @@ on a Page: pins live in `featured`, locks in `Lock`. | Gap | P | Client surface | |---|---|---| | `Dislike` and its `Undo`: a vote ledger per object (actor, ±1, activity, relaying group, time) | P1 | `favourites_count` = upvotes; own `privapub.vote{score, up, down, mine}`, `POST …/vote` | -| Announces of activities other than Create (votes, moderation): trust the inner activity when the object's own group signed the Announce; refetching every vote does not scale. Keep the origin refetch for Create and Update. | P1 | — | +| Announces of activities other than Create (votes, moderation): trust the inner activity when the object's own group signed the Announce; refetching every vote does not scale. Keep the origin refetch for Create and Update. Votes and their undoing **done** 2026-10-04 (`AnnounceHandler.Relayed`: trusted from the community's own server or on its own posts, otherwise fetched from the voter's origin); moderation still open | P1 | — | | Moderation state: removals (reason, by, at, cascade), locks, bans with `endTime`/`removeData`, featured, moderators, `Update{Group}` by a moderator | P1 | removed posts hidden plus `privapub.removed`; `privapub.locked` (replying answers 422); pins as `pinned=true` | | Link posts: keep `Link.href`, the thumbnail `image` and alt text; build the card (Lemmy sends no title or description for the link) | P1 | `Status.card` | | `ChatMessage` in and out (out only to Lemmy < 1.0 and Mbin; `Note` to everyone else) | P1 | `visibility: direct` |