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); Task Permission(PostEntity quoted, LocalActor author, CancellationToken token); Task Grant(LocalActor author, PostEntity quoted, string quotingUri, string quoterUri, CancellationToken token); Task ReceiveRequest(JsonNode request, ForeignAvatar actor, CancellationToken token); Task RevokeLicence(PostEntity quoted, PostEntity quoting, CancellationToken token); 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-"; const string LicencePath = "/parrot-licences/"; public static string LicenceUri(LocalActor author, string licenceId) => author.Uri + LicencePath + licenceId; async Task MayQuote(PostEntity quoted, string quoterUri, CancellationToken token) { var author = await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token); if (author == default) return false; if (quoterUri == author.Uri) return quoted.Visibility is PostVisibility.Public or PostVisibility.Unlisted; return ActivityPubRenderer.QuotableBy(quoted) switch { QuotePolicies.Public => true, QuotePolicies.Followers => await Follows(quoterUri, author, token), _ => false }; } async Task Follows(string followerUri, LocalActor author, CancellationToken token) { if (await _dbEntities.Followers.Match(f => f.LocalActorId == author.Id && f.ActorURI == followerUri && f.IsAccepted).ExecuteAnyAsync(token)) return true; var local = await _localActors.FindByUri(followerUri, token); return local != default && await _dbEntities.Followings.Match(f => f.AvatarId == local.Id && f.TargetActorURI == author.Uri && f.State == FollowState.Accepted).ExecuteAnyAsync(token); } public async Task Grant(LocalActor author, PostEntity quoted, string quotingUri, string quoterUri, CancellationToken token) { var existing = await DB.Default.Find().Match(l => l.PostId == quoted.ID && l.QuotingObjectURI == quotingUri && l.RevokedAt == null).ExecuteFirstAsync(token); if (existing != default) return LicenceUri(author, existing.ID); var licence = new QuoteLicence { PostId = quoted.ID, AuthorAvatarId = author.Id, QuotingObjectURI = quotingUri, QuoterActorURI = quoterUri }; await DB.Default.SaveAsync(licence, token); return LicenceUri(author, licence.ID); } public async Task ReceiveRequest(JsonNode request, ForeignAvatar actor, CancellationToken token) { var objectUri = Id(request["object"]); var instrument = request["instrument"]; var quotingUri = Id(instrument); if (objectUri == default || quotingUri == default || !Origin.Same(quotingUri, actor.ActorURI) || instrument is JsonObject embedded && Id(embedded["attributedTo"]) is { } by && by != actor.ActorURI) { Arrival.Drop("misattributed"); return; } var quoted = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); var author = quoted == default ? default : await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token); if (author == default || string.IsNullOrEmpty(actor.InboxURL)) { Arrival.Drop("unknown-object"); return; } Arrival.About("Note", quoted.Visibility, quoted.CreationDate); var answer = new JsonObject { ["@context"] = ActivityPubRenderer.Context(), ["actor"] = author.Uri, ["object"] = Id(request), ["to"] = new JsonArray(actor.ActorURI) }; if (await MayQuote(quoted, actor.ActorURI, token)) { Arrival.Accept("quote-granted"); answer["type"] = "Accept"; answer["result"] = await Grant(author, quoted, quotingUri, actor.ActorURI, token); answer["id"] = author.ActivityUri($"accept-quote-{Guid.NewGuid():N}"); } else { Arrival.Reject("quote-refused"); answer["type"] = "Reject"; answer["id"] = author.ActivityUri($"reject-quote-{Guid.NewGuid():N}"); } await _delivery.Enqueue(author, new[] { actor.InboxURL }, answer, token); } public async Task RevokeLicence(PostEntity quoted, PostEntity quoting, CancellationToken token) { var licence = await DB.Default.Find().Match(l => l.PostId == quoted.ID && l.QuotingObjectURI == quoting.ObjectURI && l.RevokedAt == null).ExecuteFirstAsync(token); var author = licence == default ? default : await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token); if (author == default) return false; await DB.Default.Update().MatchID(licence.ID).Modify(l => l.RevokedAt, DateTime.UtcNow).ExecuteAsync(token); var uri = LicenceUri(author, licence.ID); await Revoke(uri, token); var quoter = quoting.IsFederatedCopy ? await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == quoting.ActorURI).ExecuteFirstAsync(token) : default; var inboxes = (await _delivery.FollowerInboxes(author, token)).Append(quoter?.InboxURL).Where(i => !string.IsNullOrEmpty(i)).Distinct().ToList(); if (inboxes.Count > 0) await _delivery.Enqueue(author, inboxes, new JsonObject { ["@context"] = ActivityPubRenderer.Context(), ["id"] = author.ActivityUri($"revoke-quote-{licence.ID}"), ["type"] = "Delete", ["actor"] = author.Uri, ["object"] = uri, ["to"] = new JsonArray(Addressing.Public) }, token); return true; } async Task OurLicence(string stampUri, PostEntity quoting, PostEntity quoted, CancellationToken token) { var marker = stampUri?.LastIndexOf(LicencePath, StringComparison.Ordinal) ?? -1; if (marker < 0 || !stampUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase)) return false; var id = stampUri[(marker + LicencePath.Length)..]; return await DB.Default.Find().Match(l => l.ID == id && l.PostId == quoted.ID && l.QuotingObjectURI == quoting.ObjectURI && l.RevokedAt == null) .ExecuteAnyAsync(token); } public async Task Permission(PostEntity quoted, LocalActor author, CancellationToken token) { if (!quoted.IsFederatedCopy) return await MayQuote(quoted, author.Uri, token) ? 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.PublishUpdate(author, post, "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) : await OurLicence(note.QuoteAuthorization, post, quoted, token)) ? QuoteState.Accepted : QuoteState.Unauthorized : 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; } } }