diff --git a/CLAUDE.md b/CLAUDE.md index f48b557..b40ab45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,7 +178,8 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too. - an activity is queued once per id, but an id that comes back carrying another type, actor or object is a second activity, not a copy (Friendica's ids are `uniqid()`, which two of its processes can share), and is queued apart; - the activity's `id`, and any object it creates, updates or deletes, is on the actor's origin; a cross-origin - object is refetched from its own origin. + object is refetched from its own origin. One attributed to another account of the actor's server (Mobilizon's + organiser and the group's event) is taken as that server has it, and deleted once it answers 404 or 410. 5. **Status codes:** - bad or missing signature: **401**; - malformed or forged body: **400**; diff --git a/FEDERATION.md b/FEDERATION.md index 9459d97..ba6cc8f 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -225,6 +225,10 @@ Posts with a location (shown to nearby users of this server) never leave the ser - **Origins.** An actor document is accepted only from the address it names as its `id`. A key only if its actor lists it with `owner` set to the actor and on the actor's origin. An activity's `id`, and any object it creates, updates or deletes, must be on its actor's origin. An embedded object from another origin is fetched from that origin. +- **Another account of the same server.** An object attributed to another account of the actor's own server (Mobilizon's + organiser creates, edits and deletes the group's event) is that server's to vouch for: a creation or an edit is + taken as the server has the object, a deletion once it answers 404 or 410. Attributed to an account elsewhere, it is + refused (400). - **Forwarded activities** (ActivityPub's inbox forwarding: a thread's server passing on the replies in it, signed with its own key) are answered 202 and believed only as far as their origin vouches for them: a created or edited post is read again from its author's server, a deletion of a public or unlisted post is applied once that server answers diff --git a/PrivaPub.Tests/Federation/InboxGapTests.cs b/PrivaPub.Tests/Federation/InboxGapTests.cs index 56b2fe9..7d74069 100644 --- a/PrivaPub.Tests/Federation/InboxGapTests.cs +++ b/PrivaPub.Tests/Federation/InboxGapTests.cs @@ -116,6 +116,50 @@ namespace PrivaPub.Tests.Federation Assert.Equal(FollowState.Accepted, (await Of(jun)).State); } + // Mobilizon: the organiser creates, edits and deletes an event attributed to the group, which announces it. They used + // to be refused as misattributed (400), so the edit was lost and the deletion left the event in place + [Fact] + public async Task An_event_its_organiser_edits_and_deletes_for_a_group_on_the_same_server_follows_that_server() + { + var token = TestContext.Current.CancellationToken; + var (_, alice) = await _harness.Persona("alice"); + var group = new RemoteActor(_harness.Peer, "group", type: "Group"); + var organiser = new RemoteActor(_harness.Peer, "organiser"); + await Follows(alice.Id, group); + var path = $"/events/{Guid.NewGuid():N}"; + JsonObject Event(string name) => new() + { + ["id"] = _harness.Peer.A + path, ["type"] = "Event", ["name"] = name, ["content"] = "

bring bread

