Files
SocialPub/PrivaPub/Domain/Statuses/InteractionApprovals.cs
T
thepraandClaude Opus 5.5 5860b71223 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
2026-10-05 11:43:58 +02:00

265 lines
12 KiB
C#

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);
}
}
}