using MongoDB.Entities; using PrivaPub.Domain.Social; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Inbox; using PrivaPub.Federation.Objects; using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Rendering; using PrivaPub.Models.Federation; using PrivaPub.Models.User; using PrivaPub.Models.Post; using PrivaPub.Models.Social; 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 interface IQuoteService { Task Resolve(PostEntity post, NoteDocument note, CancellationToken token); Task Revoke(string stampUri, CancellationToken token); Task Verified(string stampUri, string quotingUri, PostEntity quoted, CancellationToken token); QuotePermission Permission(PostEntity quoted, LocalActor author); Task Request(LocalActor author, PostEntity post, PostEntity quoted, JsonObject note, CancellationToken token); Task Answered(JsonNode answer, ForeignAvatar actor, bool accepted, CancellationToken token); } public enum QuotePermission { Denied, Granted, AskFirst } public class QuoteService : IQuoteService { readonly DbEntities _dbEntities; readonly IRemoteActorService _remoteActors; readonly IRemotePosts _remotePosts; readonly ILocalActorService _localActors; readonly IDeliveryService _delivery; readonly IOutboxPublisher _outbox; public QuoteService(DbEntities dbEntities, IRemoteActorService remoteActors, IRemotePosts remotePosts, ILocalActorService localActors, IDeliveryService delivery, IOutboxPublisher outbox) { _dbEntities = dbEntities; _remoteActors = remoteActors; _remotePosts = remotePosts; _localActors = localActors; _delivery = delivery; _outbox = outbox; } const string RequestPrefix = "quote-request-"; public QuotePermission Permission(PostEntity quoted, LocalActor author) { if (!quoted.IsFederatedCopy) return quoted.GroupUserId == author.Id && quoted.Visibility is PostVisibility.Public or PostVisibility.Unlisted ? QuotePermission.Granted : QuotePermission.Denied; if (quoted.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted)) return QuotePermission.Denied; if (quoted.QuotePolicy is not { } policy) return QuotePermission.Granted; return policy.Automatic.Concat(policy.Manual).Any(Addressing.IsPublic) || policy.Automatic.Concat(policy.Manual).Contains(author.Uri) ? QuotePermission.AskFirst : QuotePermission.Denied; } public async Task Request(LocalActor author, PostEntity post, PostEntity quoted, JsonObject note, CancellationToken token) { var owner = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == quoted.ActorURI).ExecuteFirstAsync(token); if (string.IsNullOrEmpty(owner?.InboxURL)) return; await _delivery.Enqueue(author, new[] { owner.InboxURL }, new JsonObject { ["@context"] = ActivityPubRenderer.Context(), ["id"] = author.ActivityUri(RequestPrefix + post.ID), ["type"] = "QuoteRequest", ["actor"] = author.Uri, ["object"] = quoted.ObjectURI, ["instrument"] = note.DeepClone(), ["to"] = new JsonArray(quoted.ActorURI) }, token); } public async Task Answered(JsonNode answer, ForeignAvatar actor, bool accepted, CancellationToken token) { var request = answer["object"]; var requestId = Id(request); var marker = requestId?.LastIndexOf("/grunts/" + RequestPrefix, StringComparison.Ordinal) ?? -1; if (marker < 0 && !(request is JsonObject && Value(request, "type") == "QuoteRequest")) return false; if (marker < 0) return true; var postId = requestId[(marker + "/grunts/".Length + RequestPrefix.Length)..]; var post = await _dbEntities.Posts.Match(p => p.ID == postId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); var author = post == default ? default : await _localActors.FindById(LocalActorKind.Person, post.GroupUserId, token); if (author == default || author.ActivityUri(RequestPrefix + post.ID) != requestId || post.QuoteState != QuoteState.Pending) return true; var quoted = await _dbEntities.Posts.MatchID(post.QuotedPostId).ExecuteFirstAsync(token); if (quoted?.ActorURI != actor.ActorURI) return true; if (!accepted) { await DB.Default.Update().MatchID(post.ID).Modify(p => p.QuoteState, QuoteState.Rejected).ExecuteAsync(token); return true; } var stamp = Id(answer["result"]); if (stamp == default || !await Verified(stamp, post.ObjectURI, quoted, token)) return true; post.QuoteAuthorizationURI = stamp; post.QuoteState = QuoteState.Accepted; await DB.Default.Update().MatchID(post.ID) .Modify(p => p.QuoteAuthorizationURI, stamp) .Modify(p => p.QuoteState, QuoteState.Accepted) .ExecuteAsync(token); await DB.Default.Update().MatchID(quoted.ID).Modify(b => b.Inc(p => p.QuotesCount, 1)).ExecuteAsync(token); if (!post.IsLocalOnly) await _outbox.Publish(author, post, ActivityPubRenderer.UpdateOf(post, author, "quote-approved"), token); return true; } public async Task Resolve(PostEntity post, NoteDocument note, CancellationToken token) { if (note.QuoteUri == default && !note.QuoteDeleted) return; var quoted = note.QuoteUri == default ? default : await _dbEntities.Posts.Match(p => p.ObjectURI == note.QuoteUri && !p.DeletedAt.HasValue).ExecuteFirstAsync(token) ?? await _remotePosts.StoreContext(note.QuoteUri, RemotePosts.MaxDepth, token); var state = note.QuoteDeleted ? QuoteState.Deleted : quoted == default ? QuoteState.Pending : note.QuoteAuthorization != default ? !quoted.IsFederatedCopy || !await Verified(note.QuoteAuthorization, post.ObjectURI, quoted, token) ? QuoteState.Unauthorized : QuoteState.Accepted : note.QuotesByConsent ? QuoteState.Pending : quoted.Visibility is PostVisibility.Public or PostVisibility.Unlisted ? QuoteState.Accepted : QuoteState.Unauthorized; var wasCounted = post.QuoteState == QuoteState.Accepted ? post.QuotedPostId : default; await DB.Default.Update().MatchID(post.ID) .Modify(p => p.QuoteURI, note.QuoteUri) .Modify(p => p.QuotedPostId, quoted?.ID) .Modify(p => p.QuoteState, state) .Modify(p => p.QuoteAuthorizationURI, state == QuoteState.Accepted ? note.QuoteAuthorization : default) .ExecuteAsync(token); post.QuotedPostId = quoted?.ID; post.QuoteState = state; var nowCounted = state == QuoteState.Accepted ? quoted?.ID : default; if (wasCounted == nowCounted) return; if (wasCounted != default) await DB.Default.Update().MatchID(wasCounted).Modify(b => b.Inc(p => p.QuotesCount, -1)).ExecuteAsync(token); if (nowCounted != default) { await DB.Default.Update().MatchID(nowCounted).Modify(b => b.Inc(p => p.QuotesCount, 1)).ExecuteAsync(token); if (!quoted.IsFederatedCopy) await Notifications.Add(quoted.GroupUserId, NotificationType.Quote, post.AuthorAccountId, post.ActorURI, post.ID, token); } } public async Task Revoke(string stampUri, CancellationToken token) { var revoked = await _dbEntities.Posts.Match(p => p.QuoteAuthorizationURI == stampUri && p.QuoteState == QuoteState.Accepted).ExecuteAsync(token); foreach (var post in revoked) { await DB.Default.Update().MatchID(post.ID).Modify(p => p.QuoteState, QuoteState.Revoked).ExecuteAsync(token); if (post.QuotedPostId != default) await DB.Default.Update().MatchID(post.QuotedPostId).Modify(b => b.Inc(p => p.QuotesCount, -1)).ExecuteAsync(token); } } public async Task Verified(string stampUri, string quotingUri, PostEntity quoted, CancellationToken token) { if (!Origin.Same(stampUri, quoted.ActorURI)) return false; using var fetched = await _remoteActors.FetchObject(stampUri, token); if (fetched == default) return false; var stamp = JsonNode.Parse(fetched.Root.GetRawText()); return Value(stamp, "type") == "QuoteAuthorization" && Id(stamp["attributedTo"]) == quoted.ActorURI && Id(stamp["interactingObject"]) == quotingUri && Id(stamp["interactionTarget"]) == quoted.ObjectURI; } } }