P6 done: personas can be quoted, under the owner's default of anyone, automatically
- Our public and unlisted posts state interactionPolicy.canQuote. The policy comes from the post, then the persona
(`source[quote_policy]`: public, followers or nobody; default public as the owner chose), and is always nobody for
followers-only posts and DMs.
- QuoteRequests are answered with Accept{result} naming a parrot-licence at /peasants/{name}/parrot-licences/{id}
(the route name the owner chose), or with Reject. Followers-only checks that the requester really follows.
- A licence is a QuoteAuthorization naming both posts; revoking it (POST /api/v1/statuses/:id/quotes/:quoting_id/revoke)
marks it 410, sends Delete{licence} to the quoter and the persona's followers, and revokes our own copy of the quote.
- A quote that arrives with one of our licences is accepted only if that licence is ours, unrevoked and names exactly
that quoting post. One persona quoting another gets a licence too.
- Mastodon API: quote_approval for our posts (automatic, followers, current_user), `quote_approval_policy` when posting,
PUT /api/v1/statuses/:id/interaction_policy, `source.quote_policy`.
Checked live: GoToSocial still accepts our posts with the policy stated, and leaves likes, replies and boosts open.
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
f9658a8e7a
commit
e6729c77e7
19 files changed
+372
-17
No files matched your search
@@ -25,7 +25,10 @@ namespace PrivaPub.Domain.Statuses
|
||||
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<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);
|
||||
}
|
||||
@@ -59,12 +62,115 @@ namespace PrivaPub.Domain.Statuses
|
||||
|
||||
const string RequestPrefix = "quote-request-";
|
||||
|
||||
public QuotePermission Permission(PostEntity quoted, LocalActor author)
|
||||
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)
|
||||
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))
|
||||
return;
|
||||
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))
|
||||
{
|
||||
answer["type"] = "Accept";
|
||||
answer["result"] = await Grant(author, quoted, quotingUri, actor.ActorURI, token);
|
||||
answer["id"] = author.ActivityUri($"accept-quote-{Guid.NewGuid():N}");
|
||||
}
|
||||
else
|
||||
{
|
||||
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 quoted.GroupUserId == author.Id && quoted.Visibility is PostVisibility.Public or PostVisibility.Unlisted
|
||||
? QuotePermission.Granted
|
||||
: QuotePermission.Denied;
|
||||
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)
|
||||
@@ -140,7 +246,9 @@ namespace PrivaPub.Domain.Statuses
|
||||
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
|
||||
? (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;
|
||||
|
||||
Reference in new issue
Block a user