GoToSocial's interaction policies are honoured

A remote post's canReply, canLike and canAnnounce (with the older always and approvalRequired) are kept beside
canQuote and judged for each persona: let in at once when the rule names the public, the persona, the author's
followers while it follows the author, or the accounts the author follows while the author follows it; asked first
when only the manual list names it; refused (422) otherwise. Asked first, a ReplyRequest, LikeRequest or
AnnounceRequest with the interaction as its instrument goes to the author alone, and the interaction waits
(privapub.approval: pending). The author's Accept brings an authorization, verified on the author's origin as naming the
interaction and the post; the reply then goes out with replyAuthorization, the boost with announceAuthorization, the
like with likeAuthorization. A Reject leaves the reply ours alone and takes a like or a boost back. As a third party, a
reply a policy does not let in at once is kept only with an authorization that verifies. Clients see the rules as
GoToSocial's interaction_policy.

Checked live against GoToSocial 0.22.1: the scenario's nine new checks pass (64 in all), a reply and a like approved
through its interaction requests and a boost refused.

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-05 11:43:58 +02:00
1 parent c65a44ba88
commit 5860b71223
21 files changed
+687 -26

No files matched your search

@@ -0,0 +1,264 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
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.Domain.Statuses
{
public enum InteractionKind
{
Reply,
Like,
Announce
}
// GoToSocial's interaction policies (canReply, canLike, canAnnounce): who may answer, like or boost a post at once, who
// must ask its author first, and who may not. Asked, the author answers Accept with an authorization (`result`), which
// the interaction then carries for everyone else (replyAuthorization, likeAuthorization, announceAuthorization), or
// Reject. Our own posts state no such policy: anyone may.
public interface IInteractionApprovals
{
Task<QuotePermission> Judge(PostEntity target, LocalActor actor, InteractionKind kind, CancellationToken token);
Task Ask(LocalActor actor, PostEntity target, InteractionKind kind, JsonObject interaction, string localId, CancellationToken token);
Task<bool> Answered(JsonNode answer, ForeignAvatar actor, bool accepted, CancellationToken token);
Task<bool> MayReply(NoteDocument reply, ForeignAvatar replier, PostEntity parent, CancellationToken token);
}
public class InteractionApprovals : IInteractionApprovals
{
readonly DbEntities _dbEntities;
readonly IRemoteActorService _remoteActors;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
readonly IOutboxPublisher _outbox;
public InteractionApprovals(DbEntities dbEntities, IRemoteActorService remoteActors, ILocalActorService localActors, IDeliveryService delivery,
IOutboxPublisher outbox)
{
_dbEntities = dbEntities;
_remoteActors = remoteActors;
_localActors = localActors;
_delivery = delivery;
_outbox = outbox;
}
static string Prefix(InteractionKind kind) => kind switch
{
InteractionKind.Reply => "reply-request-",
InteractionKind.Like => "like-request-",
_ => "announce-request-"
};
static InteractionRule Rule(PostEntity post, InteractionKind kind) => kind switch
{
InteractionKind.Reply => post.ReplyPolicy,
InteractionKind.Like => post.LikePolicy,
_ => post.AnnouncePolicy
};
public async Task<QuotePermission> Judge(PostEntity target, LocalActor actor, InteractionKind kind, CancellationToken token)
{
if (!target.IsFederatedCopy || Rule(target, kind) is not { } rule)
return QuotePermission.Granted;
if (await Includes(rule.Automatic, target, actor, token))
return QuotePermission.Granted;
return await Includes(rule.Manual, target, actor, token) ? QuotePermission.AskFirst : QuotePermission.Denied;
}
// whether a policy's list names the persona: anyone, the persona itself, the author's followers when it follows the
// author, the accounts the author follows when the author follows it
async Task<bool> Includes(List<string> who, PostEntity target, LocalActor actor, CancellationToken token)
{
if (who.Count == 0)
return false;
if (who.Any(Addressing.IsPublic) || who.Contains(actor.Uri))
return true;
var author = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == target.ActorURI).ExecuteFirstAsync(token);
if (author == default)
return false;
if (!string.IsNullOrEmpty(author.FollowersURL) && who.Contains(author.FollowersURL)
&& await _dbEntities.Followings.Match(f => f.AvatarId == actor.Id && f.TargetActorURI == author.ActorURI && f.State == FollowState.Accepted)
.ExecuteAnyAsync(token))
return true;
return !string.IsNullOrEmpty(author.FollowingURL) && who.Contains(author.FollowingURL)
&& await _dbEntities.Followers.Match(f => f.LocalActorId == actor.Id && f.ActorURI == author.ActorURI && f.IsAccepted).ExecuteAnyAsync(token);
}
// asks the author, with the interaction as the request's instrument; it goes to no one else until the author agrees
public async Task Ask(LocalActor actor, PostEntity target, InteractionKind kind, JsonObject interaction, string localId, CancellationToken token)
{
var author = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == target.ActorURI).ExecuteFirstAsync(token);
if (string.IsNullOrEmpty(author?.InboxURL))
return;
var instrument = (JsonObject)interaction.DeepClone();
instrument.Remove("@context");
await _delivery.Enqueue(actor, new[] { author.InboxURL }, new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = actor.ActivityUri(Prefix(kind) + localId),
["type"] = kind switch { InteractionKind.Reply => "ReplyRequest", InteractionKind.Like => "LikeRequest", _ => "AnnounceRequest" },
["actor"] = actor.Uri,
["to"] = target.ActorURI,
["object"] = target.ObjectURI,
["instrument"] = instrument
}, token);
}
// an author's answer to one of our requests (the request, or the interaction itself when it was sent unasked)
public async Task<bool> Answered(JsonNode answer, ForeignAvatar actor, bool accepted, CancellationToken token)
{
var answered = answer["object"];
var answeredId = Id(answered);
if (answeredId == default)
return false;
var (kind, localId) = Request(answeredId);
if (localId == default && answered is JsonObject request && Value(request, "type") is "ReplyRequest" or "LikeRequest" or "AnnounceRequest")
answeredId = Id(request["instrument"]);
if (localId == default && answeredId != default)
(kind, localId) = await Interaction(answeredId, token);
if (localId == default)
return false;
if (kind == InteractionKind.Like)
{
var like = await DB.Default.Find<Favourite>().OneAsync(localId, token);
var liked = like == default ? default : await _dbEntities.Posts.MatchID(like.PostId).ExecuteFirstAsync(token);
if (like is not { Approval: ApprovalState.Pending } || liked?.ActorURI != actor.ActorURI)
return true;
if (!accepted)
{
await DB.Default.DeleteAsync<Favourite>(like.ID);
await DB.Default.Update<PostEntity>().MatchID(liked.ID).Modify(b => b.Inc(p => p.FavouritesCount, -1)).ExecuteAsync(token);
return true;
}
var stamp = Id(answer["result"]);
if (stamp == default || !await Verified(stamp, like.ActivityURI, liked, token))
return true;
await DB.Default.Update<Favourite>().MatchID(like.ID).Modify(f => f.Approval, ApprovalState.Accepted).Modify(f => f.ApprovalURI, stamp)
.ExecuteAsync(token);
var liker = await _localActors.FindById(Models.Federation.LocalActorKind.Person, like.AccountId, token);
if (liker != default && !string.IsNullOrEmpty(actor.InboxURL))
await _delivery.Enqueue(liker, new[] { actor.InboxURL }, new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = like.ActivityURI,
["type"] = "Like",
["actor"] = liker.Uri,
["object"] = liked.ObjectURI,
["likeAuthorization"] = stamp
}, token);
return true;
}
var post = await _dbEntities.Posts.Match(p => p.ID == localId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
var target = post == default ? default : await _dbEntities.Posts.MatchID(kind == InteractionKind.Reply ? post.AnsweringToPostId : post.ReblogOfPostId)
.ExecuteFirstAsync(token);
if (post is not { Approval: ApprovalState.Pending } || target?.ActorURI != actor.ActorURI)
return true;
var author = await _localActors.FindById(Models.Federation.LocalActorKind.Person, post.GroupUserId, token);
if (author == default)
return true;
if (!accepted)
{
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.Approval, ApprovalState.Rejected).ExecuteAsync(token);
if (kind == InteractionKind.Announce)
await DB.Default.Update<PostEntity>().MatchID(target.ID).Modify(b => b.Inc(p => p.ReblogsCount, -1)).ExecuteAsync(token);
return true;
}
var authorization = Id(answer["result"]);
var interactionUri = kind == InteractionKind.Reply ? post.ObjectURI : post.ActivityURI;
if (authorization == default || !await Verified(authorization, interactionUri, target, token))
return true;
post.Approval = ApprovalState.Accepted;
post.ApprovalURI = authorization;
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.Approval, ApprovalState.Accepted).Modify(p => p.ApprovalURI, authorization)
.ExecuteAsync(token);
// now it goes where it was meant to, carrying the authorization
if (kind == InteractionKind.Reply)
{
var create = ActivityPubRenderer.Create(author, ActivityPubRenderer.Note(post, author, default, post.InReplyToURI), $"create-{post.ID}");
await _outbox.Publish(author, post, create, token);
return true;
}
var announce = new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = post.ActivityURI,
["type"] = "Announce",
["actor"] = author.Uri,
["published"] = ActivityPubRenderer.Timestamp(post.CreationDate),
["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()),
["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray()),
["object"] = target.ObjectURI,
["announceAuthorization"] = authorization
};
await _delivery.EnqueueToFollowers(author, announce, token, string.IsNullOrEmpty(actor.InboxURL) ? default : new[] { actor.InboxURL });
return true;
}
(InteractionKind Kind, string LocalId) Request(string requestId)
{
if (requestId == default || !requestId.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
return default;
foreach (var kind in new[] { InteractionKind.Reply, InteractionKind.Like, InteractionKind.Announce })
{
var marker = requestId.LastIndexOf("/grunts/" + Prefix(kind), StringComparison.Ordinal);
if (marker >= 0)
return (kind, requestId[(marker + "/grunts/".Length + Prefix(kind).Length)..]);
}
return default;
}
// an interaction of ours named by its own id, as an answer to one sent unasked names it
async Task<(InteractionKind Kind, string LocalId)> Interaction(string uri, CancellationToken token)
{
if (!uri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
return default;
if (await DB.Default.Find<Favourite>().Match(f => f.ActivityURI == uri).ExecuteFirstAsync(token) is { } like)
return (InteractionKind.Like, like.ID);
if (await _dbEntities.Posts.Match(p => (p.ObjectURI == uri || p.ActivityURI == uri) && !p.IsFederatedCopy).ExecuteFirstAsync(token) is { } post)
return (post.ReblogOfPostId != default ? InteractionKind.Announce : InteractionKind.Reply, post.ID);
return default;
}
// an authorization is the target author's own document: on its server, naming the interaction and the post
async Task<bool> Verified(string authorization, string interactionUri, PostEntity target, CancellationToken token)
{
if (!Origin.Same(authorization, target.ActorURI))
return false;
using var fetched = await _remoteActors.FetchObject(authorization, token);
if (fetched == default)
return false;
var stamp = JsonNode.Parse(fetched.Root.GetRawText());
return Value(stamp, "type") is "ReplyAuthorization" or "LikeAuthorization" or "AnnounceAuthorization" or "LikeApproval" or "ReplyApproval"
or "AnnounceApproval"
&& Id(stamp["interactingObject"]) == interactionUri && Id(stamp["interactionTarget"]) == target.ObjectURI
&& Id(stamp["attributedTo"]) == target.ActorURI;
}
// as a third party: a reply its parent's policy does not let in at once carries the parent author's authorization
public async Task<bool> MayReply(NoteDocument reply, ForeignAvatar replier, PostEntity parent, CancellationToken token)
{
if (parent is not { IsFederatedCopy: true, ReplyPolicy: { } rule } || replier.ActorURI == parent.ActorURI)
return true;
// anyone, the replier itself, or a collection (followers, following) whose members we cannot list from here
var author = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == parent.ActorURI).ExecuteFirstAsync(token);
if (rule.Automatic.Any(Addressing.IsPublic) || rule.Automatic.Contains(replier.ActorURI)
|| author != default && (rule.Automatic.Contains(author.FollowersURL) || rule.Automatic.Contains(author.FollowingURL)))
return true;
return reply.ReplyAuthorization != default && await Verified(reply.ReplyAuthorization, reply.Id, parent, token);
}
}
}
+39 -8
View File
@@ -77,11 +77,13 @@ namespace PrivaPub.Domain.Statuses
readonly IPollService _polls;
readonly ILinkPreviews _previews;
readonly IQuoteService _quotes;
readonly IInteractionApprovals _approvals;
public StatusService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IContentRenderer content, IOutboxPublisher outbox, IFanout fanout, IMediaService media, IGroupDistributor groups, IPollService polls,
ILinkPreviews previews, IQuoteService quotes)
ILinkPreviews previews, IQuoteService quotes, IInteractionApprovals approvals = default)
{
_approvals = approvals;
_previews = previews;
_quotes = quotes;
_polls = polls;
@@ -132,6 +134,10 @@ namespace PrivaPub.Domain.Statuses
// its community's moderators locked the thread
if (parent is { LockedAt: not null })
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This thread is locked");
// its author takes our reply at once, once asked, or not at all (GoToSocial's canReply)
var replyPermission = await Permission(parent, author, InteractionKind.Reply, token);
if (replyPermission == QuotePermission.Denied)
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Its author does not take replies from you");
// a reply in a circle stays in the circle, whichever client wrote it: Mastodon clients know nothing of groups
if (group == default && parent is { Visibility: PostVisibility.Circle } && !string.IsNullOrEmpty(parent.GroupId)
&& await _dbEntities.Groups.MatchID(parent.GroupId).ExecuteFirstAsync(token) is { DeletionAt: null } parentCircle
@@ -213,7 +219,8 @@ namespace PrivaPub.Domain.Statuses
QuoteURI = quoted?.ObjectURI,
QuotedPostId = quoted?.ID,
QuoteByConsent = quoted != default && (quoted.QuotePolicy != default || !quoted.IsFederatedCopy),
QuoteState = quoted == default ? QuoteState.None : quotePermission == QuotePermission.Granted ? QuoteState.Accepted : QuoteState.Pending
QuoteState = quoted == default ? QuoteState.None : quotePermission == QuotePermission.Granted ? QuoteState.Accepted : QuoteState.Pending,
Approval = replyPermission == QuotePermission.AskFirst ? ApprovalState.Pending : ApprovalState.None
};
post.ID = (string)post.GenerateNewID();
post.ObjectURI = author.PostUri(post.ID);
@@ -266,9 +273,12 @@ namespace PrivaPub.Domain.Statuses
if (post.QuoteState == QuoteState.Pending && create?["object"] is JsonObject quotingNote)
await _quotes.Request(author, post, quoted, quotingNote, token);
await _previews.Wanted(post, token);
if (create != default)
// a reply its parent's author must approve goes to that author alone, as a request, until it is approved
if (create != default && post.Approval == ApprovalState.Pending)
await _approvals.Ask(author, parent, InteractionKind.Reply, create["object"].AsObject(), post.ID, token);
else if (create != default)
await _outbox.Publish(author, post, create, token);
if (create != default && group is { IsCircle: false } && visibility is PostVisibility.Public or PostVisibility.Unlisted)
if (create != default && post.Approval != ApprovalState.Pending && group is { IsCircle: false } && visibility is PostVisibility.Public or PostVisibility.Unlisted)
await _groups.Announce(group, create, post.ObjectURI, isNewPost: true, token);
return new StatusOutcome(post);
}
@@ -387,13 +397,20 @@ namespace PrivaPub.Domain.Statuses
var post = await Visible(me, postId, token);
if (post == default)
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
var likePermission = on ? await Permission(post, me, InteractionKind.Like, token) : QuotePermission.Granted;
if (likePermission == QuotePermission.Denied)
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Its author does not take likes from you");
// each favourite is a Like of its own, as Mastodon's are: a favourite after an unfavourite is a new Like, which no
// server (and no delivery queue) takes for the one already undone; an unfavourite undoes the Like it ends
var favourite = default(Favourite);
if (on)
{
favourite = new Favourite { ID = ObjectId.GenerateNewId().ToString(), AccountId = me.Id, ActorURI = me.Uri, PostId = post.ID };
favourite = new Favourite
{
ID = ObjectId.GenerateNewId().ToString(), AccountId = me.Id, ActorURI = me.Uri, PostId = post.ID,
Approval = likePermission == QuotePermission.AskFirst ? ApprovalState.Pending : ApprovalState.None
};
favourite.ActivityURI = me.ActivityUri($"like-{favourite.ID}");
try
{
@@ -427,11 +444,18 @@ namespace PrivaPub.Domain.Statuses
["actor"] = me.Uri,
["object"] = post.ObjectURI
};
await _delivery.Enqueue(me, new[] { inbox }, on ? like : Undo(me, like, $"undo-like-{favourite.ID}"), token);
if (on && favourite.Approval == ApprovalState.Pending)
await _approvals.Ask(me, post, InteractionKind.Like, like, favourite.ID, token);
else
await _delivery.Enqueue(me, new[] { inbox }, on ? like : Undo(me, like, $"undo-like-{favourite.ID}"), token);
}
return new StatusOutcome(post);
}
// how a remote post's interaction policy takes an interaction of ours: at once, once asked, or not at all
async Task<QuotePermission> Permission(PostEntity target, LocalActor actor, InteractionKind kind, CancellationToken token) =>
target is { IsFederatedCopy: true } && _approvals != default ? await _approvals.Judge(target, actor, kind, token) : QuotePermission.Granted;
public async Task<StatusOutcome> Reblog(LocalActor me, string postId, bool on, PostVisibility visibility, CancellationToken token)
{
var original = await Visible(me, postId, token);
@@ -441,6 +465,9 @@ namespace PrivaPub.Domain.Statuses
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
if (original.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This post can't be boosted");
var announcePermission = on ? await Permission(original, me, InteractionKind.Announce, token) : QuotePermission.Granted;
if (announcePermission == QuotePermission.Denied)
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Its author does not take boosts from you");
var existing = await _dbEntities.Posts.Match(p => p.ReblogOfPostId == original.ID && p.AuthorAccountId == me.Id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
// each boost is an activity of its own, as Mastodon's are: a boost after an unboost is a new Announce, which no
@@ -474,7 +501,8 @@ namespace PrivaPub.Domain.Statuses
ActivityURI = announceId,
ActorURI = me.Uri,
To = Strings(announce["to"]),
Cc = Strings(announce["cc"])
Cc = Strings(announce["cc"]),
Approval = announcePermission == QuotePermission.AskFirst ? ApprovalState.Pending : ApprovalState.None
};
try
{
@@ -496,7 +524,10 @@ namespace PrivaPub.Domain.Statuses
if (!original.IsFederatedCopy)
await Notifications.Add(original.GroupUserId, NotificationType.Reblog, me.Id, me.Uri, original.ID, token);
await _fanout.Distribute(reblog, token);
await _delivery.EnqueueToFollowers(me, announce, token, await AuthorInbox(original, token, shared: true) is { } inbox ? new[] { inbox } : default);
if (reblog.Approval == ApprovalState.Pending)
await _approvals.Ask(me, original, InteractionKind.Announce, announce, reblog.ID, token);
else
await _delivery.EnqueueToFollowers(me, announce, token, await AuthorInbox(original, token, shared: true) is { } inbox ? new[] { inbox } : default);
return new StatusOutcome(reblog);
}