An id reused for another activity is not a copy

Friendica's activity ids are uniqid(): a short prefix and the microsecond. Two of its processes answering two follows at
once gave both Accepts one id, and PrivaPub, queueing each inbox activity once per id, dropped the second as a copy:
that follow stayed pending on our side while Friendica counted the persona as a follower (seen in the town's Friendica
pair). An id that comes back carrying another type, actor or object is now queued apart; a true copy is still dropped.

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 09:52:02 +02:00
1 parent 7eb7c017a5
commit e485f7bd47
5 files changed
+64 -4

No files matched your search

+22 -2
View File
@@ -191,14 +191,34 @@ namespace PrivaPub.Federation.Inbox
// a forwarded copy never stands in for the author's own delivery, which may carry what the copy could not
// (a followers-only reply our instance actor cannot read): each is kept once, apart
var dedupe = activityId == default ? default : (forwardedBy == default ? "inbox|" : "inbox|forwarded|") + activityId;
var queued = await _queue.Enqueue(JobKind.ProcessInbox, JsonSerializer.Serialize(payload),
new Uri(actorUri).Host.ToLowerInvariant(), dedupe, token);
var queuedPayload = JsonSerializer.Serialize(payload);
var host = new Uri(actorUri).Host.ToLowerInvariant();
var queued = await _queue.Enqueue(JobKind.ProcessInbox, queuedPayload, host, dedupe, token);
// one id its sender gave two activities is no duplicate (Friendica's ids are uniqid(), a prefix and the
// microsecond, which two of its processes answering at once share): what each carries tells them apart, and a
// true copy carries the same
if (!queued && dedupe != default && CarriesOther(await _queue.Payload(dedupe, token), activity))
queued = await _queue.Enqueue(JobKind.ProcessInbox, queuedPayload, host, $"{dedupe}|{Digest(activity)}", token);
_logger.LogInformation("Inbox {Recipient}: {Type} from {Actor} queued", recipient?.Handle ?? "shared", type, actorUri);
return new(StatusCodes.Status202Accepted, Reason: !queued ? "duplicate" : forwardedBy != default ? "forwarded" : "queued");
}
static string HostOf(string uri) => Uri.TryCreate(uri, UriKind.Absolute, out var parsed) ? parsed.Host.ToLowerInvariant() : default;
static bool CarriesOther(string earlierPayload, JsonNode activity)
{
if (earlierPayload == default)
return false;
var earlier = JsonNode.Parse(JsonSerializer.Deserialize<InboxPayload>(earlierPayload).Activity);
return Value(earlier, "type") != Value(activity, "type") || Id(earlier["actor"]) != Id(activity["actor"])
|| earlier["object"]?.ToJsonString() != activity["object"]?.ToJsonString();
}
// what an activity carries, short: its type, actor and object
static string Digest(JsonNode activity) =>
Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(
$"{Value(activity, "type")}\n{Id(activity["actor"])}\n{activity["object"]?.ToJsonString()}")))[..16].ToLowerInvariant();
async Task<InboxResult> ShapeProblem(string type, JsonNode activity, string actorUri, CancellationToken token)
{
var activityId = Id(activity);