GoToSocial's interaction policies are honoured

A remote post's canReply, canLike and canAnnounce (with the older always and approvalRequired) are kept beside
canQuote and judged for each persona: let in at once when the rule names the public, the persona, the author's
followers while it follows the author, or the accounts the author follows while the author follows it; asked first
when only the manual list names it; refused (422) otherwise. Asked first, a ReplyRequest, LikeRequest or
AnnounceRequest with the interaction as its instrument goes to the author alone, and the interaction waits
(privapub.approval: pending). The author's Accept brings an authorization, verified on the author's origin as naming the
interaction and the post; the reply then goes out with replyAuthorization, the boost with announceAuthorization, the
like with likeAuthorization. A Reject leaves the reply ours alone and takes a like or a boost back. As a third party, a
reply a policy does not let in at once is kept only with an authorization that verifies. Clients see the rules as
GoToSocial's interaction_policy.

Checked live against GoToSocial 0.22.1: the scenario's nine new checks pass (64 in all), a reply and a like approved
through its interaction requests and a boost refused.

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-05 11:43:58 +02:00
1 parent c65a44ba88
commit 5860b71223
21 files changed
+687 -26

No files matched your search

+5 -3
View File
@@ -98,7 +98,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0
Privacy/ VisibilityPolicy (IsPublic expression, CanSee)
Relationships/ RelationshipService (blocks, mutes, account domain blocks; Hidden), ReportService
Media/ MediaService (libvips, ffmpeg remux, blurhash), MediaProxy, MediaJanitor
Domain/Statuses/ StatusService: publish, edit, remove, favourite, reblog, for a persona (both client APIs use it)
Domain/Statuses/ StatusService: publish, edit, remove, favourite, reblog, for a persona (both client APIs use it);
InteractionApprovals: GoToSocial's canReply/canLike/canAnnounce, judged, asked and answered
Api/Mastodon/
Auth/ OpenIddict setup (keys in Mongo), MastodonScopes, TokenController, OAuthPruner
Infrastructure/ MastodonController (avatar context, scopes, errors, Link), MastodonParams, MastodonJson, Page
@@ -473,9 +474,10 @@ tools/pasture/run.sh down # removes e
delete is checked as a 404 on `/api/v1/statuses/{id}`.
- It creates its accounts locked, so the scenario approves alice's request through `/api/v1/follow_requests`, which
also checks our pending (`requested`) state and the manual Accept.
- 55 checks: discovery and follows both ways; posts and CW; a reply and its notification; likes and boosts both
- 64 checks: discovery and follows both ways; posts and CW; a reply and its notification; likes and boosts both
ways; DMs both ways and off public timelines; polls both ways; quote policy; link cards; edits and deletes both
ways; communities and circles; locked personas; unfollow; block and unblock; statistics.
ways; communities and circles; locked personas; interaction policies (a reply and a like asked for and approved
through `/api/v1/interaction_requests`, a boost refused); unfollow; block and unblock; statistics.
- It files a circle post as a direct message and shows it only to the accounts it mentions, so like a DM it is
checked in the member's `/api/v1/conversations`, never by URI.
- **Mastodon (4.7.3):** web and sidekiq on the shared Postgres and Redis, `ALLOWED_PRIVATE_ADDRESSES` for the network.
+16
View File
@@ -176,6 +176,22 @@ An incoming `Update` without a newer `updated` only refreshes counts and details
`Delete{stamp}`.
- Followers-only posts and DMs can never be quoted.
**Interaction policies** (GoToSocial's `canReply`, `canLike`, `canAnnounce`, with the older `always` and
`approvalRequired`):
- **Read** on every remote post; one left out means anyone, automatically. A persona is let in at once when the rule
names the public, the persona itself, the author's followers while it follows the author, or the accounts the author
follows while the author follows it.
- **Shut out:** the client API answers 422 to a reply, favourite or boost the rule does not allow at all.
- **Asked first:** a `ReplyRequest`, `LikeRequest` or `AnnounceRequest` (the interaction as its `instrument`) goes to the
author alone, and the interaction waits (`privapub.approval: pending`). The author's `Accept` brings an authorization
(`result`), which must be on the author's origin, attributed to them and naming the interaction and the post; only then
does the reply go out with `replyAuthorization`, the boost with `announceAuthorization` and the like with
`likeAuthorization`. A `Reject` leaves it ours alone (`rejected`), and takes a like or a boost back.
- **As a third party,** a reply that a post's policy does not let in at once is kept only with an authorization that
verifies the same way. A rule naming a collection we cannot list (followers, following) lets the reply in.
- **Shown** to clients as GoToSocial's `interaction_policy` (`can_favourite`, `can_reply`, `can_reblog`).
- 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.
**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
@@ -0,0 +1,207 @@
using MongoDB.Entities;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
using static PrivaPub.Tests.Support.FederatedSeeds;
namespace PrivaPub.Tests.Federation
{
// GoToSocial's interaction policies: who may reply to, like and boost a post at once, who must ask its author first, and
// who may not
[Trait("Category", "Integration")]
public sealed class InteractionPolicyTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static JsonObject Rule(params string[] automatic) => Rule(automatic, Array.Empty<string>());
static JsonObject Rule(string[] automatic, string[] manual) => new()
{
["automaticApproval"] = new JsonArray(automatic.Select(a => (JsonNode)a).ToArray()),
["manualApproval"] = new JsonArray(manual.Select(m => (JsonNode)m).ToArray())
};
// bob's public post, which alice follows bob to see, under the policy given
async Task<(PrivaPub.Federation.Actors.LocalActor Alice, RemoteActor Bob, Post Post, RemoteActor Fan)> Posted(JsonObject policy)
{
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 post with rules</p>");
note["interactionPolicy"] = policy;
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<List<JsonObject>> To(RemoteActor actor) => _harness.Outgoing(actor.Id + "/inbox");
Task<List<JsonObject>> ToShared(RemoteActor actor) => _harness.Outgoing(actor.SharedInbox);
JsonObject Authorization(RemoteActor author, string type, string interaction, Post target)
{
var path = $"/authorizations/{Guid.NewGuid():N}";
var stamp = new JsonObject
{
["id"] = Origin(author) + path, ["type"] = type, ["attributedTo"] = author.Id,
["interactingObject"] = interaction, ["interactionTarget"] = target.ObjectURI
};
_harness.Peer.Serve(path, stamp.ToJsonString());
return stamp;
}
[Fact]
public async Task A_reply_the_policy_lets_in_at_once_goes_out_as_always_and_one_it_shuts_out_is_refused()
{
var token = TestContext.Current.CancellationToken;
var (alice, bob, post, _) = await Posted(new JsonObject { ["canReply"] = Rule("https://www.w3.org/ns/activitystreams#Public") });
var reply = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "an open reply", InReplyTo = post.ID }, token);
Assert.Equal(ApprovalState.None, reply.Post.Approval);
Assert.Contains(await To(bob), a => a["type"]!.GetValue<string>() == "Create");
var (carol, dave, shut, _) = await Posted(new JsonObject { ["canReply"] = Rule(new string[0], new string[0]) });
var refused = await _harness.Statuses.Publish(carol, new StatusDraft { Text = "a shut-out reply", InReplyTo = shut.ID }, token);
Assert.Equal(422, refused.Status);
}
[Fact]
public async Task A_reply_its_author_must_approve_is_asked_for_and_goes_out_with_the_authorization_once_given()
{
var token = TestContext.Current.CancellationToken;
var (alice, bob, post, fan) = await Posted(new JsonObject
{
["canReply"] = Rule(new[] { "https://dummy.invalid/nobody" }, new[] { "https://www.w3.org/ns/activitystreams#Public" })
});
var reply = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "may I?", InReplyTo = post.ID }, token)).Post;
Assert.Equal(ApprovalState.Pending, reply.Approval);
var request = Assert.Single(await To(bob), a => a["type"]!.GetValue<string>() == "ReplyRequest");
Assert.Equal(post.ObjectURI, request["object"]!.GetValue<string>());
Assert.Equal(reply.ObjectURI, request["instrument"]!["id"]!.GetValue<string>());
Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue<string>() == "Create");
var stamp = Authorization(bob, "ReplyAuthorization", reply.ObjectURI, post);
await _harness.Deliver(bob, "/human-centipede", new JsonObject
{
["id"] = NewId(bob, "accepts"), ["type"] = "Accept", ["actor"] = bob.Id,
["object"] = new JsonObject { ["type"] = "ReplyRequest", ["id"] = request["id"]!.GetValue<string>() },
["result"] = IdOf(stamp)
});
var after = await DB.Default.Find<Post>().OneAsync(reply.ID, token);
Assert.Equal((ApprovalState.Accepted, IdOf(stamp)), (after.Approval, after.ApprovalURI));
var create = Assert.Single(await ToShared(fan), a => a["type"]!.GetValue<string>() == "Create");
Assert.Equal(IdOf(stamp), create["object"]!["replyAuthorization"]!.GetValue<string>());
}
[Fact]
public async Task A_rejected_reply_stays_ours_alone_and_a_forged_authorization_changes_nothing()
{
var token = TestContext.Current.CancellationToken;
var (alice, bob, post, fan) = await Posted(new JsonObject
{
["canReply"] = Rule(new[] { "https://dummy.invalid/nobody" }, new[] { "https://www.w3.org/ns/activitystreams#Public" })
});
var reply = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "may I?", InReplyTo = post.ID }, token)).Post;
var request = Assert.Single(await To(bob), a => a["type"]!.GetValue<string>() == "ReplyRequest");
// an authorization naming another post is no authorization
var forged = Authorization(bob, "ReplyAuthorization", reply.ObjectURI, new Post { ObjectURI = "https://elsewhere.invalid/post" });
await _harness.Deliver(bob, "/human-centipede", new JsonObject
{
["id"] = NewId(bob, "accepts"), ["type"] = "Accept", ["actor"] = bob.Id, ["object"] = request["id"]!.GetValue<string>(), ["result"] = IdOf(forged)
});
Assert.Equal(ApprovalState.Pending, (await DB.Default.Find<Post>().OneAsync(reply.ID, token)).Approval);
await _harness.Deliver(bob, "/human-centipede", new JsonObject
{
["id"] = NewId(bob, "rejects"), ["type"] = "Reject", ["actor"] = bob.Id, ["object"] = request["id"]!.GetValue<string>()
});
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 A_like_and_a_boost_its_author_must_approve_are_asked_for_and_a_shut_out_boost_is_refused()
{
var token = TestContext.Current.CancellationToken;
var (alice, bob, post, fan) = await Posted(new JsonObject
{
["canLike"] = Rule(new[] { "https://dummy.invalid/nobody" }, new[] { "https://www.w3.org/ns/activitystreams#Public" }),
["canAnnounce"] = Rule(new[] { "https://dummy.invalid/nobody" }, new[] { "https://www.w3.org/ns/activitystreams#Public" })
});
await _harness.Statuses.Favourite(alice, post.ID, true, token);
var like = await DB.Default.Find<Favourite>().Match(f => f.AccountId == alice.Id && f.PostId == post.ID).ExecuteSingleAsync(token);
Assert.Equal(ApprovalState.Pending, like.Approval);
var likeRequest = Assert.Single(await To(bob), a => a["type"]!.GetValue<string>() == "LikeRequest");
Assert.DoesNotContain(await To(bob), a => a["type"]!.GetValue<string>() == "Like");
var stamp = Authorization(bob, "LikeAuthorization", like.ActivityURI, post);
await _harness.Deliver(bob, "/human-centipede", new JsonObject
{
["id"] = NewId(bob, "accepts"), ["type"] = "Accept", ["actor"] = bob.Id, ["object"] = likeRequest["id"]!.GetValue<string>(), ["result"] = IdOf(stamp)
});
Assert.Equal(ApprovalState.Accepted, (await DB.Default.Find<Favourite>().OneAsync(like.ID, token)).Approval);
Assert.Equal(IdOf(stamp), Assert.Single(await To(bob), a => a["type"]!.GetValue<string>() == "Like")["likeAuthorization"]!.GetValue<string>());
var boost = (await _harness.Statuses.Reblog(alice, post.ID, true, PostVisibility.Public, token)).Post;
Assert.Equal(ApprovalState.Pending, boost.Approval);
var announceRequest = Assert.Single(await _harness.Outgoing(bob.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "AnnounceRequest");
Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue<string>() == "Announce");
await _harness.Deliver(bob, "/human-centipede", new JsonObject
{
["id"] = NewId(bob, "rejects"), ["type"] = "Reject", ["actor"] = bob.Id, ["object"] = announceRequest["id"]!.GetValue<string>()
});
Assert.Equal(ApprovalState.Rejected, (await DB.Default.Find<Post>().OneAsync(boost.ID, token)).Approval);
var (carol, _, shut, _) = await Posted(new JsonObject { ["canAnnounce"] = Rule(new string[0], new string[0]) });
Assert.Equal(422, (await _harness.Statuses.Reblog(carol, shut.ID, true, PostVisibility.Public, token)).Status);
}
// as a third party: a reply bob's policy does not let in at once is shown only with bob's authorization
[Fact]
public async Task A_reply_to_a_post_that_needs_its_authors_approval_is_kept_only_with_that_approval()
{
var token = TestContext.Current.CancellationToken;
var (alice, bob, post, _) = await Posted(new JsonObject
{
["canReply"] = Rule(new[] { "https://dummy.invalid/nobody" }, new[] { "https://www.w3.org/ns/activitystreams#Public" })
});
var carol = new RemoteActor(_harness.Peer, "carol");
await Follows(alice.Id, carol);
var unasked = PublicNote(carol, "<p>barging in</p>");
unasked["inReplyTo"] = post.ObjectURI;
await _harness.Deliver(carol, "/human-centipede", Create(carol, unasked));
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(unasked)).ExecuteAnyAsync(token));
var asked = PublicNote(carol, "<p>with leave</p>");
asked["inReplyTo"] = post.ObjectURI;
asked["replyAuthorization"] = IdOf(Authorization(bob, "ReplyAuthorization", IdOf(asked), post));
await _harness.Deliver(carol, "/human-centipede", Create(carol, asked));
Assert.True(await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(asked)).ExecuteAnyAsync(token));
}
}
}
+6 -4
View File
@@ -51,12 +51,13 @@ namespace PrivaPub.Tests.Support
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue, Records, new NoPreviews());
Outbox = new OutboxPublisher(Db, Local, Delivery);
Quotes = new QuoteService(Db, Remote, RemotePosts, Local, Delivery, Outbox);
Approvals = new InteractionApprovals(Db, Remote, Local, Delivery, Outbox);
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance, Ledger);
Handlers = new IActivityHandler[]
{
new FollowHandler(Db, Local, Remote, Delivery),
new AcceptHandler(Db, Local, Quotes),
new RejectHandler(Db, Local, Quotes),
new AcceptHandler(Db, Local, Quotes, Approvals),
new RejectHandler(Db, Local, Quotes, Approvals),
new UndoHandler(Db, Local, Reactions),
new LikeHandler(Db, Reactions),
new EmojiReactHandler(Db, Reactions),
@@ -64,7 +65,7 @@ namespace PrivaPub.Tests.Support
new DislikeHandler(Db),
new JoinHandler(Db, Local, Delivery),
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote, Records, Quotes),
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls, new NoPreviews(), Quotes),
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls, new NoPreviews(), Quotes, Approvals),
new DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes),
new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes),
new FlagHandler(Db, Local),
@@ -76,7 +77,7 @@ namespace PrivaPub.Tests.Support
Content = new ContentRenderer(Local, Remote);
Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }),
Local, default, NullLogger<MediaService>.Instance);
Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout, Media, Groups, Polls, new NoPreviews(), Quotes);
Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout, Media, Groups, Polls, new NoPreviews(), Quotes, Approvals);
Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer<GenericRes>(), NullLogger<PostsService>.Instance);
Timelines = new TimelineService(Db, new KeyLocalizer<GenericRes>());
Relationships = new RelationshipService(Db, Follows, Delivery);
@@ -106,6 +107,7 @@ namespace PrivaPub.Tests.Support
public PollService Polls { get; }
public Reactions Reactions { get; }
public QuoteService Quotes { get; }
public InteractionApprovals Approvals { get; }
public TimelineService Timelines { get; }
public RelationshipService Relationships { get; }
public ReportService Reports { get; }
@@ -66,6 +66,7 @@
{
public string ObjectType { get; set; }
public bool Locked { get; set; }//its community's moderators locked it: a reply is refused
public string Approval { get; set; }//our reply or boost its target's author must approve: "pending", "rejected"
public string Title { get; set; }
public string Excerpt { get; set; }
public string Cover { get; set; }
@@ -316,6 +317,21 @@
public QuoteEntity Quote { get; set; }
public int QuotesCount { get; set; }
public QuoteApprovalEntity QuoteApproval { get; set; }
public InteractionPolicyEntity InteractionPolicy { get; set; }//GoToSocial's: who may like, reply to and boost a remote post
}
// GoToSocial's interaction_policy: "public", "followers", "following", "author", "me", or an account's URI
public class InteractionPolicyEntity
{
public InteractionRuleEntity CanFavourite { get; set; }
public InteractionRuleEntity CanReply { get; set; }
public InteractionRuleEntity CanReblog { get; set; }
}
public class InteractionRuleEntity
{
public List<string> AutomaticApproval { get; set; } = new();
public List<string> ManualApproval { get; set; } = new();
}
public class MediaAttachment
@@ -288,6 +288,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
{
status.QuotesCount = post.QuotesCount;
status.QuoteApproval = post.IsFederatedCopy ? Approval(post, viewerId) : LocalApproval(post, viewerId, followsAuthor.Contains(post.GroupUserId));
status.InteractionPolicy = Policies(post);
if (post.QuoteState == QuoteState.None)
return;
var state = post.QuoteState.ToString().ToLowerInvariant();
@@ -360,6 +361,23 @@ namespace PrivaPub.Api.Mastodon.Mappers
return new QuoteApprovalEntity { Automatic = everyone, CurrentUser = current };
}
// a remote post's canLike, canReply and canAnnounce, in GoToSocial's client terms; null when it states none
static InteractionPolicyEntity Policies(PostEntity post)
{
if (!post.IsFederatedCopy || post.LikePolicy == default && post.ReplyPolicy == default && post.AnnouncePolicy == default)
return default;
var collections = post.ActorURI?.TrimEnd('/');
string Named(string who) => Addressing.IsPublic(who) ? "public"
: who == post.ActorURI ? "author"
: who.EndsWith("/followers", StringComparison.Ordinal) && who.StartsWith(collections, StringComparison.Ordinal) ? "followers"
: who.EndsWith("/following", StringComparison.Ordinal) && who.StartsWith(collections, StringComparison.Ordinal) ? "following"
: who;
InteractionRuleEntity Rule(InteractionRule rule) => rule == default
? new InteractionRuleEntity { AutomaticApproval = new List<string> { "public" } }
: new InteractionRuleEntity { AutomaticApproval = rule.Automatic.Select(Named).Distinct().ToList(), ManualApproval = rule.Manual.Select(Named).Distinct().ToList() };
return new InteractionPolicyEntity { CanFavourite = Rule(post.LikePolicy), CanReply = Rule(post.ReplyPolicy), CanReblog = Rule(post.AnnouncePolicy) };
}
static QuoteApprovalEntity Approval(PostEntity post, string viewerId)
{
if (!post.IsFederatedCopy || post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
@@ -448,6 +466,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
{
ObjectType = post.ObjectType,
Locked = post.LockedAt.HasValue,
Approval = post.Approval is ApprovalState.Pending or ApprovalState.Rejected ? post.Approval.ToString().ToLowerInvariant() : default,
Title = post.IsFederatedCopy ? post.Title : default,
Excerpt = post.Excerpt,
Cover = Proxied(post.CoverURL),
@@ -0,0 +1,264 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Domain.Statuses
{
public enum InteractionKind
{
Reply,
Like,
Announce
}
// GoToSocial's interaction policies (canReply, canLike, canAnnounce): who may answer, like or boost a post at once, who
// must ask its author first, and who may not. Asked, the author answers Accept with an authorization (`result`), which
// the interaction then carries for everyone else (replyAuthorization, likeAuthorization, announceAuthorization), or
// Reject. Our own posts state no such policy: anyone may.
public interface IInteractionApprovals
{
Task<QuotePermission> Judge(PostEntity target, LocalActor actor, InteractionKind kind, CancellationToken token);
Task Ask(LocalActor actor, PostEntity target, InteractionKind kind, JsonObject interaction, string localId, CancellationToken token);
Task<bool> Answered(JsonNode answer, ForeignAvatar actor, bool accepted, CancellationToken token);
Task<bool> MayReply(NoteDocument reply, ForeignAvatar replier, PostEntity parent, CancellationToken token);
}
public class InteractionApprovals : IInteractionApprovals
{
readonly DbEntities _dbEntities;
readonly IRemoteActorService _remoteActors;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
readonly IOutboxPublisher _outbox;
public InteractionApprovals(DbEntities dbEntities, IRemoteActorService remoteActors, ILocalActorService localActors, IDeliveryService delivery,
IOutboxPublisher outbox)
{
_dbEntities = dbEntities;
_remoteActors = remoteActors;
_localActors = localActors;
_delivery = delivery;
_outbox = outbox;
}
static string Prefix(InteractionKind kind) => kind switch
{
InteractionKind.Reply => "reply-request-",
InteractionKind.Like => "like-request-",
_ => "announce-request-"
};
static InteractionRule Rule(PostEntity post, InteractionKind kind) => kind switch
{
InteractionKind.Reply => post.ReplyPolicy,
InteractionKind.Like => post.LikePolicy,
_ => post.AnnouncePolicy
};
public async Task<QuotePermission> Judge(PostEntity target, LocalActor actor, InteractionKind kind, CancellationToken token)
{
if (!target.IsFederatedCopy || Rule(target, kind) is not { } rule)
return QuotePermission.Granted;
if (await Includes(rule.Automatic, target, actor, token))
return QuotePermission.Granted;
return await Includes(rule.Manual, target, actor, token) ? QuotePermission.AskFirst : QuotePermission.Denied;
}
// whether a policy's list names the persona: anyone, the persona itself, the author's followers when it follows the
// author, the accounts the author follows when the author follows it
async Task<bool> Includes(List<string> who, PostEntity target, LocalActor actor, CancellationToken token)
{
if (who.Count == 0)
return false;
if (who.Any(Addressing.IsPublic) || who.Contains(actor.Uri))
return true;
var author = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == target.ActorURI).ExecuteFirstAsync(token);
if (author == default)
return false;
if (!string.IsNullOrEmpty(author.FollowersURL) && who.Contains(author.FollowersURL)
&& await _dbEntities.Followings.Match(f => f.AvatarId == actor.Id && f.TargetActorURI == author.ActorURI && f.State == FollowState.Accepted)
.ExecuteAnyAsync(token))
return true;
return !string.IsNullOrEmpty(author.FollowingURL) && who.Contains(author.FollowingURL)
&& await _dbEntities.Followers.Match(f => f.LocalActorId == actor.Id && f.ActorURI == author.ActorURI && f.IsAccepted).ExecuteAnyAsync(token);
}
// asks the author, with the interaction as the request's instrument; it goes to no one else until the author agrees
public async Task Ask(LocalActor actor, PostEntity target, InteractionKind kind, JsonObject interaction, string localId, CancellationToken token)
{
var author = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == target.ActorURI).ExecuteFirstAsync(token);
if (string.IsNullOrEmpty(author?.InboxURL))
return;
var instrument = (JsonObject)interaction.DeepClone();
instrument.Remove("@context");
await _delivery.Enqueue(actor, new[] { author.InboxURL }, new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = actor.ActivityUri(Prefix(kind) + localId),
["type"] = kind switch { InteractionKind.Reply => "ReplyRequest", InteractionKind.Like => "LikeRequest", _ => "AnnounceRequest" },
["actor"] = actor.Uri,
["to"] = target.ActorURI,
["object"] = target.ObjectURI,
["instrument"] = instrument
}, token);
}
// an author's answer to one of our requests (the request, or the interaction itself when it was sent unasked)
public async Task<bool> Answered(JsonNode answer, ForeignAvatar actor, bool accepted, CancellationToken token)
{
var answered = answer["object"];
var answeredId = Id(answered);
if (answeredId == default)
return false;
var (kind, localId) = Request(answeredId);
if (localId == default && answered is JsonObject request && Value(request, "type") is "ReplyRequest" or "LikeRequest" or "AnnounceRequest")
answeredId = Id(request["instrument"]);
if (localId == default && answeredId != default)
(kind, localId) = await Interaction(answeredId, token);
if (localId == default)
return false;
if (kind == InteractionKind.Like)
{
var like = await DB.Default.Find<Favourite>().OneAsync(localId, token);
var liked = like == default ? default : await _dbEntities.Posts.MatchID(like.PostId).ExecuteFirstAsync(token);
if (like is not { Approval: ApprovalState.Pending } || liked?.ActorURI != actor.ActorURI)
return true;
if (!accepted)
{
await DB.Default.DeleteAsync<Favourite>(like.ID);
await DB.Default.Update<PostEntity>().MatchID(liked.ID).Modify(b => b.Inc(p => p.FavouritesCount, -1)).ExecuteAsync(token);
return true;
}
var stamp = Id(answer["result"]);
if (stamp == default || !await Verified(stamp, like.ActivityURI, liked, token))
return true;
await DB.Default.Update<Favourite>().MatchID(like.ID).Modify(f => f.Approval, ApprovalState.Accepted).Modify(f => f.ApprovalURI, stamp)
.ExecuteAsync(token);
var liker = await _localActors.FindById(Models.Federation.LocalActorKind.Person, like.AccountId, token);
if (liker != default && !string.IsNullOrEmpty(actor.InboxURL))
await _delivery.Enqueue(liker, new[] { actor.InboxURL }, new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = like.ActivityURI,
["type"] = "Like",
["actor"] = liker.Uri,
["object"] = liked.ObjectURI,
["likeAuthorization"] = stamp
}, token);
return true;
}
var post = await _dbEntities.Posts.Match(p => p.ID == localId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
var target = post == default ? default : await _dbEntities.Posts.MatchID(kind == InteractionKind.Reply ? post.AnsweringToPostId : post.ReblogOfPostId)
.ExecuteFirstAsync(token);
if (post is not { Approval: ApprovalState.Pending } || target?.ActorURI != actor.ActorURI)
return true;
var author = await _localActors.FindById(Models.Federation.LocalActorKind.Person, post.GroupUserId, token);
if (author == default)
return true;
if (!accepted)
{
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.Approval, ApprovalState.Rejected).ExecuteAsync(token);
if (kind == InteractionKind.Announce)
await DB.Default.Update<PostEntity>().MatchID(target.ID).Modify(b => b.Inc(p => p.ReblogsCount, -1)).ExecuteAsync(token);
return true;
}
var authorization = Id(answer["result"]);
var interactionUri = kind == InteractionKind.Reply ? post.ObjectURI : post.ActivityURI;
if (authorization == default || !await Verified(authorization, interactionUri, target, token))
return true;
post.Approval = ApprovalState.Accepted;
post.ApprovalURI = authorization;
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.Approval, ApprovalState.Accepted).Modify(p => p.ApprovalURI, authorization)
.ExecuteAsync(token);
// now it goes where it was meant to, carrying the authorization
if (kind == InteractionKind.Reply)
{
var create = ActivityPubRenderer.Create(author, ActivityPubRenderer.Note(post, author, default, post.InReplyToURI), $"create-{post.ID}");
await _outbox.Publish(author, post, create, token);
return true;
}
var announce = new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = post.ActivityURI,
["type"] = "Announce",
["actor"] = author.Uri,
["published"] = ActivityPubRenderer.Timestamp(post.CreationDate),
["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()),
["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray()),
["object"] = target.ObjectURI,
["announceAuthorization"] = authorization
};
await _delivery.EnqueueToFollowers(author, announce, token, string.IsNullOrEmpty(actor.InboxURL) ? default : new[] { actor.InboxURL });
return true;
}
(InteractionKind Kind, string LocalId) Request(string requestId)
{
if (requestId == default || !requestId.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
return default;
foreach (var kind in new[] { InteractionKind.Reply, InteractionKind.Like, InteractionKind.Announce })
{
var marker = requestId.LastIndexOf("/grunts/" + Prefix(kind), StringComparison.Ordinal);
if (marker >= 0)
return (kind, requestId[(marker + "/grunts/".Length + Prefix(kind).Length)..]);
}
return default;
}
// an interaction of ours named by its own id, as an answer to one sent unasked names it
async Task<(InteractionKind Kind, string LocalId)> Interaction(string uri, CancellationToken token)
{
if (!uri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
return default;
if (await DB.Default.Find<Favourite>().Match(f => f.ActivityURI == uri).ExecuteFirstAsync(token) is { } like)
return (InteractionKind.Like, like.ID);
if (await _dbEntities.Posts.Match(p => (p.ObjectURI == uri || p.ActivityURI == uri) && !p.IsFederatedCopy).ExecuteFirstAsync(token) is { } post)
return (post.ReblogOfPostId != default ? InteractionKind.Announce : InteractionKind.Reply, post.ID);
return default;
}
// an authorization is the target author's own document: on its server, naming the interaction and the post
async Task<bool> Verified(string authorization, string interactionUri, PostEntity target, CancellationToken token)
{
if (!Origin.Same(authorization, target.ActorURI))
return false;
using var fetched = await _remoteActors.FetchObject(authorization, token);
if (fetched == default)
return false;
var stamp = JsonNode.Parse(fetched.Root.GetRawText());
return Value(stamp, "type") is "ReplyAuthorization" or "LikeAuthorization" or "AnnounceAuthorization" or "LikeApproval" or "ReplyApproval"
or "AnnounceApproval"
&& Id(stamp["interactingObject"]) == interactionUri && Id(stamp["interactionTarget"]) == target.ObjectURI
&& Id(stamp["attributedTo"]) == target.ActorURI;
}
// as a third party: a reply its parent's policy does not let in at once carries the parent author's authorization
public async Task<bool> MayReply(NoteDocument reply, ForeignAvatar replier, PostEntity parent, CancellationToken token)
{
if (parent is not { IsFederatedCopy: true, ReplyPolicy: { } rule } || replier.ActorURI == parent.ActorURI)
return true;
// anyone, the replier itself, or a collection (followers, following) whose members we cannot list from here
var author = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == parent.ActorURI).ExecuteFirstAsync(token);
if (rule.Automatic.Any(Addressing.IsPublic) || rule.Automatic.Contains(replier.ActorURI)
|| author != default && (rule.Automatic.Contains(author.FollowersURL) || rule.Automatic.Contains(author.FollowingURL)))
return true;
return reply.ReplyAuthorization != default && await Verified(reply.ReplyAuthorization, reply.Id, parent, token);
}
}
}
+39 -8
View File
@@ -77,11 +77,13 @@ namespace PrivaPub.Domain.Statuses
readonly IPollService _polls;
readonly ILinkPreviews _previews;
readonly IQuoteService _quotes;
readonly IInteractionApprovals _approvals;
public StatusService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IContentRenderer content, IOutboxPublisher outbox, IFanout fanout, IMediaService media, IGroupDistributor groups, IPollService polls,
ILinkPreviews previews, IQuoteService quotes)
ILinkPreviews previews, IQuoteService quotes, IInteractionApprovals approvals = default)
{
_approvals = approvals;
_previews = previews;
_quotes = quotes;
_polls = polls;
@@ -132,6 +134,10 @@ namespace PrivaPub.Domain.Statuses
// its community's moderators locked the thread
if (parent is { LockedAt: not null })
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This thread is locked");
// its author takes our reply at once, once asked, or not at all (GoToSocial's canReply)
var replyPermission = await Permission(parent, author, InteractionKind.Reply, token);
if (replyPermission == QuotePermission.Denied)
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Its author does not take replies from you");
// a reply in a circle stays in the circle, whichever client wrote it: Mastodon clients know nothing of groups
if (group == default && parent is { Visibility: PostVisibility.Circle } && !string.IsNullOrEmpty(parent.GroupId)
&& await _dbEntities.Groups.MatchID(parent.GroupId).ExecuteFirstAsync(token) is { DeletionAt: null } parentCircle
@@ -213,7 +219,8 @@ namespace PrivaPub.Domain.Statuses
QuoteURI = quoted?.ObjectURI,
QuotedPostId = quoted?.ID,
QuoteByConsent = quoted != default && (quoted.QuotePolicy != default || !quoted.IsFederatedCopy),
QuoteState = quoted == default ? QuoteState.None : quotePermission == QuotePermission.Granted ? QuoteState.Accepted : QuoteState.Pending
QuoteState = quoted == default ? QuoteState.None : quotePermission == QuotePermission.Granted ? QuoteState.Accepted : QuoteState.Pending,
Approval = replyPermission == QuotePermission.AskFirst ? ApprovalState.Pending : ApprovalState.None
};
post.ID = (string)post.GenerateNewID();
post.ObjectURI = author.PostUri(post.ID);
@@ -266,9 +273,12 @@ namespace PrivaPub.Domain.Statuses
if (post.QuoteState == QuoteState.Pending && create?["object"] is JsonObject quotingNote)
await _quotes.Request(author, post, quoted, quotingNote, token);
await _previews.Wanted(post, token);
if (create != default)
// a reply its parent's author must approve goes to that author alone, as a request, until it is approved
if (create != default && post.Approval == ApprovalState.Pending)
await _approvals.Ask(author, parent, InteractionKind.Reply, create["object"].AsObject(), post.ID, token);
else if (create != default)
await _outbox.Publish(author, post, create, token);
if (create != default && group is { IsCircle: false } && visibility is PostVisibility.Public or PostVisibility.Unlisted)
if (create != default && post.Approval != ApprovalState.Pending && group is { IsCircle: false } && visibility is PostVisibility.Public or PostVisibility.Unlisted)
await _groups.Announce(group, create, post.ObjectURI, isNewPost: true, token);
return new StatusOutcome(post);
}
@@ -387,13 +397,20 @@ namespace PrivaPub.Domain.Statuses
var post = await Visible(me, postId, token);
if (post == default)
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
var likePermission = on ? await Permission(post, me, InteractionKind.Like, token) : QuotePermission.Granted;
if (likePermission == QuotePermission.Denied)
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Its author does not take likes from you");
// each favourite is a Like of its own, as Mastodon's are: a favourite after an unfavourite is a new Like, which no
// server (and no delivery queue) takes for the one already undone; an unfavourite undoes the Like it ends
var favourite = default(Favourite);
if (on)
{
favourite = new Favourite { ID = ObjectId.GenerateNewId().ToString(), AccountId = me.Id, ActorURI = me.Uri, PostId = post.ID };
favourite = new Favourite
{
ID = ObjectId.GenerateNewId().ToString(), AccountId = me.Id, ActorURI = me.Uri, PostId = post.ID,
Approval = likePermission == QuotePermission.AskFirst ? ApprovalState.Pending : ApprovalState.None
};
favourite.ActivityURI = me.ActivityUri($"like-{favourite.ID}");
try
{
@@ -427,11 +444,18 @@ namespace PrivaPub.Domain.Statuses
["actor"] = me.Uri,
["object"] = post.ObjectURI
};
await _delivery.Enqueue(me, new[] { inbox }, on ? like : Undo(me, like, $"undo-like-{favourite.ID}"), token);
if (on && favourite.Approval == ApprovalState.Pending)
await _approvals.Ask(me, post, InteractionKind.Like, like, favourite.ID, token);
else
await _delivery.Enqueue(me, new[] { inbox }, on ? like : Undo(me, like, $"undo-like-{favourite.ID}"), token);
}
return new StatusOutcome(post);
}
// how a remote post's interaction policy takes an interaction of ours: at once, once asked, or not at all
async Task<QuotePermission> Permission(PostEntity target, LocalActor actor, InteractionKind kind, CancellationToken token) =>
target is { IsFederatedCopy: true } && _approvals != default ? await _approvals.Judge(target, actor, kind, token) : QuotePermission.Granted;
public async Task<StatusOutcome> Reblog(LocalActor me, string postId, bool on, PostVisibility visibility, CancellationToken token)
{
var original = await Visible(me, postId, token);
@@ -441,6 +465,9 @@ namespace PrivaPub.Domain.Statuses
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
if (original.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This post can't be boosted");
var announcePermission = on ? await Permission(original, me, InteractionKind.Announce, token) : QuotePermission.Granted;
if (announcePermission == QuotePermission.Denied)
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Its author does not take boosts from you");
var existing = await _dbEntities.Posts.Match(p => p.ReblogOfPostId == original.ID && p.AuthorAccountId == me.Id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
// each boost is an activity of its own, as Mastodon's are: a boost after an unboost is a new Announce, which no
@@ -474,7 +501,8 @@ namespace PrivaPub.Domain.Statuses
ActivityURI = announceId,
ActorURI = me.Uri,
To = Strings(announce["to"]),
Cc = Strings(announce["cc"])
Cc = Strings(announce["cc"]),
Approval = announcePermission == QuotePermission.AskFirst ? ApprovalState.Pending : ApprovalState.None
};
try
{
@@ -496,7 +524,10 @@ namespace PrivaPub.Domain.Statuses
if (!original.IsFederatedCopy)
await Notifications.Add(original.GroupUserId, NotificationType.Reblog, me.Id, me.Uri, original.ID, token);
await _fanout.Distribute(reblog, token);
await _delivery.EnqueueToFollowers(me, announce, token, await AuthorInbox(original, token, shared: true) is { } inbox ? new[] { inbox } : default);
if (reblog.Approval == ApprovalState.Pending)
await _approvals.Ask(me, original, InteractionKind.Announce, announce, reblog.ID, token);
else
await _delivery.EnqueueToFollowers(me, announce, token, await AuthorInbox(original, token, shared: true) is { } inbox ? new[] { inbox } : default);
return new StatusOutcome(reblog);
}
@@ -17,12 +17,14 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IQuoteService _quotes;
readonly IInteractionApprovals _approvals;
public AcceptHandler(DbEntities dbEntities, ILocalActorService localActors, IQuoteService quotes)
public AcceptHandler(DbEntities dbEntities, ILocalActorService localActors, IQuoteService quotes, IInteractionApprovals approvals = default)
{
_dbEntities = dbEntities;
_localActors = localActors;
_quotes = quotes;
_approvals = approvals;
}
public virtual string Type => "Accept";
@@ -34,6 +36,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
Arrival.Accept("quote-answer");
return;
}
if (_approvals != default && await _approvals.Answered(activity, actor, accepted: Type == "Accept", token))
{
Arrival.Accept("interaction-answer");
return;
}
var following = await FindFollowing(activity["object"], actor, _dbEntities, _localActors, token);
if (following == default)
{
@@ -72,7 +79,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
public class RejectHandler : AcceptHandler
{
public RejectHandler(DbEntities dbEntities, ILocalActorService localActors, IQuoteService quotes) : base(dbEntities, localActors, quotes)
public RejectHandler(DbEntities dbEntities, ILocalActorService localActors, IQuoteService quotes, IInteractionApprovals approvals = default)
: base(dbEntities, localActors, quotes, approvals)
{
}
@@ -40,10 +40,13 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly IPollService _polls;
readonly ILinkPreviews _previews;
readonly IQuoteService _quotes;
readonly IInteractionApprovals _approvals;
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups, IObjectRecords records, IPollService polls, ILinkPreviews previews, IQuoteService quotes)
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups, IObjectRecords records, IPollService polls, ILinkPreviews previews, IQuoteService quotes,
IInteractionApprovals approvals = default)
{
_approvals = approvals;
_quotes = quotes;
_previews = previews;
_records = records;
@@ -141,6 +144,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
var parent = string.IsNullOrEmpty(note.InReplyTo)
? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
// a reply its parent's author has not let in (GoToSocial's canReply), which a third party must not show
if (_approvals != default && parent != default && !await _approvals.MayReply(note, author, parent, token))
{
Arrival.Drop("reply-not-authorized");
return;
}
var circle = localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: true });
if (circle != default)
{
+3
View File
@@ -30,6 +30,9 @@ namespace PrivaPub.Federation.Inbox
{
post.Poll = note.Poll ?? post.Poll;
post.QuotePolicy = note.QuotePolicy;
post.ReplyPolicy = note.ReplyPolicy;
post.LikePolicy = note.LikePolicy;
post.AnnouncePolicy = note.AnnouncePolicy;
post.Video = note.Video ?? post.Video;
post.Audio = note.Audio ?? post.Audio;
post.Event = note.Event ?? post.Event;
+3
View File
@@ -107,6 +107,9 @@ namespace PrivaPub.Federation.Inbox
Source = note.Source,
Poll = note.Poll,
QuotePolicy = note.QuotePolicy,
ReplyPolicy = note.ReplyPolicy,
LikePolicy = note.LikePolicy,
AnnouncePolicy = note.AnnouncePolicy,
Emojis = note.Emojis.ToList(),
CoverURL = note.CoverURL,
Link = note.Link,
@@ -25,6 +25,7 @@ namespace PrivaPub.Federation.Objects
public string Context { get; init; }
public string Audience { get; init; }
public string QuoteUri { get; init; }
public string ReplyAuthorization { get; init; }//the parent author's ReplyAuthorization (approvedBy before GoToSocial 0.21)
public string QuoteAuthorization { get; init; }
public bool QuotesByConsent { get; init; }//FEP-044f `quote`, as opposed to the legacy keys that ask nobody
public bool QuoteDeleted { get; init; }
@@ -38,6 +39,9 @@ namespace PrivaPub.Federation.Objects
public PostSource Source { get; init; }
public PostPoll Poll { get; init; }
public InteractionRule QuotePolicy { get; init; }
public InteractionRule ReplyPolicy { get; init; }
public InteractionRule LikePolicy { get; init; }
public InteractionRule AnnouncePolicy { get; init; }
public IReadOnlyList<CustomEmoji> Emojis { get; init; } = Array.Empty<CustomEmoji>();
public string CoverURL { get; init; }
public PostLink Link { get; init; }
@@ -86,6 +90,7 @@ namespace PrivaPub.Federation.Objects
Context = Id(note["context"]) ?? Value(note, "conversation"),
Audience = Id(note["audience"]),
QuoteUri = Id(note["quote"]) ?? Value(note, "quoteUrl") ?? Value(note, "quoteUri") ?? Value(note, "_misskey_quote") ?? QuoteLink(note),
ReplyAuthorization = Id(note["replyAuthorization"]) ?? Id(note["approvedBy"]),
QuoteAuthorization = Id(note["quoteAuthorization"]),
QuotesByConsent = note.ContainsKey("quote"),
QuoteDeleted = note["quote"] is JsonObject quoted && Value(quoted, "type") == "Tombstone",
@@ -107,6 +112,9 @@ namespace PrivaPub.Federation.Objects
Source = ObjectShapes.Source(note),
Poll = ObjectShapes.Poll(note),
QuotePolicy = ObjectShapes.QuotePolicy(note),
ReplyPolicy = ObjectShapes.Policy(note, "canReply"),
LikePolicy = ObjectShapes.Policy(note, "canLike"),
AnnouncePolicy = ObjectShapes.Policy(note, "canAnnounce"),
Emojis = ObjectShapes.Emojis(note["tag"]),
CoverURL = ObjectShapes.Cover(note),
Link = ObjectShapes.Link(note),
+8 -4
View File
@@ -98,9 +98,13 @@ namespace PrivaPub.Federation.Objects
.Take(MaxEmojis)
.ToList();
public static InteractionRule QuotePolicy(JsonObject note)
public static InteractionRule QuotePolicy(JsonObject note) => Policy(note, "canQuote");
// 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["canQuote"] is not JsonObject canQuote)
if (note["interactionPolicy"] is not JsonObject policy || policy[rule] is not JsonObject allowed)
return default;
static List<string> Who(JsonNode node) => node switch
{
@@ -110,8 +114,8 @@ namespace PrivaPub.Federation.Objects
};
return new InteractionRule
{
Automatic = Who(canQuote["automaticApproval"] ?? canQuote["always"]),
Manual = Who(canQuote["manualApproval"] ?? canQuote["approvalRequired"])
Automatic = Who(allowed["automaticApproval"] ?? allowed["always"]),
Manual = Who(allowed["manualApproval"] ?? allowed["approvalRequired"])
};
}
@@ -55,6 +55,15 @@ namespace PrivaPub.Federation.Rendering
["canQuote"] = new JsonObject { ["@id"] = "gts:canQuote", ["@type"] = "@id" },
["automaticApproval"] = new JsonObject { ["@id"] = "gts:automaticApproval", ["@type"] = "@id" },
["manualApproval"] = new JsonObject { ["@id"] = "gts:manualApproval", ["@type"] = "@id" },
["LikeRequest"] = "gts:LikeRequest",
["ReplyRequest"] = "gts:ReplyRequest",
["AnnounceRequest"] = "gts:AnnounceRequest",
["LikeAuthorization"] = "gts:LikeApproval",
["ReplyAuthorization"] = "gts:ReplyAuthorization",
["AnnounceAuthorization"] = "gts:AnnounceAuthorization",
["likeAuthorization"] = new JsonObject { ["@id"] = "gts:likeAuthorization", ["@type"] = "@id" },
["replyAuthorization"] = new JsonObject { ["@id"] = "gts:replyAuthorization", ["@type"] = "@id" },
["announceAuthorization"] = new JsonObject { ["@id"] = "gts:announceAuthorization", ["@type"] = "@id" },
["litepub"] = "http://litepub.social/ns#",
["EmojiReact"] = "litepub:EmojiReact",
["focalPoint"] = new JsonObject { ["@container"] = "@list", ["@id"] = "toot:focalPoint" },
@@ -329,6 +338,9 @@ namespace PrivaPub.Federation.Rendering
}
if (!string.IsNullOrEmpty(inReplyTo))
note["inReplyTo"] = inReplyTo;
// 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 (post.EditedAt.HasValue)
note["updated"] = Timestamp(post.EditedAt.Value);
return note;
@@ -101,6 +101,7 @@ namespace PrivaPub.Middleware
.AddSingleton<IObjectRecords, ObjectRecords>()
.AddSingleton<IPollService, PollService>()
.AddSingleton<IQuoteService, QuoteService>()
.AddSingleton<IInteractionApprovals, InteractionApprovals>()
.AddSingleton<IJobHandler, PollRefreshJob>()
.AddSingleton<IJobHandler, RecoveryJob>()
.AddSingleton<IJobHandler, Federation.Actors.AccountCountsJob>()
+11
View File
@@ -70,6 +70,17 @@ namespace PrivaPub.Models.Post
public int QuotesCount { get; set; }
[BsonIgnoreIfNull]
public InteractionRule QuotePolicy { get; set; }//a remote post's interactionPolicy.canQuote; null when it states none
// a remote post's interactionPolicy.canReply, canLike and canAnnounce (GoToSocial); null when it states none, which
// means anyone, automatically
[BsonIgnoreIfNull]
public InteractionRule ReplyPolicy { get; set; }
[BsonIgnoreIfNull]
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
public ApprovalState Approval { get; set; }
public string ApprovalURI { get; set; }
public List<string> To { get; set; } = new();
public List<string> Cc { get; set; } = new();
+9
View File
@@ -23,6 +23,15 @@
public int Votes { get; set; }
}
// an interaction of ours with a remote post whose interaction policy asks its author first
public enum ApprovalState
{
None,
Pending,
Accepted,
Rejected
}
public class InteractionRule
{
public List<string> Automatic { get; set; } = new();//actor or collection URIs; Public for anyone
+2
View File
@@ -8,6 +8,8 @@ namespace PrivaPub.Models.Social
public string ActorURI { get; set; }
public string PostId { get; set; }
public string ActivityURI { get; set; }
public Post.ApprovalState Approval { get; set; }//ours, on a post whose policy asks its author first
public string ApprovalURI { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
}
+7 -4
View File
@@ -235,8 +235,8 @@ Findings:
| Gap | P | Client surface |
|---|---|---|
| Store remote `interactionPolicy` (with GoToSocial's defaults); disable or mark actions | P1 | GoToSocial-style `Status.interaction_policy` |
| Send `ReplyRequest`/`LikeRequest` where approval is needed; handle `Accept{result}`/`Reject`; attach the authorization | P1 | own: pending/approved/rejected on our own reply |
| ~~Store remote `interactionPolicy` (with GoToSocial's defaults); disable or mark actions~~ done 2026-10-05 | P1 | GoToSocial-style `Status.interaction_policy` |
| ~~Send `ReplyRequest`/`LikeRequest` where approval is needed; handle `Accept{result}`/`Reject`; attach the authorization~~ done 2026-10-05 (`InteractionApprovals`), `AnnounceRequest` too | P1 | `privapub.approval`: pending/rejected on our own reply or boost |
| Honour 503 with `Retry-After` in delivery and in the proxy | P1 | — |
| Respect `hides*FromUnauthedWeb` on our public pages; emit it for personas (it suits the privacy design) | P2 | — |
| Measure media size when proxying | P2 | `MediaAttachment.meta` |
@@ -244,8 +244,11 @@ Findings:
**Pasture evidence (2026-10-03, GoToSocial 0.22.1, `tools/pasture/scenarios/gts.sh`):** 37 checks pass, three runs in a
row. That is the original 33 plus four on statistics: described as gotosocial, inbound and outbound traffic counted,
no account named. On 2026-10-05 the scenario runs 55 checks, all passing, and can be run again on the same pasture (the
locked persona loses its follower first). Since 2026-10-04 (v1.19.0) circle posts reach a GoToSocial member too. GoToSocial files a post for
no account named. On 2026-10-05 the scenario runs 64 checks, all passing, and can be run again on the same pasture (the
locked persona loses its follower first). Nine of them are interaction policies: gtsuser's post asks before anyone but
its author replies or likes and lets nobody else boost it; PrivaPub shows the policy, refuses the boost, sends alice's
reply and like as a `ReplyRequest` and a `LikeRequest`, and once gtsuser approves them the reply threads under the
post and the like counts. Since 2026-10-04 (v1.19.0) circle posts reach a GoToSocial member too. GoToSocial files a post for
neither the public nor the author's followers as a direct message, like our DMs, and shows it only to the accounts it
mentions. Being in `cc` stored it but left it invisible, so each member's copy also mentions that member silently.
Such posts are then found in the member's conversations, never by a search on their URI.
+31
View File
@@ -194,6 +194,37 @@ until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$lo
echo "smoke"
"$here/../smoke/mastodon-api.sh" "$P" "$PT" >/dev/null 2>&1 && ok "the deploy's Mastodon smoke check passes, signed in" || ko "the Mastodon smoke check fails"
echo "interaction policies"
# gtsuser's post asks before anyone but its author replies or likes, and lets nobody else boost it: PrivaPub shows the
# policy, refuses the boost, and sends the reply and the like as requests, which go out for real once approved
run_id=$(date +%s)
gp=$(gcurl -s -X POST -H "$GH" -H 'Content-Type: application/json' "$G/api/v1/statuses" -d '{"status":"ask before you answer '"$run_id"'","visibility":"public",
"interaction_policy":{"can_reply":{"automatic_approval":["author"],"manual_approval":["public"]},
"can_favourite":{"automatic_approval":["author"],"manual_approval":["public"]},"can_reblog":{"automatic_approval":["author"],"manual_approval":[]}}}')
gp_id=$(echo "$gp" | j "print(d['id'])")
gp_on_pp_of() { curl -s -H "$PH" "$P/api/v1/timelines/home?limit=40" | j "print(next((s['id'] for s in d if 'ask before you answer $run_id' in s['content']), ''))"; }
until_true 30 '[ -n "$(gp_on_pp_of)" ]' && ok "gtsuser's post with an interaction policy arrives" || ko "the post with a policy never arrived"
gp_on_pp=$(gp_on_pp_of)
[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$gp_on_pp" | j "p = d.get('interaction_policy') or {}; print(p.get('can_reply', {}).get('manual_approval'), p.get('can_reblog', {}).get('automatic_approval'))")" = "['public'] ['author']" ] \
&& ok "PrivaPub shows its policy as GoToSocial states it" || ko "the post's interaction policy is not shown"
[ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "$PH" "$P/api/v1/statuses/$gp_on_pp/reblog")" = "422" ] \
&& ok "a boost its policy shuts out is refused" || ko "PrivaPub boosted a post whose policy shuts boosts out"
asked=$(curl -s -X POST -H "$PH" "$P/api/v1/statuses" -d "status=@gtsuser@gts.test may I answer $run_id?&in_reply_to_id=$gp_on_pp&visibility=public")
asked_id=$(echo "$asked" | j "print(d['id'])")
[ "$(echo "$asked" | j "print((d.get('privapub') or {}).get('approval'))")" = "pending" ] && ok "alice's reply waits for gtsuser's approval" || ko "the reply was not held for approval"
request_of() { gcurl -s -H "$GH" "$G/api/v1/interaction_requests?$1=true" | j "print(next((r['id'] for r in d if r['status']['id'] == '$gp_id' and r['type'] == '$2'), ''))"; }
until_true 30 '[ -n "$(request_of replies reply)" ]' && ok "it reaches gtsuser as a reply request" || ko "no reply request on GoToSocial"
gcurl -s -o /dev/null -X POST -H "$GH" "$G/api/v1/interaction_requests/$(request_of replies reply)/authorize"
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$asked_id" | j "print((d.get(\"privapub\") or {}).get(\"approval\"))")" = "None" ]' \
&& ok "gtsuser's approval reaches PrivaPub" || ko "the approval never reached PrivaPub"
until_true 30 'gcurl -s -H "$GH" "$G/api/v1/statuses/$gp_id/context" | grep -q "may I answer $run_id"' \
&& ok "the approved reply threads under gtsuser's post" || ko "the approved reply is missing under gtsuser's post"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$gp_on_pp/favourite"
until_true 30 '[ -n "$(request_of favourites favourite)" ]' && ok "alice's like reaches gtsuser as a like request" || ko "no like request on GoToSocial"
gcurl -s -o /dev/null -X POST -H "$GH" "$G/api/v1/interaction_requests/$(request_of favourites favourite)/authorize"
until_true 30 '[ "$(gcurl -s -H "$GH" "$G/api/v1/statuses/$gp_id" | j "print(d[\"favourites_count\"])")" = "1" ]' \
&& ok "the approved like counts on GoToSocial" || ko "the approved like does not count"
echo "unfollow"
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/accounts/$gts_on_pp/unfollow
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$alice_on_gts" | j "print(d[0][\"followed_by\"])")" = "False" ]' && ok "alice's unfollow reaches GoToSocial" || ko "unfollow not applied"