From 08acdc05031841b86799249fbb8640a27dbdea15 Mon Sep 17 00:00:00 2001 From: thepra Date: Sun, 4 Oct 2026 23:42:23 +0200 Subject: [PATCH] Personas a remote post is addressed to see it, named or not A remote post decided who here may see it by its Mention tags alone. A followers-only post addressed in to or cc to a persona without naming it (Akkoma's to[], or a GoToSocial edit that took the @name out while the post stayed addressed to the persona) was hidden from that persona. Such personas are now kept as silent mentions, as Mastodon does: they see the post and it reaches their home, and no list shows them as mentioned. An edit never narrows who a post was for. The GoToSocial note that showed it is the first captured fixture (Fixtures/gotosocial), and parses with its lone tag, to and cc. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- .../Federation/InboxScenarioTests.cs | 22 +++++++++++++++++ PrivaPub.Tests/Federation/NoteParserTests.cs | 13 ++++++++++ .../followers-only-reply-one-tag.json | 1 + .../Api/Mastodon/Mappers/MastodonMapper.cs | 2 +- PrivaPub/Federation/Inbox/RemoteEdits.cs | 8 ++----- PrivaPub/Federation/Inbox/RemotePosts.cs | 24 ++++++++++++++++--- PrivaPub/Models/Post/Post.cs | 2 ++ 7 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 PrivaPub.Tests/Fixtures/gotosocial/followers-only-reply-one-tag.json diff --git a/PrivaPub.Tests/Federation/InboxScenarioTests.cs b/PrivaPub.Tests/Federation/InboxScenarioTests.cs index 434d6a6..8794393 100644 --- a/PrivaPub.Tests/Federation/InboxScenarioTests.cs +++ b/PrivaPub.Tests/Federation/InboxScenarioTests.cs @@ -263,6 +263,28 @@ namespace PrivaPub.Tests.Federation Assert.Equal(alice.Id, mention.AccountId); } + // GoToSocial kept a persona in cc after an edit took the @name out, and Akkoma addresses with to[] alone: the persona it + // is addressed to sees a followers-only post as a silent mention, which no list shows as a mention (found by the town) + [Fact] + public async Task A_followers_only_post_addressed_to_a_persona_without_naming_it_is_a_silent_mention() + { + var token = TestContext.Current.CancellationToken; + var alice = await LocalAvatar("alice"); + var bob = new RemoteActor(_peer, "bob"); + var create = PublicCreate(bob, default, alice.Uri); + create["object"]!["to"] = bob.Id + "/followers"; + create["object"]!["cc"] = alice.Uri; + + await Deliver(bob, "/human-centipede", create); + + var post = await DB.Default.Find().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token); + Assert.Equal(PostVisibility.FollowersOnly, post.Visibility); + var mention = Assert.Single(post.Mentions); + Assert.True(mention.Silent); + Assert.Equal(alice.Id, mention.AccountId); + Assert.True(await PrivaPub.Domain.Privacy.VisibilityPolicy.CanSee(post, alice.Id, token)); + } + [Fact] public async Task A_suspended_domain_is_dropped_before_its_key_is_fetched() { diff --git a/PrivaPub.Tests/Federation/NoteParserTests.cs b/PrivaPub.Tests/Federation/NoteParserTests.cs index 3cc11af..f77bf60 100644 --- a/PrivaPub.Tests/Federation/NoteParserTests.cs +++ b/PrivaPub.Tests/Federation/NoteParserTests.cs @@ -57,6 +57,19 @@ namespace PrivaPub.Tests.Federation Assert.Null(note.Updated); } + // GoToSocial writes a lone tag, and lone to and cc, as plain values instead of arrays (captured by the town) + [Fact] + public void Reads_a_gotosocial_reply_whose_only_tag_and_audience_are_single_values() + { + var note = NoteParser.Parse(JsonNode.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "gotosocial", + "followers-only-reply-one-tag.json")))); + + var mention = Assert.Single(note.Mentions); + Assert.Equal("https://privapub.test/peasants/flor_pp98", mention.ActorURI); + Assert.Equal(["https://gts.test/users/ines_gt59/followers"], note.To); + Assert.Equal(["https://privapub.test/peasants/flor_pp98"], note.Cc); + } + [Fact] public void Reads_a_lemmy_page_with_a_title() { diff --git a/PrivaPub.Tests/Fixtures/gotosocial/followers-only-reply-one-tag.json b/PrivaPub.Tests/Fixtures/gotosocial/followers-only-reply-one-tag.json new file mode 100644 index 0000000..b304551 --- /dev/null +++ b/PrivaPub.Tests/Fixtures/gotosocial/followers-only-reply-one-tag.json @@ -0,0 +1 @@ +{"attributedTo":"https://gts.test/users/ines_gt59","cc":"https://privapub.test/peasants/flor_pp98","content":"\u003Cp\u003E\u003Cspan class=\u0022h-card\u0022\u003E\u003Ca href=\u0022https://privapub.test/@flor_pp98\u0022 class=\u0022u-url mention\u0022 rel=\u0022nofollow noreferrer noopener\u0022 target=\u0022_blank\u0022\u003E@\u003Cspan\u003Eflor_pp98\u003C/span\u003E\u003C/a\u003E\u003C/span\u003E bicycle the window bicycle tomorrow festival (p111)\u003C/p\u003E","contentMap":{"en":"\u003Cp\u003E\u003Cspan class=\u0022h-card\u0022\u003E\u003Ca href=\u0022https://privapub.test/@flor_pp98\u0022 class=\u0022u-url mention\u0022 rel=\u0022nofollow noreferrer noopener\u0022 target=\u0022_blank\u0022\u003E@\u003Cspan\u003Eflor_pp98\u003C/span\u003E\u003C/a\u003E\u003C/span\u003E bicycle the window bicycle tomorrow festival (p111)\u003C/p\u003E"},"id":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY","inReplyTo":"https://privapub.test/peasants/flor_pp98/scribbles/6ac2beaf092dc0af8ce6035e","interactionPolicy":{"canAnnounce":{"automaticApproval":["https://gts.test/users/ines_gt59"]},"canLike":{"automaticApproval":["https://gts.test/users/ines_gt59","https://gts.test/users/ines_gt59/followers","https://privapub.test/peasants/flor_pp98"]},"canQuote":{"automaticApproval":["https://gts.test/users/ines_gt59"]},"canReply":{"automaticApproval":["https://gts.test/users/ines_gt59","https://gts.test/users/ines_gt59/followers","https://privapub.test/peasants/flor_pp98"]}},"published":"2026-10-04T21:02:31Z","replies":{"first":{"id":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY/replies?page=true","next":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY/replies?page=true\u0026only_other_accounts=false","partOf":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY/replies","type":"CollectionPage"},"id":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY/replies","type":"Collection"},"tag":{"href":"https://privapub.test/peasants/flor_pp98","name":"@flor_pp98@privapub.test","type":"Mention"},"to":"https://gts.test/users/ines_gt59/followers","type":"Note","url":"https://gts.test/@ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY"} diff --git a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs index a82b810..0496f57 100644 --- a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs +++ b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs @@ -232,7 +232,7 @@ namespace PrivaPub.Api.Mastodon.Mappers Privapub = Extension(post), EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new(), Pleroma = new PleromaStatus { EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new() }, - Mentions = post.Mentions.Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(), + Mentions = post.Mentions.Where(m => !m.Silent).Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(), Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList() }; return status; diff --git a/PrivaPub/Federation/Inbox/RemoteEdits.cs b/PrivaPub/Federation/Inbox/RemoteEdits.cs index 7a221c9..8fba836 100644 --- a/PrivaPub/Federation/Inbox/RemoteEdits.cs +++ b/PrivaPub/Federation/Inbox/RemoteEdits.cs @@ -53,12 +53,8 @@ namespace PrivaPub.Federation.Inbox if (post.Revisions.Count > MaxRevisions) post.Revisions.RemoveRange(0, post.Revisions.Count - MaxRevisions); - var mentions = new List(); - foreach (var mention in note.Mentions) - { - var local = await localActors.FindByUri(mention.ActorURI, token); - mentions.Add(new PostMention { ActorURI = mention.ActorURI, Handle = mention.Handle, IsLocal = local != default, AccountId = local?.Id }); - } + //an edit never narrows who a post was for: what it was addressed to before still holds + var mentions = await RemotePosts.Mentions(note, post.To.Concat(post.Cc).Concat(note.To).Concat(note.Cc), localActors, token); post.Title = note.Title; post.SpoilerText = note.SpoilerText; diff --git a/PrivaPub/Federation/Inbox/RemotePosts.cs b/PrivaPub/Federation/Inbox/RemotePosts.cs index e8f580b..87b8bb1 100644 --- a/PrivaPub/Federation/Inbox/RemotePosts.cs +++ b/PrivaPub/Federation/Inbox/RemotePosts.cs @@ -55,15 +55,33 @@ namespace PrivaPub.Federation.Inbox _queue = queue; } - public async Task Build(NoteDocument note, ForeignAvatar author, PostVisibility visibility, IReadOnlyList to, - IReadOnlyList cc, PostEntity parent, CancellationToken token) + // The accounts a remote post names (its Mention tags), plus our personas it is addressed to without naming them: Mastodon + // keeps those as silent mentions, and Akkoma's to[], or an edit that dropped the @name, address someone that way. A + // silent mention lets the persona see the post and puts it in its home, and is never shown as a mention. + public static async Task> Mentions(NoteDocument note, IEnumerable addressed, ILocalActorService localActors, + CancellationToken token) { var mentions = new List(); foreach (var mention in note.Mentions) { - var local = await _localActors.FindByUri(mention.ActorURI, token); + var local = await localActors.FindByUri(mention.ActorURI, token); mentions.Add(new PostMention { ActorURI = mention.ActorURI, Handle = mention.Handle, IsLocal = local != default, AccountId = local?.Id }); } + foreach (var uri in addressed.Where(a => !string.IsNullOrEmpty(a) && !Addressing.IsPublic(a)).Distinct(StringComparer.Ordinal)) + { + if (mentions.Any(m => m.ActorURI == uri)) + continue; + var local = await localActors.FindByUri(uri, token); + if (local is { Kind: LocalActorKind.Person }) + mentions.Add(new PostMention { ActorURI = uri, Handle = "@" + local.Handle, IsLocal = true, AccountId = local.Id, Silent = true }); + } + return mentions; + } + + public async Task Build(NoteDocument note, ForeignAvatar author, PostVisibility visibility, IReadOnlyList to, + IReadOnlyList cc, PostEntity parent, CancellationToken token) + { + var mentions = await Mentions(note, to.Concat(cc), _localActors, token); return new PostEntity { diff --git a/PrivaPub/Models/Post/Post.cs b/PrivaPub/Models/Post/Post.cs index 937e6a6..4b9a598 100644 --- a/PrivaPub/Models/Post/Post.cs +++ b/PrivaPub/Models/Post/Post.cs @@ -111,6 +111,8 @@ namespace PrivaPub.Models.Post public string AccountId { get; set; } public string Handle { get; set; } public bool IsLocal { get; set; } + //addressed in to or cc without a Mention tag (Mastodon's silent mention): may see the post, but the post does not name it + public bool Silent { get; set; } } public class PostRevision