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
+246
-15
No files matched your search
@@ -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
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -203,5 +203,82 @@ namespace PrivaPub.Tests.Federation
|
||||
await _harness.Deliver(carol, "/human-centipede", Create(carol, asked));
|
||||
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);
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,6 +197,19 @@ namespace PrivaPub.Domain.Statuses
|
||||
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))
|
||||
@@ -185,6 +218,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
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
|
||||
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>()
|
||||
};
|
||||
|
||||
// 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;
|
||||
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();
|
||||
|
||||
|
||||
+4
-1
@@ -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).
|
||||
|
||||
|
||||
+3
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user