From 864dae446975b54407c341da535ed06db5a13b38 Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 02:57:33 +0200 Subject: [PATCH] A boost reaches the author's server once A boost and its undo went to the author's personal inbox and to the shared inbox of its server's followers: two copies at once, which Misskey counted twice as it processed them and undid once (seen in decePub's e2e boost on Misskey). The author's server now gets them through its shared inbox, as Mastodon sends them; a like still goes to the author's own inbox. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- PrivaPub.Tests/Http/MastodonStatusesTests.cs | 25 ++++++++++++++++++++ PrivaPub/Domain/Statuses/StatusService.cs | 11 +++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/PrivaPub.Tests/Http/MastodonStatusesTests.cs b/PrivaPub.Tests/Http/MastodonStatusesTests.cs index 66bdba4..a8222f3 100644 --- a/PrivaPub.Tests/Http/MastodonStatusesTests.cs +++ b/PrivaPub.Tests/Http/MastodonStatusesTests.cs @@ -1,3 +1,6 @@ +using MongoDB.Entities; + +using PrivaPub.Models.User; using PrivaPub.Tests.Support; using PrivaPub.Tests.Support.Host; @@ -385,6 +388,28 @@ namespace PrivaPub.Tests.Http Assert.Equal(1, status.Number("reblogs_count")); } + [Fact] + public async Task A_boost_reaches_the_authors_server_once_through_its_shared_inbox_and_a_like_its_own_inbox() + { + var since = DateTime.UtcNow.AddSeconds(-1); + var alice = await _host.Mastodon("alice"); + var bob = new RemoteActor(_peer, "bob"); + var remote = await _host.PublicPostFrom(bob, alice); + await DB.Default.Update().Match(f => f.ActorURI == bob.Id).Modify(f => f.SharedInboxURL, bob.SharedInbox) + .ExecuteAsync(TestContext.Current.CancellationToken); + + (await alice.Client.Post($"/api/v1/statuses/{remote.ID}/reblog")).Ok(); + (await alice.Client.Post($"/api/v1/statuses/{remote.ID}/unreblog")).Ok(); + (await alice.Client.Post($"/api/v1/statuses/{remote.ID}/favourite")).Ok(); + + var shared = await Jobs.Deliveries(bob.SharedInbox, since, TestContext.Current.CancellationToken); + Assert.Single(shared, d => d.Type() == "Announce"); + Assert.Single(shared, d => d.Type() == "Undo"); + var own = await bob.Delivered(since); + Assert.DoesNotContain(own, d => d.Type() is "Announce" or "Undo"); + Assert.Single(own, d => d.Type() == "Like"); + } + [Fact] public async Task Bookmarks_are_kept_listed_and_removed() { diff --git a/PrivaPub/Domain/Statuses/StatusService.cs b/PrivaPub/Domain/Statuses/StatusService.cs index fb2f36e..47c8c61 100644 --- a/PrivaPub/Domain/Statuses/StatusService.cs +++ b/PrivaPub/Domain/Statuses/StatusService.cs @@ -487,7 +487,7 @@ namespace PrivaPub.Domain.Statuses if (!original.IsFederatedCopy) await Notifications.Add(original.GroupUserId, NotificationType.Reblog, me.Id, me.Uri, original.ID, token); await _fanout.Distribute(reblog, token); - await _delivery.EnqueueToFollowers(me, announce, token, await AuthorInbox(original, token) is { } inbox ? new[] { inbox } : default); + await _delivery.EnqueueToFollowers(me, announce, token, await AuthorInbox(original, token, shared: true) is { } inbox ? new[] { inbox } : default); return new StatusOutcome(reblog); } @@ -497,7 +497,7 @@ namespace PrivaPub.Domain.Statuses await DB.Default.DeleteAsync(e => e.PostId == existing.ID); await DB.Default.Update().MatchID(original.ID).Modify(b => b.Inc(p => p.ReblogsCount, -1)).ExecuteAsync(token); await _delivery.EnqueueToFollowers(me, Undo(me, announce, $"undo-announce-{existing.ID}"), token, - await AuthorInbox(original, token) is { } authorInbox ? new[] { authorInbox } : default); + await AuthorInbox(original, token, shared: true) is { } authorInbox ? new[] { authorInbox } : default); return new StatusOutcome(original); } @@ -595,11 +595,14 @@ namespace PrivaPub.Domain.Statuses return post != default && await VisibilityPolicy.CanSee(post, me.Id, token) ? post : default; } - async Task AuthorInbox(PostEntity post, CancellationToken token) + // shared: what goes to the author's followers too (a boost) goes to its server's shared inbox, so the server gets + // one copy, as Mastodon sends it; two copies at once race in servers that count as they process (Misskey) + async Task AuthorInbox(PostEntity post, CancellationToken token, bool shared = false) { if (!post.IsFederatedCopy || string.IsNullOrEmpty(post.AuthorAccountId)) return default; - return (await _dbEntities.ForeignAvatars.MatchID(post.AuthorAccountId).ExecuteFirstAsync(token))?.InboxURL; + var author = await _dbEntities.ForeignAvatars.MatchID(post.AuthorAccountId).ExecuteFirstAsync(token); + return shared && !string.IsNullOrEmpty(author?.SharedInboxURL) ? author.SharedInboxURL : author?.InboxURL; } async Task MayPost(Models.Group.Group group, LocalActor author, CancellationToken token)