Files
SocialPub/PrivaPub/Domain/Statuses/InteractionApprovals.cs
T
thepraandClaude Opus 5.5 a3a66b6db2 Replies a post's author approves (FEP-5624), checked live with PeerTube
PeerTube puts canReply on a video whose comments wait for approval, and answers each comment with ApproveReply. A
persona's reply to such a post now waits (privapub.approval: pending), its Create going to the author alone; the
author's ApproveReply, signed by the author and naming the post answered, lets it out to its audience with
replyApproval, and RejectReply leaves it ours. A null canReply (PeerTube's open comments) says nothing; an empty one
refuses. The PeerTube scenario holds a comment for review and approves it through PeerTube's API (28 checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 16:16:34 +02:00

299 lines
14 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. And FEP-5624's canReply (PeerTube's): who may reply, the reply itself going to the author alone, who answers
// ApproveReply, which the reply then carries (replyApproval), or RejectReply. 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
};
// FEP-5624's canReply, where GoToSocial's policy says nothing of replies
static bool ApprovesReplies(PostEntity target, InteractionKind kind) =>
kind == InteractionKind.Reply && target.ReplyPolicy == default && target.ReplyApprovals != default;
public async Task<QuotePermission> Judge(PostEntity target, LocalActor actor, InteractionKind kind, CancellationToken token)
{
if (!target.IsFederatedCopy)
return QuotePermission.Granted;
// those it names, or mentions, may reply, and every reply waits for its author's approval
if (ApprovesReplies(target, kind))
return await Includes(target.ReplyApprovals.Manual, target, actor, token) || target.Mentions.Any(m => m.ActorURI == actor.Uri)
? QuotePermission.AskFirst
: QuotePermission.Denied;
if (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;
// FEP-5624: the reply itself is the question, to the author alone
if (ApprovesReplies(target, kind))
{
await _delivery.Enqueue(actor, new[] { author.InboxURL }, ActivityPubRenderer.Create(actor, (JsonObject)interaction.DeepClone(), $"create-{localId}"),
token);
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;
}
// FEP-5624's ApproveReply is its own stamp, from the author (the delivery's signature says so) and naming the post
// the reply answers
if (Value(answer, "type") == "ApproveReply")
{
if (kind != InteractionKind.Reply || Id(answer) is not { } approval || Id(answer["inReplyTo"]) is { } answers && answers != target.ObjectURI)
return true;
post.Approval = ApprovalState.Accepted;
post.ReplyApprovalURI = approval;
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.Approval, ApprovalState.Accepted).Modify(p => p.ReplyApprovalURI, approval)
.ExecuteAsync(token);
}
else
{
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"] = post.ApprovalURI
};
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);
}
}
}