Votes a community relays count
Lemmy, PieFed and Mbin relay their members' votes, and the undoing of them, inside the community's Announce; PrivaPub dropped them all as unsupported (G-0003, the Lemmy scenario's expected failures). A relayed Like, Dislike or Undo from a community a persona follows is now handled as if its actor had sent it. 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); a vote from elsewhere on anything else is believed only once fetched from its own origin. Moderation relayed the same way is still open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
2873344690
commit
ed08f80f05
4 files changed
+104
-6
No files matched your search
@@ -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<Post>().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<Post>().OneAsync(post.ID, token);
|
||||
Assert.Equal(0, after.FavouritesCount);
|
||||
Assert.False(await DB.Default.Find<Favourite>().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<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
|
||||
var page = PublicNote(poster, "<p>a post</p>", 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<Post>().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<Post>().OneAsync(post.ID, token)).FavouritesCount);
|
||||
Assert.True(await DB.Default.Find<Favourite>().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<Post>().OneAsync(other.ID, token)).FavouritesCount);
|
||||
}
|
||||
|
||||
// a public post of a persona's own, outside any community
|
||||
static async Task<Post> 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()
|
||||
{
|
||||
|
||||
@@ -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<InboxProcessor>.Instance, Ledger);
|
||||
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
|
||||
Content = new ContentRenderer(Local, Remote);
|
||||
|
||||
@@ -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<IActivityHandler> 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<IActivityHandler>)) as IEnumerable<IActivityHandler>;
|
||||
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<string> Strings(JsonNode node) => node switch
|
||||
{
|
||||
JsonArray array => array.Select(Id).Where(id => id != default).ToList(),
|
||||
|
||||
+1
-1
@@ -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` |
|
||||
|
||||
Reference in new issue
Block a user