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:
thepraandClaude Opus 5.5 committed 2026-10-06 16:16:34 +02:00
1 parent 5d5f1d6909
commit a3a66b6db2
17 files changed
+256 -25

No files matched your search

+2 -1
View File
@@ -664,7 +664,8 @@ tools/pasture/run.sh down # removes e
- **PeerTube (8.3.1):** the official image on the shared Postgres and Redis (db 4), configured through `PEERTUBE_*` - **PeerTube (8.3.1):** the official image on the shared Postgres and Redis (db 4), configured through `PEERTUBE_*`
variables, trusting Caddy's CA through `NODE_EXTRA_CA_CERTS`; `peertube_settle` turns transcoding off (a test video variables, trusting Caddy's CA through `NODE_EXTRA_CA_CERTS`; `peertube_settle` turns transcoding off (a test video
is served as uploaded) and keeps root's token in `.state/peertube.token`. It wants a bare `Host: peertube.test`. is served as uploaded) and keeps root's token in `.state/peertube.token`. It wants a bare `Host: peertube.test`.
`scenarios/peertube.sh`, 24 checks. Its list of comments held for review (`/api/v1/users/me/videos/comments?isHeldForReview=true`) answers 500 in 8.3.1;
the video's owner sees them in the video's threads (`heldForReview`). `scenarios/peertube.sh`, 28 checks.
- **Pleroma (2.10.2):** `images/pleroma` installs the OTP release, pinned by checksum, as Akkoma's does; it also needs - **Pleroma (2.10.2):** `images/pleroma` installs the OTP release, pinned by checksum, as Akkoma's does; it also needs
libvips, and `instance gen` asks about deduplicating uploads. Its federation runs on hackney, which trusts only libvips, and `instance gen` asks about deduplicating uploads. Its federation runs on hackney, which trusts only
certifi's compiled-in roots, so the entrypoint points `:pleroma, :http, adapter` at the system CA bundle. It answers certifi's compiled-in roots, so the entrypoint points `:pleroma, :http, adapter` at the system CA bundle. It answers
+8
View File
@@ -96,6 +96,8 @@ covered by unit tests written in their documents' shape.
passed on by the owner (on its FEP-8b32 proof, or as its origin has it) passed on by the owner (on its FEP-8b32 proof, or as its origin has it)
- [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md) - [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md)
(sent and honoured; see "Followers synchronisation") (sent and honoured; see "Followers synchronisation")
- [FEP-5624: Per-object reply control policies](https://codeberg.org/fediverse/fep/src/branch/main/fep/5624/fep-5624.md):
read (see "Replies its author approves")
Planned (see `docs/ROADMAP.md`, phases P7 and P8, and the per-platform notes in `docs/INTEROP.md`): FEP-9098 (custom Planned (see `docs/ROADMAP.md`, phases P7 and P8, and the per-platform notes in `docs/INTEROP.md`): FEP-9098 (custom
emoji), FEP-7888 and FEP-f228 (threads), FEP-7628 (Move), FEP-8967 (link emoji), FEP-7888 and FEP-f228 (threads), FEP-7628 (Move), FEP-8967 (link
@@ -290,6 +292,12 @@ a post first met after its edits has them, and an edit that carries them brings
- Our own posts state only `canQuote`: anyone may reply to, like and boost them. - Our own posts state only `canQuote`: anyone may reply to, like and boost them.
**Custom emoji** (`Emoji` tags) are read on posts, display names, bios and profile fields, at most 64 per object. **Custom emoji** (`Emoji` tags) are read on posts, display names, bios and profile fields, at most 64 per object.
**Replies its author approves** (FEP-5624's `canReply`, as PeerTube sets it on a video whose comments are moderated; a
`null` one says nothing): a persona it names, or that the post mentions, may reply, and the reply 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`; a `RejectReply` leaves it ours alone
(`rejected`). An empty `canReply` refuses every reply (422).
**Profiles** keep their header, profile fields, `manuallyApprovesFollowers`, `published`, `movedTo`, `indexable`, **Profiles** keep their header, profile fields, `manuallyApprovesFollowers`, `published`, `movedTo`, `indexable`,
`memorial` and avatar and header descriptions. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show `memorial` and avatar and header descriptions. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show
`name`. A content warning without its own text uses the title, or "Content warning". `name`. A content warning without its own text uses the title, or "Content warning".
@@ -203,5 +203,82 @@ namespace PrivaPub.Tests.Federation
await _harness.Deliver(carol, "/human-centipede", Create(carol, asked)); await _harness.Deliver(carol, "/human-centipede", Create(carol, asked));
Assert.True(await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(asked)).ExecuteAnyAsync(token)); Assert.True(await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(asked)).ExecuteAnyAsync(token));
} }
// bob's public post whose replies wait for his approval (FEP-5624's canReply, as PeerTube sets it on a video whose
// comments are moderated), which alice follows bob to see
async Task<(PrivaPub.Federation.Actors.LocalActor Alice, RemoteActor Bob, Post Post, RemoteActor Fan)> Moderated(JsonNode canReply)
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var bob = new RemoteActor(_harness.Peer, "bob");
var fan = new RemoteActor(_harness.Peer, "fan");
await Follows(alice.Id, bob);
await _harness.FollowedBy(alice, fan);
var note = PublicNote(bob, "<p>a moderated post</p>");
note["canReply"] = canReply;
await _harness.Deliver(bob, "/human-centipede", Create(bob, note));
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token);
return (alice, bob, post, fan);
}
Task Answer(RemoteActor from, string type, Post reply, string inReplyTo) => _harness.Deliver(from, "/human-centipede", new JsonObject
{
["id"] = NewId(from, "approvals"), ["type"] = type, ["actor"] = from.Id, ["object"] = reply.ObjectURI, ["inReplyTo"] = inReplyTo
});
[Fact]
public async Task A_reply_its_author_approves_goes_to_that_author_alone_then_out_with_the_approval()
{
var token = TestContext.Current.CancellationToken;
var (alice, bob, post, fan) = await Moderated("https://www.w3.org/ns/activitystreams#Public");
var reply = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "a comment to approve", InReplyTo = post.ID }, token)).Post;
Assert.Equal(ApprovalState.Pending, reply.Approval);
var asked = Assert.Single(await To(bob), a => a["type"]!.GetValue<string>() == "Create");
Assert.Equal(reply.ObjectURI, asked["object"]!["id"]!.GetValue<string>());
Assert.DoesNotContain(await To(bob), a => a["type"]!.GetValue<string>() == "ReplyRequest");
Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue<string>() == "Create");
var approval = NewId(bob, "approve-reply");
await _harness.Deliver(bob, "/human-centipede", new JsonObject
{
["id"] = approval, ["type"] = "ApproveReply", ["actor"] = bob.Id, ["object"] = reply.ObjectURI, ["inReplyTo"] = post.ObjectURI
});
var after = await DB.Default.Find<Post>().OneAsync(reply.ID, token);
Assert.Equal((ApprovalState.Accepted, approval), (after.Approval, after.ReplyApprovalURI));
var create = Assert.Single(await ToShared(fan), a => a["type"]!.GetValue<string>() == "Create");
Assert.Equal(approval, create["object"]!["replyApproval"]!.GetValue<string>());
Assert.Null(create["object"]!["replyAuthorization"]);
}
[Fact]
public async Task A_refused_reply_stays_ours_alone_and_an_approval_from_elsewhere_changes_nothing()
{
var token = TestContext.Current.CancellationToken;
var (alice, bob, post, fan) = await Moderated("https://www.w3.org/ns/activitystreams#Public");
var reply = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "a comment to refuse", InReplyTo = post.ID }, token)).Post;
// an approval by someone else than the post's author, or naming another post, is no approval
await Answer(new RemoteActor(_harness.Peer, "mallory"), "ApproveReply", reply, post.ObjectURI);
await Answer(bob, "ApproveReply", reply, "https://elsewhere.invalid/post");
Assert.Equal(ApprovalState.Pending, (await DB.Default.Find<Post>().OneAsync(reply.ID, token)).Approval);
await Answer(bob, "RejectReply", reply, post.ObjectURI);
Assert.Equal(ApprovalState.Rejected, (await DB.Default.Find<Post>().OneAsync(reply.ID, token)).Approval);
Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue<string>() == "Create");
}
[Fact]
public async Task Only_those_its_canReply_names_or_it_mentions_may_reply()
{
var (alice, _, post, _) = await Moderated(new JsonArray());
var refused = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "nobody asked me", InReplyTo = post.ID },
TestContext.Current.CancellationToken);
Assert.Equal(422, refused.Status);
}
} }
} }
@@ -90,6 +90,26 @@ namespace PrivaPub.Tests.Federation
Assert.Equal(["https://privapub.test/peasants/flor_pp98"], note.Cc); Assert.Equal(["https://privapub.test/peasants/flor_pp98"], note.Cc);
} }
// PeerTube's video whose comments wait for approval names who may comment (FEP-5624), apart from GoToSocial's policy
[Fact]
public void Reads_who_may_reply_pending_approval()
{
var video = NoteParser.Parse(JsonNode.Parse("""
{"id":"https://peertube.test/videos/watch/1","type":"Video","name":"a video","attributedTo":["https://peertube.test/accounts/pt"],
"canReply":"https://www.w3.org/ns/activitystreams#Public","commentsEnabled":true}
"""));
var open = NoteParser.Parse(JsonNode.Parse("""
{"id":"https://peertube.test/videos/watch/2","type":"Video","name":"an open video","attributedTo":["https://peertube.test/accounts/pt"],
"canReply":null,"commentsPolicy":1}
"""));
var note = NoteParser.Parse(JsonNode.Parse(MastodonNote));
Assert.Equal(["https://www.w3.org/ns/activitystreams#Public"], video.ReplyApprovals.Manual);
Assert.Null(video.ReplyPolicy);
Assert.Null(open.ReplyApprovals);
Assert.Null(note.ReplyApprovals);
}
// Pleroma's edited post carries its earlier versions (formerRepresentations, newest first) // Pleroma's edited post carries its earlier versions (formerRepresentations, newest first)
[Fact] [Fact]
public void Reads_the_earlier_versions_of_a_pleroma_edit() public void Reads_the_earlier_versions_of_a_pleroma_edit()
+2
View File
@@ -65,6 +65,8 @@ namespace PrivaPub.Tests.Support
new FollowHandler(Db, Local, Remote, Delivery), new FollowHandler(Db, Local, Remote, Delivery),
new AcceptHandler(Db, Local, Quotes, Approvals, Participations, Relays, Queue), new AcceptHandler(Db, Local, Quotes, Approvals, Participations, Relays, Queue),
new RejectHandler(Db, Local, Quotes, Approvals, Participations, Relays), new RejectHandler(Db, Local, Quotes, Approvals, Participations, Relays),
new ReplyApprovalHandler(Approvals),
new ReplyRejectionHandler(Approvals),
new UndoHandler(Db, Local, Reactions), new UndoHandler(Db, Local, Reactions),
new LikeHandler(Db, Reactions), new LikeHandler(Db, Reactions),
new EmojiReactHandler(Db, Reactions), new EmojiReactHandler(Db, Reactions),
@@ -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 // 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 // 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 // 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 public interface IInteractionApprovals
{ {
Task<QuotePermission> Judge(PostEntity target, LocalActor actor, InteractionKind kind, CancellationToken token); Task<QuotePermission> Judge(PostEntity target, LocalActor actor, InteractionKind kind, CancellationToken token);
@@ -68,9 +70,20 @@ namespace PrivaPub.Domain.Statuses
_ => post.AnnouncePolicy _ => 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) 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; return QuotePermission.Granted;
if (await Includes(rule.Automatic, target, actor, token)) if (await Includes(rule.Automatic, target, actor, token))
return QuotePermission.Granted; return QuotePermission.Granted;
@@ -102,6 +115,13 @@ namespace PrivaPub.Domain.Statuses
var author = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == target.ActorURI).ExecuteFirstAsync(token); var author = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == target.ActorURI).ExecuteFirstAsync(token);
if (string.IsNullOrEmpty(author?.InboxURL)) if (string.IsNullOrEmpty(author?.InboxURL))
return; 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(); var instrument = (JsonObject)interaction.DeepClone();
instrument.Remove("@context"); instrument.Remove("@context");
await _delivery.Enqueue(actor, new[] { author.InboxURL }, new JsonObject 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); await DB.Default.Update<PostEntity>().MatchID(target.ID).Modify(b => b.Inc(p => p.ReblogsCount, -1)).ExecuteAsync(token);
return true; return true;
} }
var authorization = Id(answer["result"]); // FEP-5624's ApproveReply is its own stamp, from the author (the delivery's signature says so) and naming the post
var interactionUri = kind == InteractionKind.Reply ? post.ObjectURI : post.ActivityURI; // the reply answers
if (authorization == default || !await Verified(authorization, interactionUri, target, token)) if (Value(answer, "type") == "ApproveReply")
return true; {
post.Approval = ApprovalState.Accepted; if (kind != InteractionKind.Reply || Id(answer) is not { } approval || Id(answer["inReplyTo"]) is { } answers && answers != target.ObjectURI)
post.ApprovalURI = authorization; return true;
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.Approval, ApprovalState.Accepted).Modify(p => p.ApprovalURI, authorization) post.Approval = ApprovalState.Accepted;
.ExecuteAsync(token); 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 // now it goes where it was meant to, carrying the authorization
if (kind == InteractionKind.Reply) if (kind == InteractionKind.Reply)
{ {
@@ -202,7 +236,7 @@ namespace PrivaPub.Domain.Statuses
["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()), ["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()),
["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray()), ["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray()),
["object"] = target.ObjectURI, ["object"] = target.ObjectURI,
["announceAuthorization"] = authorization ["announceAuthorization"] = post.ApprovalURI
}; };
await _delivery.EnqueueToFollowers(author, announce, token, string.IsNullOrEmpty(actor.InboxURL) ? default : new[] { actor.InboxURL }); await _delivery.EnqueueToFollowers(author, announce, token, string.IsNullOrEmpty(actor.InboxURL) ? default : new[] { actor.InboxURL });
return true; 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;
}
}
+1
View File
@@ -33,6 +33,7 @@ namespace PrivaPub.Federation.Inbox
post.ReplyPolicy = note.ReplyPolicy; post.ReplyPolicy = note.ReplyPolicy;
post.LikePolicy = note.LikePolicy; post.LikePolicy = note.LikePolicy;
post.AnnouncePolicy = note.AnnouncePolicy; post.AnnouncePolicy = note.AnnouncePolicy;
post.ReplyApprovals = note.ReplyApprovals;
post.Video = note.Video ?? post.Video; post.Video = note.Video ?? post.Video;
post.Audio = note.Audio ?? post.Audio; post.Audio = note.Audio ?? post.Audio;
post.Event = note.Event ?? post.Event; post.Event = note.Event ?? post.Event;
+1
View File
@@ -110,6 +110,7 @@ namespace PrivaPub.Federation.Inbox
ReplyPolicy = note.ReplyPolicy, ReplyPolicy = note.ReplyPolicy,
LikePolicy = note.LikePolicy, LikePolicy = note.LikePolicy,
AnnouncePolicy = note.AnnouncePolicy, AnnouncePolicy = note.AnnouncePolicy,
ReplyApprovals = note.ReplyApprovals,
Emojis = note.Emojis.ToList(), Emojis = note.Emojis.ToList(),
CoverURL = note.CoverURL, CoverURL = note.CoverURL,
Link = note.Link, Link = note.Link,
@@ -44,6 +44,7 @@ namespace PrivaPub.Federation.Objects
public InteractionRule ReplyPolicy { get; init; } public InteractionRule ReplyPolicy { get; init; }
public InteractionRule LikePolicy { get; init; } public InteractionRule LikePolicy { get; init; }
public InteractionRule AnnouncePolicy { 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 IReadOnlyList<CustomEmoji> Emojis { get; init; } = Array.Empty<CustomEmoji>();
public string CoverURL { get; init; } public string CoverURL { get; init; }
public PostLink Link { get; init; } public PostLink Link { get; init; }
@@ -122,6 +123,7 @@ namespace PrivaPub.Federation.Objects
ReplyPolicy = ObjectShapes.Policy(note, "canReply"), ReplyPolicy = ObjectShapes.Policy(note, "canReply"),
LikePolicy = ObjectShapes.Policy(note, "canLike"), LikePolicy = ObjectShapes.Policy(note, "canLike"),
AnnouncePolicy = ObjectShapes.Policy(note, "canAnnounce"), AnnouncePolicy = ObjectShapes.Policy(note, "canAnnounce"),
ReplyApprovals = ObjectShapes.ReplyApprovals(note),
Emojis = ObjectShapes.Emojis(note["tag"]), Emojis = ObjectShapes.Emojis(note["tag"]),
CoverURL = ObjectShapes.Cover(note), CoverURL = ObjectShapes.Cover(note),
Link = ObjectShapes.Link(note), Link = ObjectShapes.Link(note),
+13 -6
View File
@@ -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 // 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 // (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) public static InteractionRule Policy(JsonObject note, string rule)
{ {
if (note["interactionPolicy"] is not JsonObject policy || policy[rule] is not JsonObject allowed) if (note["interactionPolicy"] is not JsonObject policy || policy[rule] is not JsonObject allowed)
return default; 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 return new InteractionRule
{ {
Automatic = Who(allowed["automaticApproval"] ?? allowed["always"]), Automatic = Who(allowed["automaticApproval"] ?? allowed["always"]),
@@ -63,6 +63,7 @@ namespace PrivaPub.Federation.Rendering
["AnnounceAuthorization"] = "gts:AnnounceAuthorization", ["AnnounceAuthorization"] = "gts:AnnounceAuthorization",
["likeAuthorization"] = new JsonObject { ["@id"] = "gts:likeAuthorization", ["@type"] = "@id" }, ["likeAuthorization"] = new JsonObject { ["@id"] = "gts:likeAuthorization", ["@type"] = "@id" },
["replyAuthorization"] = new JsonObject { ["@id"] = "gts:replyAuthorization", ["@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" }, ["announceAuthorization"] = new JsonObject { ["@id"] = "gts:announceAuthorization", ["@type"] = "@id" },
["litepub"] = "http://litepub.social/ns#", ["litepub"] = "http://litepub.social/ns#",
["EmojiReact"] = "litepub:EmojiReact", ["EmojiReact"] = "litepub:EmojiReact",
@@ -408,6 +409,8 @@ namespace PrivaPub.Federation.Rendering
// the parent's author let this reply in (GoToSocial's interaction policies) // the parent's author let this reply in (GoToSocial's interaction policies)
if (!string.IsNullOrEmpty(inReplyTo) && !string.IsNullOrEmpty(post.ApprovalURI)) if (!string.IsNullOrEmpty(inReplyTo) && !string.IsNullOrEmpty(post.ApprovalURI))
note["replyAuthorization"] = post.ApprovalURI; note["replyAuthorization"] = post.ApprovalURI;
if (!string.IsNullOrEmpty(inReplyTo) && !string.IsNullOrEmpty(post.ReplyApprovalURI))
note["replyApproval"] = post.ReplyApprovalURI;
if (post.EditedAt.HasValue) if (post.EditedAt.HasValue)
note["updated"] = Timestamp(post.EditedAt.Value); note["updated"] = Timestamp(post.EditedAt.Value);
return note; return note;
@@ -80,6 +80,8 @@ namespace PrivaPub.Middleware
.AddSingleton<IActivityHandler, FollowHandler>() .AddSingleton<IActivityHandler, FollowHandler>()
.AddSingleton<IActivityHandler, AcceptHandler>() .AddSingleton<IActivityHandler, AcceptHandler>()
.AddSingleton<IActivityHandler, RejectHandler>() .AddSingleton<IActivityHandler, RejectHandler>()
.AddSingleton<IActivityHandler, ReplyApprovalHandler>()
.AddSingleton<IActivityHandler, ReplyRejectionHandler>()
.AddSingleton<IActivityHandler, UndoHandler>() .AddSingleton<IActivityHandler, UndoHandler>()
.AddSingleton<IActivityHandler, LikeHandler>() .AddSingleton<IActivityHandler, LikeHandler>()
.AddSingleton<IActivityHandler, DislikeHandler>() .AddSingleton<IActivityHandler, DislikeHandler>()
+9 -1
View File
@@ -82,9 +82,17 @@ namespace PrivaPub.Models.Post
public InteractionRule LikePolicy { get; set; } public InteractionRule LikePolicy { get; set; }
[BsonIgnoreIfNull] [BsonIgnoreIfNull]
public InteractionRule AnnouncePolicy { get; set; } 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 ApprovalState Approval { get; set; }
public string ApprovalURI { get; set; } public string ApprovalURI { get; set; }
[BsonIgnoreIfNull]
public string ReplyApprovalURI { get; set; }
public List<string> To { get; set; } = new(); public List<string> To { get; set; } = new();
public List<string> Cc { get; set; } = new(); public List<string> Cc { get; set; } = new();
+4 -1
View File
@@ -724,7 +724,10 @@ The account sends `Create{Video}`; the channel (a Group) sends `Announce{Video}`
- have non-empty `content`, a valid `url` and `published`; - have non-empty `content`, a valid `url` and `published`;
- have an `id` on the actor's host; - have an `id` on the actor's host;
- have an `inReplyTo` that resolves to the video or one of its comments. - have an `inReplyTo` that resolves to the video or one of its comments.
- `commentsPolicy` 2 rejects replies; 3 holds them until approved. - `commentsPolicy` 2 rejects replies; 3 holds them until approved. A video's `canReply` is `null` while comments are
open, Public while they wait for approval; PrivaPub reads it as FEP-5624: a persona's comment waits, goes to the video's
account alone, and goes out with `replyApproval` once PeerTube's `ApproveReply` arrives (2026-10-06, live: the held
comment approved through PeerTube's API reaches PrivaPub as approved).
- PeerTube signs its fetches. - PeerTube signs its fetches.
- It drops followers that have been unreachable for about 7 days (8.2). - It drops followers that have been unreachable for about 7 days (8.2).
+3 -1
View File
@@ -679,7 +679,9 @@ it, raw where it doesn't.
- `indexable`/`discoverable`/`searchableBy`; - `indexable`/`discoverable`/`searchableBy`;
- edit history from `formerRepresentations`: **done 2026-10-06** (Pleroma's and Akkoma's earlier versions, for a post - edit history from `formerRepresentations`: **done 2026-10-06** (Pleroma's and Akkoma's earlier versions, for a post
met after its edits and for the edits missed in between); met after its edits and for the edits missed in between);
- PeerTube reply rules and `ApproveReply`. - PeerTube reply rules and `ApproveReply`: **done 2026-10-06** (FEP-5624's `canReply`: a reply waits for the author's
`ApproveReply` or `RejectReply`, going to the author alone until then; checked live against PeerTube's moderated
comments).
- **Events:** structured RSVP (`Join`/`Leave` with stable ids): **done 2026-10-05** (owner decision above), checked live - **Events:** structured RSVP (`Join`/`Leave` with stable ids): **done 2026-10-05** (owner decision above), checked live
against Mobilizon. against Mobilizon.
+29 -4
View File
@@ -1,5 +1,6 @@
# PeerTube 8.3: a persona follows a channel; the channel's video arrives as a playable post, its file streamed through # PeerTube 8.3: a persona follows a channel; the channel's video arrives as a playable post, its file streamed through
# PrivaPub's media proxy with byte ranges; comments both ways thread; a like counts and its undo too; the video's edit # PrivaPub's media proxy with byte ranges; comments both ways thread; a comment on a video whose comments are moderated
# waits for the owner's approval (FEP-5624); a like counts and its undo too; the video's edit
# and its deletion reach PrivaPub; statistics. PeerTube's users follow channels and accounts of PeerTube-like servers # and its deletion reach PrivaPub; statistics. PeerTube's users follow channels and accounts of PeerTube-like servers
# only, so nothing here has PeerTube following a persona. # only, so nothing here has PeerTube following a persona.
PTB=https://peertube.test:6443 PTB=https://peertube.test:6443
@@ -46,12 +47,13 @@ until_true 30 '[ "$(pcurl -H "$TH" "$PTB/api/v1/video-channels/$channel/follower
&& ok "PeerTube lists alice_peertube among the channel's followers" || ko "PeerTube does not list the follower" && ok "PeerTube lists alice_peertube among the channel's followers" || ko "PeerTube does not list the follower"
echo " videos" echo " videos"
run=$(date +%s)
video=$(pcurl -X POST -H "$TH" "$PTB/api/v1/videos/upload" -F "videofile=@$here/town/media/tiny.mp4;type=video/mp4" \ video=$(pcurl -X POST -H "$TH" "$PTB/api/v1/videos/upload" -F "videofile=@$here/town/media/tiny.mp4;type=video/mp4" \
-F "channelId=$channel_id" -F "name=Hello PrivaPub from PeerTube" -F "description=a tiny test video" -F privacy=1 -F commentsPolicy=1 -F waitTranscoding=false) -F "channelId=$channel_id" -F "name=Hello PrivaPub from PeerTube $run" -F "description=a tiny test video" -F privacy=1 -F commentsPolicy=1 -F waitTranscoding=false)
video_id=$(echo "$video" | j "print(d['video']['shortUUID'])") video_id=$(echo "$video" | j "print(d['video']['shortUUID'])")
[ -n "$video_id" ] && ok "ptuser uploads a video" || ko "upload refused: $(echo "$video" | head -c 200)" [ -n "$video_id" ] && ok "ptuser uploads a video" || ko "upload refused: $(echo "$video" | head -c 200)"
until_true 60 '[ -n "$(p_home_id "Hello PrivaPub from PeerTube")" ]' && ok "the video reaches alice_peertube's home, boosted by its channel" || ko "the video never arrived" until_true 60 '[ -n "$(p_home_id "Hello PrivaPub from PeerTube $run")" ]' && ok "the video reaches alice_peertube's home, boosted by its channel" || ko "the video never arrived"
v_on_p=$(p_home_id "Hello PrivaPub from PeerTube") v_on_p=$(p_home_id "Hello PrivaPub from PeerTube $run")
v_status=$(curl -s -H "$PH" "$P/api/v1/statuses/$v_on_p") v_status=$(curl -s -H "$PH" "$P/api/v1/statuses/$v_on_p")
[ "$(echo "$v_status" | j "print(d['media_attachments'][0]['type'])")" = "video" ] && ok "the video is a playable attachment" || ko "no video attachment: $(echo "$v_status" | j "print(d['media_attachments'])" | head -c 200)" [ "$(echo "$v_status" | j "print(d['media_attachments'][0]['type'])")" = "video" ] && ok "the video is a playable attachment" || ko "no video attachment: $(echo "$v_status" | j "print(d['media_attachments'])" | head -c 200)"
v_file=$(echo "$v_status" | j "print(d['media_attachments'][0]['url'])") v_file=$(echo "$v_status" | j "print(d['media_attachments'][0]['url'])")
@@ -71,6 +73,29 @@ pcurl -o /dev/null -X POST -H "$TH" -H 'Content-Type: application/json' "$PTB/ap
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$v_on_p/context" | j "print(any(\"thank you from PeerTube\" in s[\"content\"] for s in d[\"descendants\"]))")" = "True" ]' \ until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$v_on_p/context" | j "print(any(\"thank you from PeerTube\" in s[\"content\"] for s in d[\"descendants\"]))")" = "True" ]' \
&& ok "ptuser's answer threads under the video on PrivaPub" || ko "PeerTube's answer missing from the thread" && ok "ptuser's answer threads under the video on PrivaPub" || ko "PeerTube's answer missing from the thread"
echo " moderated comments"
# a video whose comments wait for approval (FEP-5624's canReply): alice's comment waits, goes to PeerTube alone, and is let
# in by ptuser's approval (ApproveReply), after which PeerTube shows it
held=$(date +%s)
mvideo=$(pcurl -X POST -H "$TH" "$PTB/api/v1/videos/upload" -F "videofile=@$here/town/media/tiny.mp4;type=video/mp4" \
-F "channelId=$channel_id" -F "name=A moderated video $held" -F "description=comments wait here" -F privacy=1 -F commentsPolicy=3 -F waitTranscoding=false \
| j "print(d['video']['shortUUID'])")
until_true 60 '[ -n "$(p_home_id "A moderated video $held")" ]' >/dev/null
m_on_p=$(p_home_id "A moderated video $held")
m_reply=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=@ptuser@peertube.test a comment to approve $held&in_reply_to_id=$m_on_p&visibility=public" | j "print(d['id'])")
[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$m_reply" | j "print((d.get('privapub') or {}).get('approval'))")" = "pending" ] \
&& ok "alice's comment on a moderated video waits for its approval" || ko "alice's comment on a moderated video does not wait"
# (its owner sees it in the video's threads, held for review; PeerTube 8.3's list of held comments answers 500)
pt_held() { pcurl -H "$TH" "$PTB/api/v1/videos/$mvideo/comment-threads" | j "print(next((c['id'] for c in d['data'] if c.get('heldForReview') and 'approve $held' in c['text']), ''))"; }
until_true 30 '[ -n "$(pt_held)" ]' && ok "PeerTube holds it for review" || ko "PeerTube never held alice's comment"
held_id=$(pt_held)
pcurl -o /dev/null -X POST -H "$TH" "$PTB/api/v1/videos/$mvideo/comments/$held_id/approve"
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$m_reply" | j "print((d.get(\"privapub\") or {}).get(\"approval\"))")" = "None" ]' \
&& ok "ptuser's approval (ApproveReply) lets alice's comment in" || ko "the approval never reached PrivaPub"
[ "$(pcurl "$PTB/api/v1/videos/$mvideo/comment-threads" | j "print(any('a comment to approve $held' in c['text'] for c in d['data']))")" = "True" ] \
&& ok "PeerTube shows the approved comment" || ko "PeerTube does not show the approved comment"
pcurl -o /dev/null -X DELETE -H "$TH" "$PTB/api/v1/videos/$mvideo"
echo " likes" echo " likes"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$v_on_p/favourite" curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$v_on_p/favourite"
until_true 30 '[ "$(pcurl "$PTB/api/v1/videos/$video_id" | j "print(d[\"likes\"])")" = "1" ]' && ok "alice_peertube's like counts on PeerTube" || ko "like not counted on PeerTube" until_true 30 '[ "$(pcurl "$PTB/api/v1/videos/$video_id" | j "print(d[\"likes\"])")" = "1" ]' && ok "alice_peertube's like counts on PeerTube" || ko "like not counted on PeerTube"