", + ["attributedTo"] = group.Id, ["actor"] = organiser.Id, ["startTime"] = "2026-10-08T18:00:00Z", + ["to"] = new JsonArray(PrivaPub.Federation.Objects.Addressing.Public), ["cc"] = new JsonArray(group.Id + "/followers") + }; + _harness.Peer.Serve(path, Event("A picnic").ToJsonString()); + Task Stored() => DB.Default.Find().Match(p => p.ObjectURI == _harness.Peer.A + path).ExecuteFirstAsync(token); + + var created = await _harness.Deliver(organiser, "/human-centipede", Create(organiser, Event("A picnic"))); + Assert.Equal(202, created.StatusCode); + Assert.Equal(group.Id, (await Stored()).ActorURI); + + var moved = Event("A picnic, moved indoors"); + moved["updated"] = DateTime.UtcNow.AddMinutes(1).ToString("O"); + _harness.Peer.Serve(path, moved.ToJsonString()); + await _harness.Deliver(organiser, "/human-centipede", Activity(organiser, "Update", Event("what the activity claims"))); + Assert.Equal("A picnic, moved indoors", (await Stored()).Title); + + await _harness.Deliver(organiser, "/human-centipede", Activity(organiser, "Delete", JsonValue.Create(_harness.Peer.A + path)!)); + Assert.NotNull(await Stored()); + _harness.Peer.Answer(path, 410); + await _harness.Deliver(organiser, "/human-centipede", Activity(organiser, "Delete", JsonValue.Create(_harness.Peer.A + path)!)); + Assert.Null(await Stored()); + + // attributed to someone of another server, it is still refused + var elsewhere = new RemoteActor(_harness.Peer, "elsewhere", _harness.Peer.B); + var claimed = Event("Someone else's"); + claimed["id"] = _harness.Peer.A + $"/events/{Guid.NewGuid():N}"; + claimed["attributedTo"] = elsewhere.Id; + Assert.Equal(400, (await _harness.Deliver(organiser, "/human-centipede", Create(organiser, claimed))).StatusCode); + } + [Fact] public async Task A_like_naming_a_post_by_its_page_counts_and_its_undo_too() { diff --git a/PrivaPub.Tests/Federation/InboxScenarioTests.cs b/PrivaPub.Tests/Federation/InboxScenarioTests.cs index 8794393..b60a455 100644 --- a/PrivaPub.Tests/Federation/InboxScenarioTests.cs +++ b/PrivaPub.Tests/Federation/InboxScenarioTests.cs @@ -171,12 +171,19 @@ namespace PrivaPub.Tests.Federation var token = TestContext.Current.CancellationToken; var alice = await LocalAvatar("alice"); var mallory = new RemoteActor(_peer, "mallory"); - var victim = new RemoteActor(_peer, "victim"); + var victim = new RemoteActor(_peer, "victim", _peer.B); var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, attributedTo: victim.Id)); Assert.Equal(400, result.StatusCode); Assert.False(await DB.Default.Find().Match(p => p.ActorURI == victim.Id).ExecuteAnyAsync(token)); + + // a colleague on mallory's own server is that server's to vouch for: believed only as the server has it, and + // it has no such note + var colleague = new RemoteActor(_peer, "colleague"); + result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, attributedTo: colleague.Id)); + Assert.Equal(202, result.StatusCode); + Assert.False(await DB.Default.Find().Match(p => p.ActorURI == colleague.Id).ExecuteAnyAsync(token)); } [Fact] diff --git a/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs b/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs index 0ca7687..8e92031 100644 --- a/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs @@ -72,6 +72,23 @@ namespace PrivaPub.Federation.Inbox.Handlers Arrival.Drop("fetch-failed"); } var note = NoteParser.Parse(node); + // made by another account of the author's own server (Mobilizon's organiser creates the group's event): taken + // as that server has it, under the account it is attributed to + if (note != default && note.AttributedTo != author.ActorURI && Origin.Same(note.AttributedTo, author.ActorURI)) + { + if (!refetched) + { + using var fetched = await _remoteActors.FetchObject(note.Id, token); + note = fetched == default ? default : NoteParser.Parse(JsonNode.Parse(fetched.Root.GetRawText())); + refetched = true; + } + author = note == default ? default : await _remoteActors.GetActor(note.AttributedTo, refresh: false, token); + if (author == default) + { + Arrival.Drop(note == default ? "fetch-failed" : "author-unavailable"); + return; + } + } if (note == default || note.AttributedTo != author.ActorURI) { Arrival.Drop(note == default ? "unparseable" : "misattributed"); diff --git a/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs b/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs index 96292d2..6b041e5 100644 --- a/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs @@ -77,6 +77,11 @@ namespace PrivaPub.Federation.Inbox.Handlers await RemoteDeletes.Tombstone(objectUri, token); var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token); + // deleted by another account of its author's server (Mobilizon's organiser deletes the group's event): once + // that server says it is gone + if (post == default && await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token) is { } held + && held.ActorURI != actor.ActorURI && Origin.Same(held.ActorURI, actor.ActorURI) && await _remoteActors.IsGone(objectUri, token)) + post = held; if (post == default) { Arrival.Accept("tombstone-only"); diff --git a/PrivaPub/Federation/Inbox/Handlers/UpdateHandler.cs b/PrivaPub/Federation/Inbox/Handlers/UpdateHandler.cs index 871faaf..1e6540a 100644 --- a/PrivaPub/Federation/Inbox/Handlers/UpdateHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/UpdateHandler.cs @@ -55,6 +55,25 @@ namespace PrivaPub.Federation.Inbox.Handlers return; } var note = NoteParser.Parse(inner); + // edited by another account of the author's own server (Mobilizon's organiser edits the group's event): applied + // as that server has it now + if (note != default && note.AttributedTo != actor.ActorURI && Origin.Same(note.AttributedTo, actor.ActorURI)) + { + using var fetched = await _remoteActors.FetchObject(note.Id, token); + var current = fetched == default ? default : NoteParser.Parse(JsonNode.Parse(fetched.Root.GetRawText())); + if (current == default || current.Id != note.Id || current.AttributedTo != note.AttributedTo) + { + Arrival.Drop("fetch-failed"); + return; + } + note = current; + actor = await _remoteActors.GetActor(note.AttributedTo, refresh: false, token); + if (actor == default) + { + Arrival.Drop("author-unavailable"); + return; + } + } if (note == default || note.AttributedTo != actor.ActorURI) { Arrival.Drop(note == default ? "unparseable" : "misattributed"); diff --git a/PrivaPub/Federation/Inbox/InboxReceiver.cs b/PrivaPub/Federation/Inbox/InboxReceiver.cs index 30c76f5..3811105 100644 --- a/PrivaPub/Federation/Inbox/InboxReceiver.cs +++ b/PrivaPub/Federation/Inbox/InboxReceiver.cs @@ -235,8 +235,10 @@ namespace PrivaPub.Federation.Inbox break; case "Undo" when inner is JsonObject && Id(inner["actor"]) != actorUri: return new(StatusCodes.Status400BadRequest, "an actor can only undo its own activities", Reason: "undo-foreign"); + // (attributed to another account of the actor's own server, as Mobilizon's organiser creates a group's + // event: the handlers read it from that server, Origin.Same) case "Create" or "Update" when inner is JsonObject && Origin.Same(Id(inner), actorUri) - && inner["attributedTo"] != default && Id(inner["attributedTo"]) != actorUri + && inner["attributedTo"] != default && !Origin.Same(Id(inner["attributedTo"]), actorUri) && Value(inner, "type") is not ("Person" or "Service" or "Application" or "Group" or "Organization"): return new(StatusCodes.Status400BadRequest, "the object is not attributed to the actor", Reason: "misattributed"); }