From 26dac4072052d5b662bdca04d9078e8c48723e36 Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 06:40:46 +0200 Subject: [PATCH] A post named by its page is found as by its id Pixelfed names one of our posts by the address of its page (/@name/, the post's url) in its Like, Announce and their Undo, so its likes were dropped as unknown objects. Before an activity is handled, such a reference to a post of ours, as its object or the object of the activity it undoes, is replaced by the post's id. Found by the pasture's new Pixelfed peer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- PrivaPub.Tests/Federation/InboxGapTests.cs | 25 ++++++++++++ PrivaPub.Tests/Support/Harness.cs | 2 +- PrivaPub/Federation/Inbox/InboxProcessor.cs | 5 ++- PrivaPub/Federation/Inbox/LocalReferences.cs | 42 ++++++++++++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 PrivaPub/Federation/Inbox/LocalReferences.cs diff --git a/PrivaPub.Tests/Federation/InboxGapTests.cs b/PrivaPub.Tests/Federation/InboxGapTests.cs index d07ce16..3472120 100644 --- a/PrivaPub.Tests/Federation/InboxGapTests.cs +++ b/PrivaPub.Tests/Federation/InboxGapTests.cs @@ -42,6 +42,31 @@ namespace PrivaPub.Tests.Federation PrivaPub.Models.Statistics.InteractionEvent Processed(string activity) => _harness.Ledger.Of("in").Last(e => e.Activity == activity); + // Pixelfed names our post by its page (/@name/, the post's url) in its Like, Announce and their Undo: the post is + // found the same as by its id + [Fact] + public async Task A_like_naming_a_post_by_its_page_counts_and_its_undo_too() + { + var token = TestContext.Current.CancellationToken; + var (_, alice) = await _harness.Persona("alice"); + var fan = new RemoteActor(_harness.Peer, "fan"); + var post = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "a picture" }, token)).Post; + var page = alice.PostHtmlUrl(post.ID); + Assert.NotEqual(post.ObjectURI, page); + var like = new JsonObject { ["id"] = NewId(fan, "likes"), ["type"] = "Like", ["actor"] = fan.Id, ["object"] = page }; + + await _harness.Deliver(fan, "/human-centipede", like); + Assert.Equal("accepted", Processed("Like").Outcome); + Assert.Equal(1, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); + await _harness.Deliver(fan, "/human-centipede", Activity(fan, "Undo", like)); + Assert.Equal(0, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); + + // a page that is no post of ours stays as it is + var elsewhere = new JsonObject { ["id"] = NewId(fan, "likes"), ["type"] = "Like", ["actor"] = fan.Id, ["object"] = alice.PostHtmlUrl("000000000000000000000000") }; + await _harness.Deliver(fan, "/human-centipede", elsewhere); + Assert.Equal(("dropped", "unknown-object"), (Processed("Like").Outcome, Processed("Like").Reason)); + } + [Fact] public async Task An_actor_update_refreshes_the_account_from_its_origin_even_when_its_updated_is_older() { diff --git a/PrivaPub.Tests/Support/Harness.cs b/PrivaPub.Tests/Support/Harness.cs index 8c181b1..98af285 100644 --- a/PrivaPub.Tests/Support/Harness.cs +++ b/PrivaPub.Tests/Support/Harness.cs @@ -71,7 +71,7 @@ namespace PrivaPub.Tests.Support new BlockHandler(Db, Local) }; ((AnnounceHandler)Handlers.First(h => h is AnnounceHandler)).Relays = Handlers; - Processor = new InboxProcessor(Remote, Handlers, NullLogger.Instance, Ledger); + Processor = new InboxProcessor(Remote, Handlers, NullLogger.Instance, Ledger, Local); Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer(), NullLogger.Instance); Content = new ContentRenderer(Local, Remote); Media = new MediaService(new StaticOptions(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }), diff --git a/PrivaPub/Federation/Inbox/InboxProcessor.cs b/PrivaPub/Federation/Inbox/InboxProcessor.cs index 0951558..21185b2 100644 --- a/PrivaPub/Federation/Inbox/InboxProcessor.cs +++ b/PrivaPub/Federation/Inbox/InboxProcessor.cs @@ -26,10 +26,12 @@ namespace PrivaPub.Federation.Inbox readonly IReadOnlyDictionary _handlers; readonly ILogger _logger; readonly IInteractionLedger _ledger; + readonly ILocalActorService _localActors; public InboxProcessor(IRemoteActorService remoteActors, IEnumerable handlers, ILogger logger, - IInteractionLedger ledger = default) + IInteractionLedger ledger = default, ILocalActorService localActors = default) { + _localActors = localActors; _remoteActors = remoteActors; _handlers = handlers.ToDictionary(h => h.Type, StringComparer.Ordinal); _logger = logger; @@ -65,6 +67,7 @@ namespace PrivaPub.Federation.Inbox Arrival.Current = arrival; try { + await LocalReferences.Canonicalise(activity, _localActors?.BaseAddress, token); await handler.Handle(activity, actor, token); } catch (Exception ex) when (ex is not OperationCanceledException) diff --git a/PrivaPub/Federation/Inbox/LocalReferences.cs b/PrivaPub/Federation/Inbox/LocalReferences.cs new file mode 100644 index 0000000..33c2786 --- /dev/null +++ b/PrivaPub/Federation/Inbox/LocalReferences.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +using MongoDB.Entities; + +using PostEntity = PrivaPub.Models.Post.Post; + +namespace PrivaPub.Federation.Inbox +{ + // Some servers name one of our posts by the address of its page instead of its id (Pixelfed likes /@name/, the + // post's `url`). Before an activity is handled, such a reference to a post of ours, as the activity's object or as the + // object of the activity it undoes, is replaced by the post's id, so that every handler finds the post the same way. + public static partial class LocalReferences + { + [GeneratedRegex("^/@[^/?#]+/([0-9a-f]{24})$")] + private static partial Regex PostPage(); + + public static async Task Canonicalise(JsonNode activity, string baseAddress, CancellationToken token) + { + if (activity is not JsonObject outer || string.IsNullOrEmpty(baseAddress)) + return; + await Replace(outer, baseAddress, token); + if (outer["object"] is JsonObject inner) + await Replace(inner, baseAddress, token); + } + + static async Task Replace(JsonObject node, string baseAddress, CancellationToken token) + { + if (node["object"] is not JsonValue value || !value.TryGetValue(out var uri) || PostId(uri, baseAddress) is not { } id) + return; + var post = await DB.Default.Find().Match(p => p.ID == id && !p.IsFederatedCopy).ExecuteFirstAsync(token); + if (post?.ObjectURI is { } canonical) + node["object"] = canonical; + } + + // the id of the post whose page the address is, on our own site + public static string PostId(string uri, string baseAddress) => + uri.StartsWith(baseAddress + "/@", StringComparison.OrdinalIgnoreCase) && PostPage().Match(uri[baseAddress.Length..]) is { Success: true } page + ? page.Groups[1].Value + : default; + } +}