Circles (owner decision 2026-10-04: fix them for compatibility):
- Mastodon 4.7 and GoToSocial drop a post that names none of their accounts, and a circle post named only the circle
and its /flock. OutboxPublisher.Publish now sends each member a copy that also names that member in `cc`, on the
activity and on the object, and names no other member. The Create, every Update (edit, poll, quote approval, policy,
through the new PublishUpdate) and the Delete (StatusService.Remove now uses Publish) all go that way.
- UpdateOf renders with the post's group, so an Update keeps a circle post's `audience` and a community post's `Page`
and title.
- A reply to a circle post stays in the circle, whichever client wrote it.
- A circle post can no longer quote a post that needs permission: asking would show the circle post to its author.
Posts that are not public, on refetch (SignedFetchAuthorizer.MayRead):
- Followers-only, direct and circle posts are served to a signed request from someone they were for, or from the
instance actor of a server where one of them lives. That is a follower or an addressed account, an addressed
account, or a member. Everyone else still gets 404.
- Once deleted they answer those readers 410. Mastodon deletes its copy when a refetch answers 404.
- A circle refetch names the requesting member, or the members on the requesting server, as the delivered copy did.
- /grunts/create-{id} serves the same.
- /peasants/{name}/whispers/{id}, a DM's `context`, was never routed. It is now the conversation's posts, for its
participants only.
SecureMode lets browsers through to the redirect to the public page, instead of answering them 401.
653 tests pass.
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.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<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;
|
|
}
|
|
}
|
|
}
|