P6: quote posts, received and sent (FEP-044f and the older keys)
- Received quotes: read from `quote`, `quoteUrl`, `quoteUri`, `_misskey_quote` or a FEP-e232 Link tag; the quoted post is fetched once; a quoteAuthorization stamp is verified field by field on the quoted author's origin; a consent quote without a stamp is pending; an older-key quote of a public post is shown; Delete of a stamp revokes. Counts and a `quote` notification follow the accepted state. The `quote-inline` fallback survives sanitising and is removed from content when the real quote is shown. - Personas quote through `quoted_status_id`: posts that state a quote policy get a QuoteRequest and stay pending until an Accept brings a stamp we can verify, then an Update adds quoteAuthorization; posts that state none are quoted the older way, without `quote`; another persona's posts cannot be quoted yet (we issue no stamps). Quoting posts are delivered to the quoted author too. - Mastodon API: Status.quote (with the quoted status one level deep), quotes_count, quote_approval from the remote policy, GET /api/v1/statuses/:id/quotes, `quote` notifications, and api_versions.mastodon = 7. Checked live: GoToSocial's author-only quote policy is respected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
001fdac3be
commit
6f1ab0073c
29 files changed
+678
-45
No files matched your search
@@ -233,30 +233,11 @@ namespace PrivaPub.Domain.Statuses
|
||||
return JobOutcome.Done;
|
||||
await Closing(post, author, token);
|
||||
if (!post.IsLocalOnly)
|
||||
await _outbox.Publish(author, post, Update(post, author), token);
|
||||
await _outbox.Publish(author, post, ActivityPubRenderer.UpdateOf(post, author, $"poll-{DateTime.UtcNow.Ticks}"), token);
|
||||
return JobOutcome.Done;
|
||||
}
|
||||
|
||||
protected virtual Task Closing(PostEntity post, LocalActor author, CancellationToken token) => Task.CompletedTask;
|
||||
|
||||
static JsonObject Update(PostEntity post, LocalActor author)
|
||||
{
|
||||
var question = post.Visibility == PostVisibility.Direct
|
||||
? ActivityPubRenderer.DirectNote(post, author, Array.Empty<(string, string)>(), post.ContextURI)
|
||||
: ActivityPubRenderer.Note(post, author, default, post.InReplyToURI);
|
||||
question["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray());
|
||||
question["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray());
|
||||
return new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.Context(),
|
||||
["id"] = author.ActivityUri($"update-{post.ID}-poll-{DateTime.UtcNow.Ticks}"),
|
||||
["type"] = "Update",
|
||||
["actor"] = author.Uri,
|
||||
["to"] = question["to"]!.DeepClone(),
|
||||
["cc"] = question["cc"]!.DeepClone(),
|
||||
["object"] = question
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class PollCloseJob : PollRefreshJob
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
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);
|
||||
QuotePermission Permission(PostEntity quoted, LocalActor author);
|
||||
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-";
|
||||
|
||||
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<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) ? 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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
public double? Longitude { get; init; }
|
||||
public double? RangeKm { get; init; }
|
||||
public PollDraft Poll { get; init; }
|
||||
public string QuotedStatusId { get; init; }
|
||||
}
|
||||
|
||||
public sealed record StatusOutcome(PostEntity Post, int Status = StatusCodes.Status200OK, string Error = default)
|
||||
@@ -72,12 +73,14 @@ namespace PrivaPub.Domain.Statuses
|
||||
readonly IGroupDistributor _groups;
|
||||
readonly IPollService _polls;
|
||||
readonly ILinkPreviews _previews;
|
||||
readonly IQuoteService _quotes;
|
||||
|
||||
public StatusService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
|
||||
IContentRenderer content, IOutboxPublisher outbox, IFanout fanout, IMediaService media, IGroupDistributor groups, IPollService polls,
|
||||
ILinkPreviews previews)
|
||||
ILinkPreviews previews, IQuoteService quotes)
|
||||
{
|
||||
_previews = previews;
|
||||
_quotes = quotes;
|
||||
_polls = polls;
|
||||
_media = media;
|
||||
_groups = groups;
|
||||
@@ -115,6 +118,18 @@ namespace PrivaPub.Domain.Statuses
|
||||
if (parent != default && !await VisibilityPolicy.CanSee(parent, author.Id, token))
|
||||
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
||||
|
||||
PostEntity quoted = default;
|
||||
var quotePermission = QuotePermission.Denied;
|
||||
if (!string.IsNullOrEmpty(draft.QuotedStatusId))
|
||||
{
|
||||
quoted = await _dbEntities.Posts.Match(p => p.ID == draft.QuotedStatusId && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
|
||||
if (quoted == default || !await VisibilityPolicy.CanSee(quoted, author.Id, token))
|
||||
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
||||
quotePermission = _quotes.Permission(quoted, author);
|
||||
if (quotePermission == QuotePermission.Denied)
|
||||
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This post cannot be quoted");
|
||||
}
|
||||
|
||||
var located = draft.Latitude.HasValue || draft.Longitude.HasValue;
|
||||
if (located && (draft.Latitude is not (>= -90 and <= 90) || draft.Longitude is not (>= -180 and <= 180) || group != default
|
||||
|| draft.Visibility == PostVisibility.Direct))
|
||||
@@ -166,7 +181,11 @@ namespace PrivaPub.Domain.Statuses
|
||||
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
|
||||
IsLocalOnly = isLocalOnly,
|
||||
ActorURI = author.Uri,
|
||||
Poll = draft.Poll == default ? default : _polls.Create(draft.Poll)
|
||||
Poll = draft.Poll == default ? default : _polls.Create(draft.Poll),
|
||||
QuoteURI = quoted?.ObjectURI,
|
||||
QuotedPostId = quoted?.ID,
|
||||
QuoteByConsent = quoted != default && (quoted.QuotePolicy != default || !quoted.IsFederatedCopy),
|
||||
QuoteState = quoted == default ? QuoteState.None : quotePermission == QuotePermission.Granted ? QuoteState.Accepted : QuoteState.Pending
|
||||
};
|
||||
post.ID = (string)post.GenerateNewID();
|
||||
post.ObjectURI = author.PostUri(post.ID);
|
||||
@@ -210,6 +229,10 @@ namespace PrivaPub.Domain.Statuses
|
||||
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
|
||||
await _fanout.Distribute(post, token);
|
||||
await _polls.Scheduled(post, token);
|
||||
if (post.QuoteState == QuoteState.Accepted)
|
||||
await DB.Default.Update<PostEntity>().MatchID(quoted.ID).Modify(b => b.Inc(p => p.QuotesCount, 1)).ExecuteAsync(token);
|
||||
if (post.QuoteState == QuoteState.Pending && create?["object"] is JsonObject quotingNote)
|
||||
await _quotes.Request(author, post, quoted, quotingNote, token);
|
||||
await _previews.Wanted(post, token);
|
||||
if (create != default)
|
||||
await _outbox.Publish(author, post, create, token);
|
||||
|
||||
Reference in new issue
Block a user