Followers and circle members may like, react, vote and downvote

LikeHandler.MaySee let a remote account interact with a followers-only
post only when the post named it in to, cc or its mentions. Our own
followers-only posts are addressed to the followers collection, never to
each follower, and circle posts were not considered at all, so every
like, reaction, downvote and poll vote from a follower on a
followers-only post, and from a member on a circle post, was dropped as
"not-visible". Likes, dislikes, reactions and poll votes now ask
VisibilityPolicy.RemoteCanSee: anyone for public and unlisted posts, an
addressed account for a DM, an accepted follower or an addressed account
for followers-only, a foreign member for a circle post, and never a
server's instance actor (SignedFetchAuthorizer's alias is for refetches
only).

Found by planning the town's multi-server interaction checks (G-0001).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-04 23:05:22 +02:00
1 parent 436f7da464
commit 128ff89426
6 files changed
+99 -11

No files matched your search

@@ -3,8 +3,10 @@ using MongoDB.Entities;
using PrivaPub.ClientModels.Post; using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels.Social; using PrivaPub.ClientModels.Social;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Models.Social; using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support; using PrivaPub.Tests.Support;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
@@ -74,6 +76,66 @@ namespace PrivaPub.Tests.Federation
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount); Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
} }
// Our followers-only posts are addressed to the followers collection, never to each follower, so a follower is found
// among our followers, not in to/cc. The town found every follower's like, reaction and downvote dropped (G-0001).
[Fact]
public async Task A_follower_likes_reacts_to_and_downvotes_a_followers_only_post()
{
var token = TestContext.Current.CancellationToken;
var (_, post) = await LocalPost("followersonly");
var carol = new RemoteActor(_harness.Peer, "carol");
await _harness.FollowedBy(_harness.Local.FromAvatar(await DB.Default.Find<Avatar>().OneAsync(post.GroupUserId, token)), carol);
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(carol)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = carol.Id, ["object"] = post.ObjectURI
});
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(carol)}/reactions/{Guid.NewGuid():N}", ["type"] = "EmojiReact", ["actor"] = carol.Id, ["object"] = post.ObjectURI,
["content"] = "🔥"
});
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(carol)}/dislikes/{Guid.NewGuid():N}", ["type"] = "Dislike", ["actor"] = carol.Id, ["object"] = post.ObjectURI
});
var stored = await DB.Default.Find<Post>().OneAsync(post.ID, token);
Assert.Equal(1, stored.FavouritesCount);
Assert.Equal(1, stored.DownvotesCount);
Assert.True(await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID && r.ActorURI == carol.Id && r.Emoji == "🔥").ExecuteAnyAsync(token));
}
[Fact]
public async Task A_circle_member_likes_a_circle_post_and_an_outsider_cannot()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var carol = new RemoteActor(_harness.Peer, "carol");
var mallory = new RemoteActor(_harness.Peer, "mallory");
var circle = new Group
{
UserName = $"circle{Guid.NewGuid():N}"[..20], Name = "a circle", Kind = GroupKind.Circle, OwnerAvatarId = alice.Id,
Members = { new() { AvatarId = alice.Id }, new() { AvatarId = carol.Id, IsForeign = true } }
};
await DB.Default.SaveAsync(circle, token);
var post = new Post
{
GroupUserId = alice.Id, AuthorAccountId = alice.Id, GroupId = circle.ID, Visibility = PostVisibility.Circle, Text = "only us",
ObjectURI = $"{alice.Uri}/scribbles/{Guid.NewGuid():N}"
};
await DB.Default.SaveAsync(post, token);
foreach (var who in new[] { carol, mallory })
await _harness.Deliver(who, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(who)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = who.Id, ["object"] = post.ObjectURI
});
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 == carol.Id).ExecuteAnyAsync(token));
}
[Fact] [Fact]
public async Task A_boost_of_a_local_post_counts_notifies_and_can_be_undone() public async Task A_boost_of_a_local_post_counts_notifies_and_can_be_undone()
{ {
@@ -1,8 +1,10 @@
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group; using PrivaPub.Models.Group;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Models.Social; using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using System.Linq.Expressions; using System.Linq.Expressions;
@@ -47,6 +49,31 @@ namespace PrivaPub.Domain.Privacy
}; };
} }
// A remote account may see a post, and so like, react to, vote on or downvote it, when the post was for it: anyone for
// public and unlisted, an addressed account for a DM, a follower or an addressed account for followers-only (our own
// followers-only posts are addressed to the followers collection, never to each follower), a member for a circle.
// Accounts only: the instance-actor alias of SignedFetchAuthorizer.MayRead is for refetches, never for interactions.
public static async Task<bool> RemoteCanSee(Post post, ForeignAvatar actor, CancellationToken token)
{
if (!Shown(post) || actor == default || string.IsNullOrEmpty(actor.ActorURI))
return false;
if (IsPublicCompiled(post))
return true;
var addressed = post.To.Contains(actor.ActorURI) || post.Cc.Contains(actor.ActorURI) || post.Mentions.Any(m => m.ActorURI == actor.ActorURI);
return post.Visibility switch
{
PostVisibility.Direct => addressed,
//a remote author's followers live on other servers too; we know only who it addressed
PostVisibility.FollowersOnly => addressed || !post.IsFederatedCopy && !string.IsNullOrEmpty(post.GroupUserId) && await DB.Default.Find<Follower>()
.Match(f => f.LocalActorId == post.GroupUserId && f.LocalActorKind == LocalActorKind.Person && f.IsAccepted && f.ActorURI == actor.ActorURI)
.ExecuteAnyAsync(token),
PostVisibility.Circle => !string.IsNullOrEmpty(post.GroupId) && await DB.Default.Find<GroupEntity>()
.Match(g => g.ID == post.GroupId && g.Members.Any(m => m.IsForeign && m.AvatarId == actor.ActorURI))
.ExecuteAnyAsync(token),
_ => false
};
}
static async Task<bool> FollowsAuthor(string viewerAvatarId, Post post, CancellationToken token) static async Task<bool> FollowsAuthor(string viewerAvatarId, Post post, CancellationToken token)
{ {
if (!string.IsNullOrEmpty(post.ActorURI)) if (!string.IsNullOrEmpty(post.ActorURI))
+4 -3
View File
@@ -1,9 +1,9 @@
using MongoDB.Driver; using MongoDB.Driver;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox; using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering; using PrivaPub.Federation.Rendering;
@@ -43,9 +43,10 @@ namespace PrivaPub.Domain.Social
public async Task Receive(PostEntity post, ForeignAvatar actor, string content, JsonNode tags, string activityId, CancellationToken token) public async Task Receive(PostEntity post, ForeignAvatar actor, string content, JsonNode tags, string activityId, CancellationToken token)
{ {
if (!LikeHandler.MaySee(post, actor) || Normalise(content, ObjectShapes.Emojis(tags)) is not { } emoji) var visible = await VisibilityPolicy.RemoteCanSee(post, actor, token);
if (!visible || Normalise(content, ObjectShapes.Emojis(tags)) is not { } emoji)
{ {
Arrival.Drop(LikeHandler.MaySee(post, actor) ? "unparseable" : "not-visible"); Arrival.Drop(visible ? "unparseable" : "not-visible");
return; return;
} }
try try
+2 -1
View File
@@ -1,6 +1,7 @@
using MongoDB.Driver; using MongoDB.Driver;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Domain.Privacy;
using PrivaPub.Domain.Social; using PrivaPub.Domain.Social;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox.Handlers; using PrivaPub.Federation.Inbox.Handlers;
@@ -120,7 +121,7 @@ namespace PrivaPub.Domain.Statuses
public async Task Receive(PostEntity post, ForeignAvatar voter, string choice, string activityId, CancellationToken token) public async Task Receive(PostEntity post, ForeignAvatar voter, string choice, string activityId, CancellationToken token)
{ {
var poll = post.Poll; var poll = post.Poll;
if (poll == default || post.IsFederatedCopy || Ended(poll) || !LikeHandler.MaySee(post, voter)) if (poll == default || post.IsFederatedCopy || Ended(poll) || !await VisibilityPolicy.RemoteCanSee(post, voter, token))
return; return;
var index = poll.Options.FindIndex(o => o.Title == choice); var index = poll.Options.FindIndex(o => o.Title == choice);
if (index < 0) if (index < 0)
@@ -1,6 +1,7 @@
using MongoDB.Driver; using MongoDB.Driver;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Domain.Privacy;
using PrivaPub.Models.Social; using PrivaPub.Models.Social;
using PrivaPub.Models.User; using PrivaPub.Models.User;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
@@ -30,7 +31,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
var post = objectUri == default var post = objectUri == default
? default ? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token); : await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
if (post == default || !LikeHandler.MaySee(post, actor)) if (post == default || !await VisibilityPolicy.RemoteCanSee(post, actor, token))
{ {
Arrival.Drop(post == default ? "unknown-object" : "not-visible"); Arrival.Drop(post == default ? "unknown-object" : "not-visible");
return; return;
@@ -1,6 +1,7 @@
using MongoDB.Driver; using MongoDB.Driver;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Domain.Privacy;
using PrivaPub.Domain.Social; using PrivaPub.Domain.Social;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Models.Social; using PrivaPub.Models.Social;
@@ -34,7 +35,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
var post = objectUri == default var post = objectUri == default
? default ? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token); : await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
if (post == default || !MaySee(post, actor)) if (post == default || !await VisibilityPolicy.RemoteCanSee(post, actor, token))
{ {
Arrival.Drop(post == default ? "unknown-object" : "not-visible"); Arrival.Drop(post == default ? "unknown-object" : "not-visible");
return; return;
@@ -61,10 +62,5 @@ namespace PrivaPub.Federation.Inbox.Handlers
if (!post.IsFederatedCopy) if (!post.IsFederatedCopy)
await Notifications.Add(post.GroupUserId, NotificationType.Favourite, actor.ID, actor.ActorURI, post.ID, token); 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));
} }
} }