Files
SocialPub/PrivaPub/Domain/Statuses/QuoteService.cs
T
thepraandClaude Opus 5.5 b710493701 A boost or a favourite given again is sent again
Every boost of a post was announce-{post}, and every favourite like-{post}:
after an Undo, giving it again made the same activity id, which the
delivery queue had already delivered and dropped (and a server that
remembers ids would ignore). Found by decePub's e2e actions against the
pasture, which unboost and boost again. Now each boost and favourite is an
activity of its own, as Mastodon's are, and an Undo names the one it ends;
a concurrent second boost is removed after saving. An edit's Update is
named to the millisecond, a quote approval's to the tick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 02:48:35 +02:00

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.PublishUpdate(author, post, $"quote-approved-{DateTime.UtcNow.Ticks}", 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;
}
}
}