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
This commit is contained in:
1 parent
5d5f1d6909
commit
a3a66b6db2
17 files changed
+256
-25
No files matched your search
@@ -27,7 +27,9 @@ namespace PrivaPub.Domain.Statuses
|
||||
// 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.
|
||||
// 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);
|
||||
@@ -68,9 +70,20 @@ namespace PrivaPub.Domain.Statuses
|
||||
_ => 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 || Rule(target, kind) is not { } rule)
|
||||
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;
|
||||
@@ -102,6 +115,13 @@ namespace PrivaPub.Domain.Statuses
|
||||
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
|
||||
@@ -177,14 +197,28 @@ namespace PrivaPub.Domain.Statuses
|
||||
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);
|
||||
// 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)
|
||||
{
|
||||
@@ -202,7 +236,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
["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
|
||||
["announceAuthorization"] = post.ApprovalURI
|
||||
};
|
||||
await _delivery.EnqueueToFollowers(author, announce, token, string.IsNullOrEmpty(actor.InboxURL) ? default : new[] { actor.InboxURL });
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Federation.Inbox.Handlers
|
||||
{
|
||||
// FEP-5624 (PeerTube): the author of a post whose canReply asks it to approve each reply lets one of ours in, which then
|
||||
// goes to its audience carrying the approval (replyApproval)
|
||||
public class ReplyApprovalHandler : IActivityHandler
|
||||
{
|
||||
readonly IInteractionApprovals _approvals;
|
||||
|
||||
public ReplyApprovalHandler(IInteractionApprovals approvals) => _approvals = approvals;
|
||||
|
||||
public virtual string Type => "ApproveReply";
|
||||
|
||||
protected virtual bool Approves => true;
|
||||
|
||||
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) =>
|
||||
await _approvals.Answered(activity, actor, Approves, token);
|
||||
}
|
||||
|
||||
// and refuses one, which stays ours alone
|
||||
public class ReplyRejectionHandler : ReplyApprovalHandler
|
||||
{
|
||||
public ReplyRejectionHandler(IInteractionApprovals approvals) : base(approvals)
|
||||
{
|
||||
}
|
||||
|
||||
public override string Type => "RejectReply";
|
||||
|
||||
protected override bool Approves => false;
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ namespace PrivaPub.Federation.Inbox
|
||||
post.ReplyPolicy = note.ReplyPolicy;
|
||||
post.LikePolicy = note.LikePolicy;
|
||||
post.AnnouncePolicy = note.AnnouncePolicy;
|
||||
post.ReplyApprovals = note.ReplyApprovals;
|
||||
post.Video = note.Video ?? post.Video;
|
||||
post.Audio = note.Audio ?? post.Audio;
|
||||
post.Event = note.Event ?? post.Event;
|
||||
|
||||
@@ -110,6 +110,7 @@ namespace PrivaPub.Federation.Inbox
|
||||
ReplyPolicy = note.ReplyPolicy,
|
||||
LikePolicy = note.LikePolicy,
|
||||
AnnouncePolicy = note.AnnouncePolicy,
|
||||
ReplyApprovals = note.ReplyApprovals,
|
||||
Emojis = note.Emojis.ToList(),
|
||||
CoverURL = note.CoverURL,
|
||||
Link = note.Link,
|
||||
|
||||
@@ -44,6 +44,7 @@ namespace PrivaPub.Federation.Objects
|
||||
public InteractionRule ReplyPolicy { get; init; }
|
||||
public InteractionRule LikePolicy { get; init; }
|
||||
public InteractionRule AnnouncePolicy { get; init; }
|
||||
public InteractionRule ReplyApprovals { get; init; }//FEP-5624's canReply: who may reply, each reply then approved or refused
|
||||
public IReadOnlyList<CustomEmoji> Emojis { get; init; } = Array.Empty<CustomEmoji>();
|
||||
public string CoverURL { get; init; }
|
||||
public PostLink Link { get; init; }
|
||||
@@ -122,6 +123,7 @@ namespace PrivaPub.Federation.Objects
|
||||
ReplyPolicy = ObjectShapes.Policy(note, "canReply"),
|
||||
LikePolicy = ObjectShapes.Policy(note, "canLike"),
|
||||
AnnouncePolicy = ObjectShapes.Policy(note, "canAnnounce"),
|
||||
ReplyApprovals = ObjectShapes.ReplyApprovals(note),
|
||||
Emojis = ObjectShapes.Emojis(note["tag"]),
|
||||
CoverURL = ObjectShapes.Cover(note),
|
||||
Link = ObjectShapes.Link(note),
|
||||
|
||||
@@ -102,16 +102,23 @@ namespace PrivaPub.Federation.Objects
|
||||
|
||||
// one rule of a post's interactionPolicy (GoToSocial, FEP-044f): who may, automatically or once asked; the old names
|
||||
// (always, approvalRequired) too. A rule left out means anyone, automatically
|
||||
static List<string> Who(JsonNode node) => node switch
|
||||
{
|
||||
JsonArray array => array.Select(Id).Where(id => id != default).Take(MaxAudience).ToList(),
|
||||
JsonNode single when Id(single) is { } id => new List<string> { id },
|
||||
_ => new List<string>()
|
||||
};
|
||||
|
||||
// FEP-5624's canReply (PeerTube's, on a video whose comments wait for approval): who may reply, each reply then
|
||||
// approved by the author with an ApproveReply, or refused with a RejectReply; null when the post states none (PeerTube
|
||||
// sends null for a video whose comments are open, and an empty list where they are closed)
|
||||
public static InteractionRule ReplyApprovals(JsonObject note) =>
|
||||
note["canReply"] is { } canReply ? new InteractionRule { Manual = Who(canReply) } : default;
|
||||
|
||||
public static InteractionRule Policy(JsonObject note, string rule)
|
||||
{
|
||||
if (note["interactionPolicy"] is not JsonObject policy || policy[rule] is not JsonObject allowed)
|
||||
return default;
|
||||
static List<string> Who(JsonNode node) => node switch
|
||||
{
|
||||
JsonArray array => array.Select(Id).Where(id => id != default).Take(MaxAudience).ToList(),
|
||||
JsonNode single when Id(single) is { } id => new List<string> { id },
|
||||
_ => new List<string>()
|
||||
};
|
||||
return new InteractionRule
|
||||
{
|
||||
Automatic = Who(allowed["automaticApproval"] ?? allowed["always"]),
|
||||
|
||||
@@ -63,6 +63,7 @@ namespace PrivaPub.Federation.Rendering
|
||||
["AnnounceAuthorization"] = "gts:AnnounceAuthorization",
|
||||
["likeAuthorization"] = new JsonObject { ["@id"] = "gts:likeAuthorization", ["@type"] = "@id" },
|
||||
["replyAuthorization"] = new JsonObject { ["@id"] = "gts:replyAuthorization", ["@type"] = "@id" },
|
||||
["replyApproval"] = new JsonObject { ["@id"] = "toot:replyApproval", ["@type"] = "@id" },
|
||||
["announceAuthorization"] = new JsonObject { ["@id"] = "gts:announceAuthorization", ["@type"] = "@id" },
|
||||
["litepub"] = "http://litepub.social/ns#",
|
||||
["EmojiReact"] = "litepub:EmojiReact",
|
||||
@@ -408,6 +409,8 @@ namespace PrivaPub.Federation.Rendering
|
||||
// the parent's author let this reply in (GoToSocial's interaction policies)
|
||||
if (!string.IsNullOrEmpty(inReplyTo) && !string.IsNullOrEmpty(post.ApprovalURI))
|
||||
note["replyAuthorization"] = post.ApprovalURI;
|
||||
if (!string.IsNullOrEmpty(inReplyTo) && !string.IsNullOrEmpty(post.ReplyApprovalURI))
|
||||
note["replyApproval"] = post.ReplyApprovalURI;
|
||||
if (post.EditedAt.HasValue)
|
||||
note["updated"] = Timestamp(post.EditedAt.Value);
|
||||
return note;
|
||||
|
||||
@@ -80,6 +80,8 @@ namespace PrivaPub.Middleware
|
||||
.AddSingleton<IActivityHandler, FollowHandler>()
|
||||
.AddSingleton<IActivityHandler, AcceptHandler>()
|
||||
.AddSingleton<IActivityHandler, RejectHandler>()
|
||||
.AddSingleton<IActivityHandler, ReplyApprovalHandler>()
|
||||
.AddSingleton<IActivityHandler, ReplyRejectionHandler>()
|
||||
.AddSingleton<IActivityHandler, UndoHandler>()
|
||||
.AddSingleton<IActivityHandler, LikeHandler>()
|
||||
.AddSingleton<IActivityHandler, DislikeHandler>()
|
||||
|
||||
@@ -82,9 +82,17 @@ namespace PrivaPub.Models.Post
|
||||
public InteractionRule LikePolicy { get; set; }
|
||||
[BsonIgnoreIfNull]
|
||||
public InteractionRule AnnouncePolicy { get; set; }
|
||||
// our reply or boost of a remote post whose policy asks its author first, and the author's authorization once given
|
||||
// a remote post's FEP-5624 canReply (PeerTube): who may reply (Manual), each reply then approved by its author with an
|
||||
// ApproveReply or refused with a RejectReply; null when it states none
|
||||
[BsonIgnoreIfNull]
|
||||
public InteractionRule ReplyApprovals { get; set; }
|
||||
// our reply or boost of a remote post whose policy asks its author first, and the author's authorization once given:
|
||||
// GoToSocial's (ApprovalURI, sent as replyAuthorization), or FEP-5624's ApproveReply (ReplyApprovalURI, sent as
|
||||
// replyApproval)
|
||||
public ApprovalState Approval { get; set; }
|
||||
public string ApprovalURI { get; set; }
|
||||
[BsonIgnoreIfNull]
|
||||
public string ReplyApprovalURI { get; set; }
|
||||
public List<string> To { get; set; } = new();
|
||||
public List<string> Cc { get; set; } = new();
|
||||
|
||||
|
||||
Reference in new issue
Block a user