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:
1 parent
c65a44ba88
commit
5860b71223
21 files changed
+687
-26
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user