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:
1 parent
c65a44ba88
commit
5860b71223
21 files changed
+687
-26
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user