264 lines
12 KiB
C#
264 lines
12 KiB
C#
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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|