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
299 lines
14 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|