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/<post id>, 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 06:40:46 +02:00
1 parent 34c0f696af
commit 26dac40720
4 files changed
+72 -2

No files matched your search

@@ -42,6 +42,31 @@ namespace PrivaPub.Tests.Federation
PrivaPub.Models.Statistics.InteractionEvent Processed(string activity) => _harness.Ledger.Of("in").Last(e => e.Activity == activity); PrivaPub.Models.Statistics.InteractionEvent Processed(string activity) => _harness.Ledger.Of("in").Last(e => e.Activity == activity);
// Pixelfed names our post by its page (/@name/<id>, 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<Post>().OneAsync(post.ID, token)).FavouritesCount);
await _harness.Deliver(fan, "/human-centipede", Activity(fan, "Undo", like));
Assert.Equal(0, (await DB.Default.Find<Post>().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] [Fact]
public async Task An_actor_update_refreshes_the_account_from_its_origin_even_when_its_updated_is_older() public async Task An_actor_update_refreshes_the_account_from_its_origin_even_when_its_updated_is_older()
{ {
+1 -1
View File
@@ -71,7 +71,7 @@ namespace PrivaPub.Tests.Support
new BlockHandler(Db, Local) new BlockHandler(Db, Local)
}; };
((AnnounceHandler)Handlers.First(h => h is AnnounceHandler)).Relays = Handlers; ((AnnounceHandler)Handlers.First(h => h is AnnounceHandler)).Relays = Handlers;
Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance, Ledger); Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance, Ledger, Local);
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance); Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
Content = new ContentRenderer(Local, Remote); Content = new ContentRenderer(Local, Remote);
Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }), Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }),
+4 -1
View File
@@ -26,10 +26,12 @@ namespace PrivaPub.Federation.Inbox
readonly IReadOnlyDictionary<string, IActivityHandler> _handlers; readonly IReadOnlyDictionary<string, IActivityHandler> _handlers;
readonly ILogger<InboxProcessor> _logger; readonly ILogger<InboxProcessor> _logger;
readonly IInteractionLedger _ledger; readonly IInteractionLedger _ledger;
readonly ILocalActorService _localActors;
public InboxProcessor(IRemoteActorService remoteActors, IEnumerable<IActivityHandler> handlers, ILogger<InboxProcessor> logger, public InboxProcessor(IRemoteActorService remoteActors, IEnumerable<IActivityHandler> handlers, ILogger<InboxProcessor> logger,
IInteractionLedger ledger = default) IInteractionLedger ledger = default, ILocalActorService localActors = default)
{ {
_localActors = localActors;
_remoteActors = remoteActors; _remoteActors = remoteActors;
_handlers = handlers.ToDictionary(h => h.Type, StringComparer.Ordinal); _handlers = handlers.ToDictionary(h => h.Type, StringComparer.Ordinal);
_logger = logger; _logger = logger;
@@ -65,6 +67,7 @@ namespace PrivaPub.Federation.Inbox
Arrival.Current = arrival; Arrival.Current = arrival;
try try
{ {
await LocalReferences.Canonicalise(activity, _localActors?.BaseAddress, token);
await handler.Handle(activity, actor, token); await handler.Handle(activity, actor, token);
} }
catch (Exception ex) when (ex is not OperationCanceledException) catch (Exception ex) when (ex is not OperationCanceledException)
@@ -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/<post id>, 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<string>(out var uri) || PostId(uri, baseAddress) is not { } id)
return;
var post = await DB.Default.Find<PostEntity>().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;
}
}