From e6729c77e7f2cbe351724ee123d4b6f3ee0d1691 Mon Sep 17 00:00:00 2001 From: thepra Date: Thu, 1 Oct 2026 19:53:13 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB --- CLAUDE.md | 5 + FEDERATION.md | 9 +- PrivaPub.Tests/Federation/QuoteTests.cs | 68 +++++++++- PrivaPub.Tests/Support/Harness.cs | 1 + .../Controllers/AccountsController.cs | 2 + .../Controllers/StatusesController.cs | 37 +++++- PrivaPub/Api/Mastodon/Entities/Entities.cs | 1 + .../Api/Mastodon/Mappers/MastodonMapper.cs | 28 +++- PrivaPub/Domain/Statuses/QuoteService.cs | 120 +++++++++++++++++- PrivaPub/Domain/Statuses/StatusService.cs | 8 +- .../Controllers/PeasantsController.cs | 27 ++++ .../Inbox/Handlers/QuoteRequestHandler.cs | 21 +++ .../Rendering/ActivityPubRenderer.cs | 23 ++++ PrivaPub/Infrastructure/Data/Indexes.cs | 1 + .../Middleware/SocialPubConfigurations.cs | 1 + PrivaPub/Models/Federation/QuoteLicence.cs | 14 ++ PrivaPub/Models/Post/Post.cs | 3 +- PrivaPub/Models/User/Avatar.cs | 10 ++ docs/ROADMAP.md | 10 +- 19 files changed, 372 insertions(+), 17 deletions(-) create mode 100644 PrivaPub/Federation/Inbox/Handlers/QuoteRequestHandler.cs create mode 100644 PrivaPub/Models/Federation/QuoteLicence.cs diff --git a/CLAUDE.md b/CLAUDE.md index e47a26b..b01062a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,7 @@ actor-scoped route gets a name in the same spirit, agreed with the owner, and a | Notes | `/peasants/{name}/scribbles/{id}` | | Activities | `/peasants/{name}/grunts/{id}` (`create-{postId}` resolves) | | DM context | `/peasants/{name}/whispers/{id}` | +| Quote permission (FEP-044f `QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{id}` | | Token refresh | `/clientapi/user/sniff/again` | Agreed for later phases: `/gossip`, `/drool`, `/echoes` (replies, likes, shares), `/trophies` (featured), `/tattoos` @@ -279,6 +280,10 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ `QuotesCount` moves with the accepted state, never with the raw key. When a persona quotes, `QuotePermission` decides: asking first for posts that state a policy, quoting at once for posts that state none, refusing otherwise. Only `quote` of a post that asks for consent puts `quote` in our JSON; older-key quotes leave it out, as Sharkey learned they must. +- **Our quote policy is `ActivityPubRenderer.QuotableBy`:** the post's `LocalQuotePolicy`, falling back to the persona's + `Settings.QuotePolicy` (default `public`, owner decision), and always `nobody` for anything but public and unlisted. The + same rule writes `canQuote`, answers `QuoteRequest`s (`QuoteService.ReceiveRequest`) and fills `quote_approval`, so they + cannot disagree. A persona quoting another persona gets a parrot-licence too, so other servers see an approved quote. - **A server is described on arrival, never on read.** The first record from a host enqueues `DescribeInstance` (its NodeInfo, at most once a week, into `RemoteInstance`), so opening the details view tells nobody anything. - **Post ids are the timeline order, so they follow arrival, not `published`.** `PrivacyIds.Arrived` gives a remote post diff --git a/FEDERATION.md b/FEDERATION.md index 9d7f2eb..309c602 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -50,6 +50,7 @@ actor document, a collection, NodeInfo or a delivery relates two avatars of the | Followers / following | `/peasants/{name}/groupies`, `/peasants/{name}/stalking` | | Objects | `/peasants/{name}/scribbles/{id}` | | Activities | `/peasants/{name}/grunts/{id}` | +| Quote permissions (`QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{id}` | | Direct-message context | `/peasants/{name}/whispers/{id}` | | Profile and post pages | `/@{name}`, `/@{name}/{id}` | @@ -130,7 +131,13 @@ An incoming `Update` without a newer `updated` only refreshes counts and details stamp we can verify; the post is then updated with `quoteAuthorization`. - Posts that state no policy are quoted the older way, without `quote`. - Quoting posts are also delivered to the quoted author. -- **Not yet.** Our own posts cannot be quoted by others yet: we do not issue stamps. +- **Quoting our accounts.** Our public and unlisted posts state `interactionPolicy.canQuote`. By default anyone may quote, + automatically (as on Mastodon); an account can choose followers only or nobody, and so can each post. + - A `QuoteRequest` is answered with `Accept{result}` naming a stamp at `/peasants/{name}/parrot-licences/{id}`, or + with `Reject`. + - A stamp is a `QuoteAuthorization` naming both posts. A revoked stamp answers 410 and is announced with + `Delete{stamp}`. + - Followers-only posts and DMs can never be quoted. **Custom emoji** (`Emoji` tags) are read on posts, display names, bios and profile fields, at most 64 per object. **Profiles** keep their header, profile fields, `manuallyApprovesFollowers`, `published`, `movedTo`, `indexable`, diff --git a/PrivaPub.Tests/Federation/QuoteTests.cs b/PrivaPub.Tests/Federation/QuoteTests.cs index 8508001..f6bf0ce 100644 --- a/PrivaPub.Tests/Federation/QuoteTests.cs +++ b/PrivaPub.Tests/Federation/QuoteTests.cs @@ -139,15 +139,15 @@ namespace PrivaPub.Tests.Federation } [Fact] - public async Task A_legacy_post_is_quoted_without_asking_and_another_persona_cannot_be_quoted_yet() + public async Task A_legacy_post_is_quoted_without_asking_and_a_persona_that_allows_nobody_cannot_be_quoted() { var token = TestContext.Current.CancellationToken; var (aliceRoot, alice) = await _harness.Persona("alice"); var (bobRoot, bob) = await _harness.Persona("bob"); var kitty = new RemoteActor(_harness.Peer, "kitty"); var legacy = await Delivered(kitty, alice.Uri); - await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = "mine" }, token); - var bobs = await DB.Default.Find().Match(p => p.GroupUserId == bob.Id).ExecuteFirstAsync(token); + bob.Settings.QuotePolicy = PrivaPub.Models.User.QuotePolicies.Nobody; + var bobs = (await _harness.Statuses.Publish(bob, new StatusDraft { Text = "mine" }, token)).Post; var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "nice", QuotedStatusId = legacy.ID }, token); Assert.Equal(QuoteState.Accepted, outcome.Post.QuoteState); @@ -155,5 +155,67 @@ namespace PrivaPub.Tests.Federation Assert.False((await _harness.Statuses.Publish(alice, new StatusDraft { Text = "and this", QuotedStatusId = bobs.ID }, token)).Ok); Assert.True((await _harness.Statuses.Publish(bob, new StatusDraft { Text = "me again", QuotedStatusId = bobs.ID }, token)).Ok); } + + JsonObject Request(RemoteActor quoter, string quotedUri, string quotingId) => new() + { + ["id"] = NewId(quoter, "activities"), ["type"] = "QuoteRequest", ["actor"] = quoter.Id, ["object"] = quotedUri, + ["instrument"] = new JsonObject { ["id"] = quotingId, ["type"] = "Note", ["attributedTo"] = quoter.Id, ["quote"] = quotedUri, ["content"] = "

so true

" } + }; + + [Fact] + public async Task Anyone_may_quote_a_persona_by_default_and_the_licence_can_be_revoked() + { + var token = TestContext.Current.CancellationToken; + var (_, alice) = await _harness.Persona("alice"); + var mallory = new RemoteActor(_harness.Peer, "mallory"); + await _harness.Deliver(mallory, "/human-centipede", new JsonObject { ["id"] = NewId(mallory, "follows"), ["type"] = "Follow", ["actor"] = mallory.Id, ["object"] = alice.Uri }); + var original = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "quote me" }, token)).Post; + var created = (await _harness.Outgoing(mallory.Id + "/inbox")).Single(a => a["type"]!.GetValue() == "Create")["object"]!; + Assert.Equal(Addressing.Public, created["interactionPolicy"]!["canQuote"]!["automaticApproval"]![0]!.GetValue()); + + var quotingId = NewId(mallory, "notes"); + await _harness.Deliver(mallory, "/human-centipede", Request(mallory, original.ObjectURI, quotingId)); + var accept = Assert.Single(await _harness.Outgoing(mallory.Id + "/inbox"), a => a["type"]!.GetValue() == "Accept" && a["result"] != null); + var licence = accept["result"]!.GetValue(); + Assert.StartsWith(alice.Uri + "/parrot-licences/", licence); + + var quoting = await Delivered(mallory, alice.Uri, n => { n["id"] = quotingId; n["quote"] = original.ObjectURI; n["quoteAuthorization"] = licence; }); + Assert.Equal(QuoteState.Accepted, quoting.QuoteState); + Assert.Equal(1, (await DB.Default.Find().OneAsync(original.ID, token)).QuotesCount); + + Assert.True(await _harness.Quotes.RevokeLicence(original, quoting, token)); + Assert.Equal(QuoteState.Revoked, (await DB.Default.Find().OneAsync(quoting.ID, token)).QuoteState); + Assert.Contains(await _harness.Outgoing(mallory.Id + "/inbox"), a => a["type"]!.GetValue() == "Delete" && a["object"]!.GetValue() == licence); + } + + [Fact] + public async Task A_followers_only_persona_turns_a_stranger_down() + { + var token = TestContext.Current.CancellationToken; + var (_, alice) = await _harness.Persona("alice"); + alice.Settings.QuotePolicy = PrivaPub.Models.User.QuotePolicies.Followers; + var original = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "friends only quoting" }, token)).Post; + var stranger = new RemoteActor(_harness.Peer, "stranger"); + + await _harness.Deliver(stranger, "/human-centipede", Request(stranger, original.ObjectURI, NewId(stranger, "notes"))); + + Assert.Equal("Reject", Assert.Single(await _harness.Outgoing(stranger.Id + "/inbox"))["type"]!.GetValue()); + } + + [Fact] + public async Task One_persona_quotes_another_with_a_licence() + { + var token = TestContext.Current.CancellationToken; + var (_, alice) = await _harness.Persona("alice"); + var (_, bob) = await _harness.Persona("bob"); + var original = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "public thought" }, token)).Post; + + var outcome = await _harness.Statuses.Publish(bob, new StatusDraft { Text = "agreed", QuotedStatusId = original.ID }, token); + + Assert.True(outcome.Ok); + Assert.Equal(QuoteState.Accepted, outcome.Post.QuoteState); + Assert.StartsWith(alice.Uri + "/parrot-licences/", outcome.Post.QuoteAuthorizationURI); + Assert.Equal(1, (await DB.Default.Find().OneAsync(original.ID, token)).QuotesCount); + } } } diff --git a/PrivaPub.Tests/Support/Harness.cs b/PrivaPub.Tests/Support/Harness.cs index eb46002..0600f69 100644 --- a/PrivaPub.Tests/Support/Harness.cs +++ b/PrivaPub.Tests/Support/Harness.cs @@ -58,6 +58,7 @@ namespace PrivaPub.Tests.Support new UndoHandler(Db, Local, Reactions), new LikeHandler(Db, Reactions), new EmojiReactHandler(Db, Reactions), + new QuoteRequestHandler(Quotes), new DislikeHandler(Db), new JoinHandler(Db, Local, Delivery), new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote), diff --git a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs index fac50f5..21aeba2 100644 --- a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs @@ -79,6 +79,8 @@ namespace PrivaPub.Api.Mastodon.Controllers avatar.Settings.DefaultSensitive = sensitive; if (Params.Has("source[language]")) avatar.Settings.DefaultLanguage = Params.Get("source[language]"); + if (Params.Get("source[quote_policy]") is { } quotePolicy && QuotePolicies.IsKnown(quotePolicy)) + avatar.Settings.QuotePolicy = quotePolicy; var fields = new Dictionary(); for (var i = 0; i < 4; i++) { diff --git a/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs b/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs index 299c9da..7fcc0b3 100644 --- a/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs @@ -9,6 +9,9 @@ using PrivaPub.Api.Mastodon.Mappers; using PrivaPub.Domain.Privacy; using PrivaPub.Domain.Statuses; using PrivaPub.Models.Post; +using PrivaPub.Models.User; +using PrivaPub.Federation.Rendering; +using PrivaPub.Federation.Outbox; using PrivaPub.StaticServices; using PostEntity = PrivaPub.Models.Post.Post; @@ -24,9 +27,14 @@ namespace PrivaPub.Api.Mastodon.Controllers readonly MastodonMapper _mapper; readonly DbEntities _dbEntities; readonly IMemoryCache _cache; + readonly IQuoteService _quotes; + readonly IOutboxPublisher _outbox; - public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache) + public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache, IQuoteService quotes, + IOutboxPublisher outbox) { + _quotes = quotes; + _outbox = outbox; _statuses = statuses; _mapper = mapper; _dbEntities = dbEntities; @@ -55,6 +63,7 @@ namespace PrivaPub.Api.Mastodon.Controllers Language = Params.Get("language") ?? Me.Settings.DefaultLanguage, MediaIds = Params.List("media_ids"), QuotedStatusId = Params.Get("quoted_status_id"), + QuotePolicy = Params.Get("quote_approval_policy"), Poll = Params.Has("poll[options]") ? new PollDraft(Params.List("poll[options]"), Params.Int("poll[expires_in]") ?? 0, Params.Bool("poll[multiple]") == true, Params.Bool("poll[hide_totals]") == true) @@ -242,6 +251,32 @@ namespace PrivaPub.Api.Mastodon.Controllers return Json(await _mapper.Statuses(visible, MyId, token)); } + [HttpPost("/api/v1/statuses/{id}/quotes/{quotingId}/revoke"), Scope("write:statuses")] + public async Task RevokeQuote(string id, string quotingId, CancellationToken token) + { + var quoted = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); + var quoting = quoted == default ? default : await _dbEntities.Posts.Match(p => p.ID == quotingId && p.QuotedPostId == quoted.ID).ExecuteFirstAsync(token); + if (quoting == default || !await _quotes.RevokeLicence(quoted, quoting, token)) + return NotFoundError(); + return Json(await _mapper.Status(await _dbEntities.Posts.MatchID(quoting.ID).ExecuteFirstAsync(token), MyId, token)); + } + + [HttpPut("/api/v1/statuses/{id}/interaction_policy"), Scope("write:statuses")] + public async Task InteractionPolicy(string id, CancellationToken token) + { + var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); + if (post == default) + return NotFoundError(); + var policy = Params.Get("quote_approval_policy"); + if (!QuotePolicies.IsKnown(policy)) + return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Unknown quote approval policy"); + await DB.Default.Update().MatchID(post.ID).Modify(p => p.LocalQuotePolicy, policy).ExecuteAsync(token); + post.LocalQuotePolicy = policy; + if (!post.IsLocalOnly) + await _outbox.Publish(Me, post, ActivityPubRenderer.UpdateOf(post, Me, $"policy-{DateTime.UtcNow.Ticks}"), token); + return Json(await _mapper.Status(post, MyId, token)); + } + [HttpPost("/api/v1/statuses/{id}/bookmark"), Scope("write:bookmarks")] public async Task Bookmark(string id, CancellationToken token) { diff --git a/PrivaPub/Api/Mastodon/Entities/Entities.cs b/PrivaPub/Api/Mastodon/Entities/Entities.cs index 5cd24b8..d1c8437 100644 --- a/PrivaPub/Api/Mastodon/Entities/Entities.cs +++ b/PrivaPub/Api/Mastodon/Entities/Entities.cs @@ -59,6 +59,7 @@ namespace PrivaPub.Api.Mastodon.Entities public List Fields { get; set; } = new(); public int FollowRequestsCount { get; set; } public string AttributionDomains { get; set; } + public string QuotePolicy { get; set; } = "public"; } public class PrivaPubStatus diff --git a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs index 06a5357..cc61a5e 100644 --- a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs +++ b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs @@ -93,6 +93,7 @@ namespace PrivaPub.Api.Mastodon.Mappers Privacy = actor.Settings.DefaultVisibility ?? "public", Sensitive = actor.Settings.DefaultSensitive, Language = actor.Settings.DefaultLanguage, + QuotePolicy = actor.Settings.QuotePolicy ?? QuotePolicies.Public, Note = actor.Summary ?? string.Empty, Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(), FollowRequestsCount = (int)await DB.Default.CountAsync(f => f.LocalActorId == actor.Id && !f.IsAccepted, token) @@ -238,6 +239,10 @@ namespace PrivaPub.Api.Mastodon.Mappers return status; } + var localAuthors = all.Where(p => !p.IsFederatedCopy && p.GroupUserId != default).Select(p => p.GroupUserId).Distinct().ToList(); + var followsAuthor = viewerId == default || localAuthors.Count == 0 + ? new HashSet() + : await FollowedLocalAuthors(viewerId, localAuthors, token); var quotedIds = nested ? new List() : all.Where(p => p.QuoteState == QuoteState.Accepted && p.QuotedPostId != default).Select(p => p.QuotedPostId).Distinct().ToList(); var quotedPosts = new List(); foreach (var quotedPost in quotedIds.Count == 0 ? new List() : await _dbEntities.Posts.Match(p => quotedIds.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)) @@ -250,7 +255,7 @@ namespace PrivaPub.Api.Mastodon.Mappers void Quote(Status status, PostEntity post) { status.QuotesCount = post.QuotesCount; - status.QuoteApproval = Approval(post, viewerId); + status.QuoteApproval = post.IsFederatedCopy ? Approval(post, viewerId) : LocalApproval(post, viewerId, followsAuthor.Contains(post.GroupUserId)); if (post.QuoteState == QuoteState.None) return; var state = post.QuoteState.ToString().ToLowerInvariant(); @@ -300,6 +305,27 @@ namespace PrivaPub.Api.Mastodon.Mappers return document.Body!.InnerHtml; } + async Task> FollowedLocalAuthors(string viewerId, List authorIds, CancellationToken token) + { + var uris = new Dictionary(); + foreach (var avatar in await _dbEntities.Avatars.Match(a => authorIds.Contains(a.ID)).ExecuteAsync(token)) + uris[_localActors.FromAvatar(avatar).Uri] = avatar.ID; + var targets = uris.Keys.ToList(); + return (await _dbEntities.Followings.Match(f => f.AvatarId == viewerId && targets.Contains(f.TargetActorURI) && f.State == FollowState.Accepted).ExecuteAsync(token)) + .Select(f => uris[f.TargetActorURI]).ToHashSet(); + } + + static QuoteApprovalEntity LocalApproval(PostEntity post, string viewerId, bool viewerFollows) + { + var policy = ActivityPubRenderer.QuotableBy(post); + var everyone = policy switch { QuotePolicies.Public => new List { "public" }, QuotePolicies.Followers => new List { "followers" }, _ => new List() }; + var current = viewerId == default ? "unknown" + : viewerId == post.GroupUserId && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted ? "automatic" + : policy == QuotePolicies.Public || policy == QuotePolicies.Followers && viewerFollows ? "automatic" + : "denied"; + return new QuoteApprovalEntity { Automatic = everyone, CurrentUser = current }; + } + static QuoteApprovalEntity Approval(PostEntity post, string viewerId) { if (!post.IsFederatedCopy || post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted)) diff --git a/PrivaPub/Domain/Statuses/QuoteService.cs b/PrivaPub/Domain/Statuses/QuoteService.cs index 9d82953..9d03f1f 100644 --- a/PrivaPub/Domain/Statuses/QuoteService.cs +++ b/PrivaPub/Domain/Statuses/QuoteService.cs @@ -25,7 +25,10 @@ namespace PrivaPub.Domain.Statuses Task Resolve(PostEntity post, NoteDocument note, CancellationToken token); Task Revoke(string stampUri, CancellationToken token); Task Verified(string stampUri, string quotingUri, PostEntity quoted, CancellationToken token); - QuotePermission Permission(PostEntity quoted, LocalActor author); + Task Permission(PostEntity quoted, LocalActor author, CancellationToken token); + Task Grant(LocalActor author, PostEntity quoted, string quotingUri, string quoterUri, CancellationToken token); + Task ReceiveRequest(JsonNode request, ForeignAvatar actor, CancellationToken token); + Task RevokeLicence(PostEntity quoted, PostEntity quoting, CancellationToken token); Task Request(LocalActor author, PostEntity post, PostEntity quoted, JsonObject note, CancellationToken token); Task 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 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 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 Grant(LocalActor author, PostEntity quoted, string quotingUri, string quoterUri, CancellationToken token) + { + var existing = await DB.Default.Find().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 RevokeLicence(PostEntity quoted, PostEntity quoting, CancellationToken token) + { + var licence = await DB.Default.Find().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().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 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().Match(l => l.ID == id && l.PostId == quoted.ID && l.QuotingObjectURI == quoting.ObjectURI && l.RevokedAt == null) + .ExecuteAnyAsync(token); + } + + public async Task 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; diff --git a/PrivaPub/Domain/Statuses/StatusService.cs b/PrivaPub/Domain/Statuses/StatusService.cs index 08acfb2..3f27c21 100644 --- a/PrivaPub/Domain/Statuses/StatusService.cs +++ b/PrivaPub/Domain/Statuses/StatusService.cs @@ -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)); diff --git a/PrivaPub/Federation/Controllers/PeasantsController.cs b/PrivaPub/Federation/Controllers/PeasantsController.cs index 04a51f2..646d3fe 100644 --- a/PrivaPub/Federation/Controllers/PeasantsController.cs +++ b/PrivaPub/Federation/Controllers/PeasantsController.cs @@ -199,6 +199,33 @@ namespace PrivaPub.Federation.Controllers return Activity(note); } + [HttpGet, Route("{actor}/parrot-licences/{licenceId}")] + public async Task ParrotLicence(string actor, string licenceId, CancellationToken token) + { + var author = await _localActors.FindByUserName(actor, token); + var licence = author == default ? default : await DB.Default.Find().Match(l => l.ID == licenceId && l.AuthorAvatarId == author.Id).ExecuteFirstAsync(token); + var quoted = licence == default ? default : await _dbEntities.Posts.MatchID(licence.PostId).ExecuteFirstAsync(token); + if (quoted == default) + return NotFound(); + var uri = Domain.Statuses.QuoteService.LicenceUri(author, licence.ID); + if (licence.RevokedAt.HasValue || quoted.DeletedAt.HasValue) + return new ContentResult + { + Content = new JsonObject { ["@context"] = ActivityPubRenderer.ActivityStreams, ["id"] = uri, ["type"] = "Tombstone", ["formerType"] = "QuoteAuthorization" }.ToJsonString(), + ContentType = ActivityContentType, + StatusCode = StatusCodes.Status410Gone + }; + return Activity(new JsonObject + { + ["@context"] = ActivityPubRenderer.Context(), + ["id"] = uri, + ["type"] = "QuoteAuthorization", + ["attributedTo"] = author.Uri, + ["interactingObject"] = licence.QuotingObjectURI, + ["interactionTarget"] = quoted.ObjectURI + }); + } + [HttpGet, Route("{actor}/grunts/{activityId}")] public async Task Grunt(string actor, string activityId, CancellationToken token) { diff --git a/PrivaPub/Federation/Inbox/Handlers/QuoteRequestHandler.cs b/PrivaPub/Federation/Inbox/Handlers/QuoteRequestHandler.cs new file mode 100644 index 0000000..4ec0c96 --- /dev/null +++ b/PrivaPub/Federation/Inbox/Handlers/QuoteRequestHandler.cs @@ -0,0 +1,21 @@ +using PrivaPub.Domain.Statuses; +using PrivaPub.Models.User; + +using System.Text.Json.Nodes; + +namespace PrivaPub.Federation.Inbox.Handlers +{ + public class QuoteRequestHandler : IActivityHandler + { + readonly IQuoteService _quotes; + + public QuoteRequestHandler(IQuoteService quotes) + { + _quotes = quotes; + } + + public string Type => "QuoteRequest"; + + public Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) => _quotes.ReceiveRequest(activity, actor, token); + } +} diff --git a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs index 86b12a9..ae16543 100644 --- a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs +++ b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs @@ -43,11 +43,18 @@ namespace PrivaPub.Federation.Rendering ["quoteAuthorization"] = new JsonObject { ["@id"] = "https://w3id.org/fep/044f#quoteAuthorization", ["@type"] = "@id" }, ["QuoteRequest"] = "https://w3id.org/fep/044f#QuoteRequest", ["QuoteAuthorization"] = "https://w3id.org/fep/044f#QuoteAuthorization", + ["interactingObject"] = new JsonObject { ["@id"] = "gts:interactingObject", ["@type"] = "@id" }, + ["interactionTarget"] = new JsonObject { ["@id"] = "gts:interactionTarget", ["@type"] = "@id" }, ["misskey"] = "https://misskey-hub.net/ns#", ["_misskey_quote"] = "misskey:_misskey_quote", ["fedibird"] = "http://fedibird.com/ns#", ["quoteUri"] = "fedibird:quoteUri", ["quoteUrl"] = "as:quoteUrl", + ["gts"] = "https://gotosocial.org/ns#", + ["interactionPolicy"] = new JsonObject { ["@id"] = "gts:interactionPolicy", ["@type"] = "@id" }, + ["canQuote"] = new JsonObject { ["@id"] = "gts:canQuote", ["@type"] = "@id" }, + ["automaticApproval"] = new JsonObject { ["@id"] = "gts:automaticApproval", ["@type"] = "@id" }, + ["manualApproval"] = new JsonObject { ["@id"] = "gts:manualApproval", ["@type"] = "@id" }, ["litepub"] = "http://litepub.social/ns#", ["EmojiReact"] = "litepub:EmojiReact", ["focalPoint"] = new JsonObject { ["@container"] = "@list", ["@id"] = "toot:focalPoint" }, @@ -188,6 +195,9 @@ namespace PrivaPub.Federation.Rendering }; } + public static string QuotableBy(PostEntity post) => + post.Visibility is PostVisibility.Public or PostVisibility.Unlisted ? post.LocalQuotePolicy ?? Models.User.QuotePolicies.Nobody : Models.User.QuotePolicies.Nobody; + static void AddQuote(JsonObject note, PostEntity post, ref string content) { if (string.IsNullOrEmpty(post.QuoteURI)) @@ -262,6 +272,19 @@ namespace PrivaPub.Federation.Rendering .ToArray()) }; AddQuote(note, post, ref content); + if (!post.IsFederatedCopy) + note["interactionPolicy"] = new JsonObject + { + ["canQuote"] = new JsonObject + { + ["automaticApproval"] = new JsonArray((JsonNode)(QuotableBy(post) switch + { + Models.User.QuotePolicies.Public => Objects.Addressing.Public, + Models.User.QuotePolicies.Followers => author.Followers, + _ => author.Uri + })) + } + }; note["content"] = content; if (!string.IsNullOrEmpty(post.Language)) note["contentMap"] = new JsonObject { [post.Language] = content }; diff --git a/PrivaPub/Infrastructure/Data/Indexes.cs b/PrivaPub/Infrastructure/Data/Indexes.cs index 3182aff..3c2ae50 100644 --- a/PrivaPub/Infrastructure/Data/Indexes.cs +++ b/PrivaPub/Infrastructure/Data/Indexes.cs @@ -116,6 +116,7 @@ namespace PrivaPub.Infrastructure.Data await DB.Default.Index().Key(r => r.PostId, KeyType.Ascending).Key(r => r.ActorURI, KeyType.Ascending).Key(r => r.Emoji, KeyType.Ascending) .Option(o => o.Unique = true).CreateAsync(token); await Plain(token, r => r.ActivityURI); + await DB.Default.Index().Key(l => l.PostId, KeyType.Ascending).Key(l => l.QuotingObjectURI, KeyType.Ascending).CreateAsync(token); await Unique(p => p.Url, Builders.Filter.Type(p => p.Url, BsonType.String), token); } diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index 95fa444..7361712 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -78,6 +78,7 @@ namespace PrivaPub.Middleware .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton(services => services.GetRequiredService()) diff --git a/PrivaPub/Models/Federation/QuoteLicence.cs b/PrivaPub/Models/Federation/QuoteLicence.cs new file mode 100644 index 0000000..1b744d3 --- /dev/null +++ b/PrivaPub/Models/Federation/QuoteLicence.cs @@ -0,0 +1,14 @@ +using MongoDB.Entities; + +namespace PrivaPub.Models.Federation +{ + public class QuoteLicence : Entity + { + public string PostId { get; set; }//the quoted local post + public string AuthorAvatarId { get; set; } + public string QuotingObjectURI { get; set; } + public string QuoterActorURI { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? RevokedAt { get; set; } + } +} diff --git a/PrivaPub/Models/Post/Post.cs b/PrivaPub/Models/Post/Post.cs index 8fa2b35..823959f 100644 --- a/PrivaPub/Models/Post/Post.cs +++ b/PrivaPub/Models/Post/Post.cs @@ -60,7 +60,8 @@ namespace PrivaPub.Models.Post public string QuotedPostId { get; set; }//our copy of the quoted post, once fetched public QuoteState QuoteState { get; set; } public string QuoteAuthorizationURI { get; set; }//FEP-044f: the quoted author's stamp - public bool QuoteByConsent { get; set; }//our quote of a post whose server asks for consent (FEP-044f), so `quote` is sent + public bool QuoteByConsent { get; set; } + public string LocalQuotePolicy { get; set; }//who may quote this local post: QuotePolicies.Public, Followers or Nobody//our quote of a post whose server asks for consent (FEP-044f), so `quote` is sent public int QuotesCount { get; set; } [BsonIgnoreIfNull] public InteractionRule QuotePolicy { get; set; }//a remote post's interactionPolicy.canQuote; null when it states none diff --git a/PrivaPub/Models/User/Avatar.cs b/PrivaPub/Models/User/Avatar.cs index b80281d..73aab76 100644 --- a/PrivaPub/Models/User/Avatar.cs +++ b/PrivaPub/Models/User/Avatar.cs @@ -110,6 +110,16 @@ namespace PrivaPub.Models.User public string DefaultVisibility { get; set; } = "public"; public bool DefaultSensitive { get; set; } public string DefaultLanguage { get; set; } + public string QuotePolicy { get; set; } = QuotePolicies.Public;//owner decision: anyone may quote a public post, automatically + } + + public static class QuotePolicies + { + public const string Public = "public"; + public const string Followers = "followers"; + public const string Nobody = "nobody"; + + public static bool IsKnown(string value) => value is Public or Followers or Nobody; } public enum AvatarServer diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3049812..f8c48c2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -11,7 +11,7 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati - [x] P3 Social features: v1.5.0, deployed 2026-10-01; media, proxy, blocks, mutes, bookmarks, pins and reports verified by tests and locally (no upload on production yet) - [x] P4 Groups and privacy features: v1.6.0, deployed 2026-10-01; communities, circles and local-only located posts verified by tests. v1.6.1 adds the pasture (`tools/pasture/`): live interop with GoToSocial 0.22.1 passes all 25 checks, three runs in a row. Lemmy and a live Mastodon circle member are not run yet; the pasture has GoToSocial only - [x] P5 Lose nothing: v1.7.0 to v1.9.1, deployed 2026-10-01; parsing, typed details, provenance, downvotes, tombstones, federated blocks, all checked live against GoToSocial. Book reviews and forum threads keep their raw form only (typed in P7 and P8) -- [ ] P6 Emoji, polls, quotes, reactions, cards, players +- [x] P6 Emoji, polls, quotes, reactions, cards, players: v1.10.0 to v1.15.0, deployed 2026-10-01; polls, link cards, ranged video streaming and quote policies checked live against GoToSocial - [ ] P7 Threads, communities, moderation, the social graph - [ ] P8 Signatures, discovery, the long tail @@ -128,6 +128,8 @@ and circles (see Owner decisions). | PeerTube views | **Never sent.** The remote video file is still downloaded through our proxy when it is played; that cannot be avoided without pre-downloading video. | | Bluesky bridging (Bridgy Fed) | **Allowed per persona**, with a clear warning that the posts become far more widely copied. Leaving the bridge must work, through a federated `Block` sent to the bridge. Persona creation dates are moved back by a random number of days, so personas created the same day no longer share a date. | | Reactions and votes | **Public, with a one-time notice.** The client tells each persona once, before its first reaction or vote, that these are public and visible as that persona. | +| Who may quote a persona (default) | **Anyone, automatically**, as on Mastodon, for public and unlisted posts only. Each persona can change its default (followers only, or nobody) and each post can be changed; a granted quote can be revoked. Followers-only posts and DMs can never be quoted. | +| Quote permission address | `/peasants/{name}/parrot-licences/{id}` | ## Libraries (researched; no maintained .NET ActivityPub library exists, so Letterbook and Iceshrimp.NET both wrote their own) @@ -171,6 +173,7 @@ were agreed on 2026-10-01. | Group members | `/peasants/{group}/flock` | *new* | | Group moderators | `/peasants/{group}/wardens` | *new* | | DM conversation context | `/peasants/{name}/whispers/{id}` | *new*, replaces `/conversations/{id}` | +| Quote permission (FEP-044f stamp) | `/peasants/{name}/parrot-licences/{id}` | chosen by the owner, 2026-10-01 | Routes outside the actor namespace stay conventional, because other software looks for them by name: - `/.well-known/*` and `/nodeinfo/*`; @@ -456,8 +459,9 @@ it, raw where it doesn't. - read every key; verify `QuoteAuthorization` on all of its fields; handle revocation (done in v1.13.0); - personas quote others: `quoted_status_id`, `QuoteRequest`, `Accept{result}` verified, `Update` with `quoteAuthorization`; legacy quotes for posts that state no policy (done in v1.13.0); `api_versions.mastodon = 7`; - - still open: be quotable (`canQuote` on our posts, answer `QuoteRequest`, serve and revoke stamps). It needs a themed - route name for stamps, agreed with the owner, and a default quote policy. + - be quotable (done in v1.15.0): `canQuote` on our posts (owner's default: anyone, automatically), `QuoteRequest` + answered with a parrot-licence or `Reject`, licences served and revoked; personas set `source[quote_policy]`, posts + `quote_approval_policy`; `POST /statuses/:id/quotes/:quoting_id/revoke`, `PUT /statuses/:id/interaction_policy`. - **Emoji reactions** in all three inbound forms, plus outbound `EmojiReact`, exposed as `emoji_reactions` (done in v1.11.0; Pleroma's `/api/v1/pleroma/statuses/:id/reactions` endpoints and `pleroma:emoji_reaction` notifications). - **Link cards** (done in v1.12.0):