P6 done: personas can be quoted, under the owner's default of anyone, automatically
Build / Build (push) Successful in 1m7s
Deploy / privapub.thepra.dev (push) Successful in 1m15s

- 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:
thepraandClaude Opus 5.5 committed 2026-10-01 19:53:13 +02:00
1 parent f9658a8e7a
commit e6729c77e7
19 files changed
+372 -17

No files matched your search

+7 -1
View File
@@ -13,6 +13,7 @@ using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Media;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.Models.Social;
using PrivaPub.StaticServices;
@@ -41,6 +42,7 @@ namespace PrivaPub.Domain.Statuses
public double? RangeKm { get; init; }
public PollDraft Poll { get; init; }
public string QuotedStatusId { get; init; }
public string QuotePolicy { get; init; }
}
public sealed record StatusOutcome(PostEntity Post, int Status = StatusCodes.Status200OK, string Error = default)
@@ -125,7 +127,7 @@ namespace PrivaPub.Domain.Statuses
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);
quotePermission = await _quotes.Permission(quoted, author, token);
if (quotePermission == QuotePermission.Denied)
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This post cannot be quoted");
}
@@ -190,6 +192,10 @@ namespace PrivaPub.Domain.Statuses
post.ID = (string)post.GenerateNewID();
post.ObjectURI = author.PostUri(post.ID);
post.Url = author.PostHtmlUrl(post.ID);
post.LocalQuotePolicy = QuotePolicies.IsKnown(draft.QuotePolicy) ? draft.QuotePolicy : author.Settings.QuotePolicy ?? QuotePolicies.Public;
if (quoted is { IsFederatedCopy: false } && post.QuoteState == QuoteState.Accepted
&& await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token) is { } quotedAuthor)
post.QuoteAuthorizationURI = await _quotes.Grant(quotedAuthor, quoted, post.ObjectURI, author.Uri, token);
if (located)
{
post.Geo = new Coordinates2D(Math.Round(draft.Longitude.Value, 2), Math.Round(draft.Latitude.Value, 2));