diff --git a/CLAUDE.md b/CLAUDE.md index 9c8e0b1..2a381d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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_*` 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`. - `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 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 diff --git a/FEDERATION.md b/FEDERATION.md index 2861e7c..cd38570 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -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) - [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") +- [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 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. **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`, `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". diff --git a/PrivaPub.Tests/Federation/InteractionPolicyTests.cs b/PrivaPub.Tests/Federation/InteractionPolicyTests.cs index 4eee6e1..0e8c0a1 100644 --- a/PrivaPub.Tests/Federation/InteractionPolicyTests.cs +++ b/PrivaPub.Tests/Federation/InteractionPolicyTests.cs @@ -203,5 +203,82 @@ namespace PrivaPub.Tests.Federation await _harness.Deliver(carol, "/human-centipede", Create(carol, asked)); Assert.True(await DB.Default.Find().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, "

a moderated post

"); + note["canReply"] = canReply; + await _harness.Deliver(bob, "/human-centipede", Create(bob, note)); + var post = await DB.Default.Find().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() == "Create"); + Assert.Equal(reply.ObjectURI, asked["object"]!["id"]!.GetValue()); + Assert.DoesNotContain(await To(bob), a => a["type"]!.GetValue() == "ReplyRequest"); + Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue() == "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().OneAsync(reply.ID, token); + Assert.Equal((ApprovalState.Accepted, approval), (after.Approval, after.ReplyApprovalURI)); + var create = Assert.Single(await ToShared(fan), a => a["type"]!.GetValue() == "Create"); + Assert.Equal(approval, create["object"]!["replyApproval"]!.GetValue()); + 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().OneAsync(reply.ID, token)).Approval); + + await Answer(bob, "RejectReply", reply, post.ObjectURI); + Assert.Equal(ApprovalState.Rejected, (await DB.Default.Find().OneAsync(reply.ID, token)).Approval); + Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue() == "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); + } } } diff --git a/PrivaPub.Tests/Federation/NoteParserTests.cs b/PrivaPub.Tests/Federation/NoteParserTests.cs index bb53332..1b40a4e 100644 --- a/PrivaPub.Tests/Federation/NoteParserTests.cs +++ b/PrivaPub.Tests/Federation/NoteParserTests.cs @@ -90,6 +90,26 @@ namespace PrivaPub.Tests.Federation 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) [Fact] public void Reads_the_earlier_versions_of_a_pleroma_edit() diff --git a/PrivaPub.Tests/Support/Harness.cs b/PrivaPub.Tests/Support/Harness.cs index 787da4f..983ce71 100644 --- a/PrivaPub.Tests/Support/Harness.cs +++ b/PrivaPub.Tests/Support/Harness.cs @@ -65,6 +65,8 @@ namespace PrivaPub.Tests.Support new FollowHandler(Db, Local, Remote, Delivery), new AcceptHandler(Db, Local, Quotes, Approvals, Participations, Relays, Queue), new RejectHandler(Db, Local, Quotes, Approvals, Participations, Relays), + new ReplyApprovalHandler(Approvals), + new ReplyRejectionHandler(Approvals), new UndoHandler(Db, Local, Reactions), new LikeHandler(Db, Reactions), new EmojiReactHandler(Db, Reactions), diff --git a/PrivaPub/Domain/Statuses/InteractionApprovals.cs b/PrivaPub/Domain/Statuses/InteractionApprovals.cs index 2cca141..999d96f 100644 --- a/PrivaPub/Domain/Statuses/InteractionApprovals.cs +++ b/PrivaPub/Domain/Statuses/InteractionApprovals.cs @@ -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 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 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().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().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().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().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; diff --git a/PrivaPub/Federation/Inbox/Handlers/ReplyApprovalHandler.cs b/PrivaPub/Federation/Inbox/Handlers/ReplyApprovalHandler.cs new file mode 100644 index 0000000..052cf05 --- /dev/null +++ b/PrivaPub/Federation/Inbox/Handlers/ReplyApprovalHandler.cs @@ -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; + } +} diff --git a/PrivaPub/Federation/Inbox/RemoteEdits.cs b/PrivaPub/Federation/Inbox/RemoteEdits.cs index 6969740..d0b3b9c 100644 --- a/PrivaPub/Federation/Inbox/RemoteEdits.cs +++ b/PrivaPub/Federation/Inbox/RemoteEdits.cs @@ -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; diff --git a/PrivaPub/Federation/Inbox/RemotePosts.cs b/PrivaPub/Federation/Inbox/RemotePosts.cs index d44bb48..2c77a13 100644 --- a/PrivaPub/Federation/Inbox/RemotePosts.cs +++ b/PrivaPub/Federation/Inbox/RemotePosts.cs @@ -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, diff --git a/PrivaPub/Federation/Objects/NoteParser.cs b/PrivaPub/Federation/Objects/NoteParser.cs index c7268c6..f0fb2e5 100644 --- a/PrivaPub/Federation/Objects/NoteParser.cs +++ b/PrivaPub/Federation/Objects/NoteParser.cs @@ -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 Emojis { get; init; } = Array.Empty(); 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), diff --git a/PrivaPub/Federation/Objects/ObjectShapes.cs b/PrivaPub/Federation/Objects/ObjectShapes.cs index 638c8a9..a6e7289 100644 --- a/PrivaPub/Federation/Objects/ObjectShapes.cs +++ b/PrivaPub/Federation/Objects/ObjectShapes.cs @@ -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 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 { id }, + _ => new List() + }; + + // 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 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 { id }, - _ => new List() - }; return new InteractionRule { Automatic = Who(allowed["automaticApproval"] ?? allowed["always"]), diff --git a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs index 64e688c..702ec71 100644 --- a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs +++ b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs @@ -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; diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index d1f5c15..fed3a5a 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -80,6 +80,8 @@ namespace PrivaPub.Middleware .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/PrivaPub/Models/Post/Post.cs b/PrivaPub/Models/Post/Post.cs index b3a1675..714864f 100644 --- a/PrivaPub/Models/Post/Post.cs +++ b/PrivaPub/Models/Post/Post.cs @@ -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 To { get; set; } = new(); public List Cc { get; set; } = new(); diff --git a/docs/INTEROP.md b/docs/INTEROP.md index a02b1bb..d50a0f1 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -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 an `id` on the actor's host; - 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. - It drops followers that have been unreachable for about 7 days (8.2). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index cdac4b4..f303474 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -679,7 +679,9 @@ it, raw where it doesn't. - `indexable`/`discoverable`/`searchableBy`; - 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); - - 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 against Mobilizon. diff --git a/tools/pasture/scenarios/peertube.sh b/tools/pasture/scenarios/peertube.sh index ed89773..c87204e 100644 --- a/tools/pasture/scenarios/peertube.sh +++ b/tools/pasture/scenarios/peertube.sh @@ -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 -# 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 # only, so nothing here has PeerTube following a persona. 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" 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" \ - -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'])") [ -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" -v_on_p=$(p_home_id "Hello PrivaPub from PeerTube") +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 $run") 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)" 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" ]' \ && 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" 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"