Arrival carries a verdict that handlers set with one line before an existing return (Arrival.Drop, Reject, Accept, About), so no handler signature changes. InboxProcessor records it as an 'in' event, with the time taken, the wait since the inbox accepted it, the attempt, the audience (from the post's visibility, or else the activity's addressing), the local actor kind, the object's age for updates, deletes and reactions, and the FEP features of a delivered object. It also records types nothing handles (unknown-type), actors that cannot be loaded (deferred) and handler failures. Drop reasons: fetch-failed, unparseable, misattributed, duplicate, deleted, not-addressed, not-followed, not-public, not-visible, not-deleted, unknown-object, unknown-recipient, cross-origin, unsupported. Accepted sub-reasons: stored, poll-vote, edit, refresh, actor-refresh, actor-delete, removed, tombstone-only, auto-accepted, pending, follow-answer, quote-answer, quote-granted, undone, reaction, reported. Rejected: blocked, ignored, quote-refused. A circle's traffic is private and kindless, and a stranger posting into one is just not-addressed, so the event store cannot reveal a circle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
314 lines
14 KiB
C#
314 lines
14 KiB
C#
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<bool> Verified(string stampUri, string quotingUri, PostEntity quoted, CancellationToken token);
|
|
Task<QuotePermission> Permission(PostEntity quoted, LocalActor author, CancellationToken token);
|
|
Task<string> Grant(LocalActor author, PostEntity quoted, string quotingUri, string quoterUri, CancellationToken token);
|
|
Task ReceiveRequest(JsonNode request, ForeignAvatar actor, CancellationToken token);
|
|
Task<bool> RevokeLicence(PostEntity quoted, PostEntity quoting, CancellationToken token);
|
|
Task Request(LocalActor author, PostEntity post, PostEntity quoted, JsonObject note, CancellationToken token);
|
|
Task<bool> 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<bool> 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<bool> 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<string> Grant(LocalActor author, PostEntity quoted, string quotingUri, string quoterUri, CancellationToken token)
|
|
{
|
|
var existing = await DB.Default.Find<QuoteLicence>().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<bool> RevokeLicence(PostEntity quoted, PostEntity quoting, CancellationToken token)
|
|
{
|
|
var licence = await DB.Default.Find<QuoteLicence>().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<QuoteLicence>().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<bool> 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<QuoteLicence>().Match(l => l.ID == id && l.PostId == quoted.ID && l.QuotingObjectURI == quoting.ObjectURI && l.RevokedAt == null)
|
|
.ExecuteAnyAsync(token);
|
|
}
|
|
|
|
public async Task<QuotePermission> 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<bool> 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<PostEntity>().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<PostEntity>().MatchID(post.ID)
|
|
.Modify(p => p.QuoteAuthorizationURI, stamp)
|
|
.Modify(p => p.QuoteState, QuoteState.Accepted)
|
|
.ExecuteAsync(token);
|
|
await DB.Default.Update<PostEntity>().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) : 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<PostEntity>().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<PostEntity>().MatchID(wasCounted).Modify(b => b.Inc(p => p.QuotesCount, -1)).ExecuteAsync(token);
|
|
if (nowCounted != default)
|
|
{
|
|
await DB.Default.Update<PostEntity>().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<PostEntity>().MatchID(post.ID).Modify(p => p.QuoteState, QuoteState.Revoked).ExecuteAsync(token);
|
|
if (post.QuotedPostId != default)
|
|
await DB.Default.Update<PostEntity>().MatchID(post.QuotedPostId).Modify(b => b.Inc(p => p.QuotesCount, -1)).ExecuteAsync(token);
|
|
}
|
|
}
|
|
|
|
public async Task<bool> 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;
|
|
}
|
|
}
|
|
}
|