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. And FEP-5624's canReply (PeerTube's): who may reply, the reply itself going to the author alone, who answers // ApproveReply, which the reply then carries (replyApproval), or RejectReply. Our own posts state no such policy: // anyone may. public interface IInteractionApprovals { Task Judge(PostEntity target, LocalActor actor, InteractionKind kind, CancellationToken token); Task Ask(LocalActor actor, PostEntity target, InteractionKind kind, JsonObject interaction, string localId, CancellationToken token); Task Answered(JsonNode answer, ForeignAvatar actor, bool accepted, CancellationToken token); Task 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 }; // FEP-5624's canReply, where GoToSocial's policy says nothing of replies static bool ApprovesReplies(PostEntity target, InteractionKind kind) => kind == InteractionKind.Reply && target.ReplyPolicy == default && target.ReplyApprovals != default; public async Task Judge(PostEntity target, LocalActor actor, InteractionKind kind, CancellationToken token) { if (!target.IsFederatedCopy) return QuotePermission.Granted; // those it names, or mentions, may reply, and every reply waits for its author's approval if (ApprovesReplies(target, kind)) return await Includes(target.ReplyApprovals.Manual, target, actor, token) || target.Mentions.Any(m => m.ActorURI == actor.Uri) ? QuotePermission.AskFirst : QuotePermission.Denied; if (Rule(target, kind) is not { } rule) return QuotePermission.Granted; if (await Includes(rule.Automatic, target, actor, token)) return QuotePermission.Granted; 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 Includes(List 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; // FEP-5624: the reply itself is the question, to the author alone if (ApprovesReplies(target, kind)) { await _delivery.Enqueue(actor, new[] { author.InboxURL }, ActivityPubRenderer.Create(actor, (JsonObject)interaction.DeepClone(), $"create-{localId}"), token); return; } var instrument = (JsonObject)interaction.DeepClone(); instrument.Remove("@context"); await _delivery.Enqueue(actor, new[] { author.InboxURL }, new JsonObject { ["@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 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().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(like.ID); await DB.Default.Update().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().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().MatchID(post.ID).Modify(p => p.Approval, ApprovalState.Rejected).ExecuteAsync(token); if (kind == InteractionKind.Announce) await DB.Default.Update().MatchID(target.ID).Modify(b => b.Inc(p => p.ReblogsCount, -1)).ExecuteAsync(token); return true; } // FEP-5624's ApproveReply is its own stamp, from the author (the delivery's signature says so) and naming the post // the reply answers if (Value(answer, "type") == "ApproveReply") { if (kind != InteractionKind.Reply || Id(answer) is not { } approval || Id(answer["inReplyTo"]) is { } answers && answers != target.ObjectURI) return true; post.Approval = ApprovalState.Accepted; post.ReplyApprovalURI = approval; await DB.Default.Update().MatchID(post.ID).Modify(p => p.Approval, ApprovalState.Accepted).Modify(p => p.ReplyApprovalURI, approval) .ExecuteAsync(token); } else { var authorization = Id(answer["result"]); var interactionUri = kind == InteractionKind.Reply ? post.ObjectURI : post.ActivityURI; if (authorization == default || !await Verified(authorization, interactionUri, target, token)) return true; post.Approval = ApprovalState.Accepted; post.ApprovalURI = authorization; await DB.Default.Update().MatchID(post.ID).Modify(p => p.Approval, ApprovalState.Accepted).Modify(p => p.ApprovalURI, authorization) .ExecuteAsync(token); } // now it goes where it was meant to, carrying the authorization if (kind == InteractionKind.Reply) { 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"] = post.ApprovalURI }; 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().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 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 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); } } }