diff --git a/CLAUDE.md b/CLAUDE.md index 15212d0..4909d4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0 Inbox/ InboxReceiver (verify, queue, 202) → InboxProcessor (job) → Handlers/{Follow,Accept,Reject, Undo,Create,Update,Delete,Like,Announce}; RemotePosts (build, fetch parents, FetchAncestors); RemoteReplies (FetchReplies: a thread's `context`, else `replies` two levels down); - Forwarded (what a thread's server passes on, believed as far as the origin vouches) + Forwarded (what a thread's server passes on, believed as far as the origin vouches); + ReplyRelay (third-party replies to a persona's posts passed on to its followers) Outbox/ OutboxPublisher (who a post goes to), DeliveryService (queues jobs) + DeliveryJobHandler Rendering/ ActivityPubRenderer (Mastodon @context, actors, notes, collections) Domain/ @@ -99,7 +100,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0 Relationships/ RelationshipService (blocks, mutes, account domain blocks; Hidden), ReportService Media/ MediaService (libvips, ffmpeg remux, blurhash), MediaProxy, MediaJanitor Domain/Statuses/ StatusService: publish, edit, remove, favourite, reblog, for a persona (both client APIs use it); - InteractionApprovals: GoToSocial's canReply/canLike/canAnnounce, judged, asked and answered + InteractionApprovals: GoToSocial's canReply/canLike/canAnnounce, judged, asked and answered; + Participations: a persona's Join and Leave of a remote event, and the organiser's answer Api/Mastodon/ Auth/ OpenIddict setup (keys in Mongo), MastodonScopes, TokenController, OAuthPruner Infrastructure/ MastodonController (avatar context, scopes, errors, Link), MastodonParams, MastodonJson, Page @@ -231,6 +233,14 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too. fetched as context. Followers-only is detected by the author's stored `followers` URL. 14. **Home timelines are written, not computed:** every stored or created post goes through `Fanout.Distribute`, and every delete removes its `TimelineEntry` rows. Local deletes are soft (content cleared, 410 Tombstone). +15. **What PrivaPub passes on is exactly what it received** (owner decision 2026-10-05). A public or unlisted reply from + another server to a persona's public, unlisted or followers-only post, and its Update and Delete, go to the + persona's followers as the activity arrived (`Arrival.Raw`, `Federation/Inbox/ReplyRelay.cs`), signed by the + persona for the transport only: never to the replier's own server, never for a local-only or group post. Only an + activity its own actor delivered carries `Raw`, so a forwarded one is never passed on again. +16. **An `Accept` or `Reject` is routed by what it answers:** our Follow (`FollowService`, any `-again-N` resend), an + interaction request (`InteractionApprovals`, with the author's authorization read back from its origin) or a + persona's `Join` (`Participations`, by its `/grunts/join-` id), each only from the origin of what it answers. ## Mastodon client API invariants @@ -552,8 +562,9 @@ tools/pasture/run.sh down # removes e extension), with the pasture's bundle mounted over certifi's and castore's CA files (hackney trusts only those) and the system store, and geocoding pointed at a closed local port. `mobilizon_ctl users.new` makes mzuser (it splits its arguments on spaces); the API is GraphQL (`/api`), signed in by the `login` mutation. An event made through it - without options has its comments closed. `scenarios/mobilizon.sh`, 23 checks: a group and its events (dates, place), - comments both ways, the organiser's edit, closed comments and deletes, a group post, the unfollow. + without options has its comments closed. `scenarios/mobilizon.sh`, 27 checks: a group and its events (dates, place), + alice joining an event (Mobilizon's participant row and its Accept) and leaving it, comments both ways, the + organiser's edit, closed comments and deletes, a group post, the unfollow. - **Gancio (1.28.2):** cisti's image on sqlite in the `pasture-gancio-data` volume, whose `config.json` is written before the first start (without it Gancio waits in its setup wizard), trusting Caddy's CA through `NODE_EXTRA_CA_CERTS`. `gancio users create` makes gcadmin; `gancio settings set enable_resources true` keeps fediverse replies. One diff --git a/FEDERATION.md b/FEDERATION.md index 18dd767..7b38bc8 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -124,6 +124,7 @@ Received: |---|---| | `Follow` | follows an avatar or community; `Accept` is sent unless the community approves members by hand | | `Accept{Follow}`, `Reject{Follow}` | completes or ends a follow an avatar requested | +| `Accept{Join}`, `Reject{Join}` | from the event's server: a persona's participation in the event is accepted or refused | | `Undo{Follow, Like, Announce}` | reverses it | | `Create{Note, Article, Page, Question, Video, Audio, Event, ChatMessage, …}` | stored when a local avatar follows the author, is addressed or mentioned, when it replies to a local post, or when it is addressed to a community the author follows; a public parent is fetched to complete the thread | | `Update{Note}` | replaces the content; the previous version is kept | @@ -131,14 +132,15 @@ Received: | `Like` | counted and notified, on posts the liker could see | | `Dislike` | counted as a downvote (Lemmy, PieFed, Mbin, Friendica); `Undo` takes it back | | `EmojiReact`, `Like` with an emoji `content` | an emoji reaction (FEP-c0e0; Pleroma, Akkoma, Iceshrimp.NET, Misskey, Sharkey), Unicode or a custom emoji from its `tag`; a `Like` whose content is ❤ stays a favourite; `Undo` takes it back | -| `Join` | answered with `Ignore`: PrivaPub hosts no events yet (FEP-8a8e) | +| `Join` | answered with `Ignore`: PrivaPub hosts no events of its own yet (FEP-8a8e) | | `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin | | `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back | | `Flag` | becomes a report for this server's moderators | | `Block` of a persona | the follows between them end, the blocker's posts and notifications are hidden from the persona, nothing of the persona's is addressed to the blocker, and the relationship says `blocked_by`; `Undo{Block}` lifts it | Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll votes, `EmojiReact` and its `Undo`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`, -`Reject{Follow}`, `Like`, `Announce` and their `Undo`, `Flag`. A deleted post answers 410 with a `Tombstone`. +`Reject{Follow}`, `Like`, `Announce` and their `Undo`, `Flag`, `Join` and `Leave` of a remote event, and the replies to a +persona's posts passed on to its followers. A deleted post answers 410 with a `Tombstone`. - **Attachments** are `Document`s with `mediaType`, `name` (alt text), `blurhash`, `focalPoint`, `width` and `height`. Uploaded files have all metadata removed. @@ -146,6 +148,18 @@ Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll vote - **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it followed); an unblock sends `Undo{Block}`. - **Reports** are sent as `Flag` by the instance actor, never by the reporting account. +- **Replies are passed on** (inbox forwarding; owner decision 2026-10-05). A public or unlisted reply from another server + to a persona's public, unlisted or followers-only post goes on to the persona's followers as its author's server sent + and signed it, the way Mastodon forwards it: to every follower's server but the replier's own. Its `Update` and + `Delete` follow the same way. Nothing is passed on for a local-only post, a group's post or a reply that is not public + or unlisted. A server that receives it checks it as it checks any forwarded activity (Mastodon by the LD signature + or by reading the reply from its origin). +- **Events** (owner decision 2026-10-05). A persona joins another server's event with a `Join` and leaves it with a + `Leave`, both sent to the event's organiser only (`…/grunts/join-`, `…/grunts/leave-`). The organiser's + server answers `Accept` (Mobilizon at once for an open event) or `Reject`, which the persona sees as + `privapub.event.participation`: `pending`, `accepted` or `rejected`. An event that takes participants by invitation + or on another site (Mobilizon's `joinMode` `invite` or `external`) is refused before anything is sent (422). Who + takes part is public on the event's server. A `Create`'s `Note` carries Mastodon's `content`, `contentMap`, `summary` and `sensitive`, plus `Mention` and `Hashtag` tags. diff --git a/PrivaPub.Tests/Federation/ParticipationTests.cs b/PrivaPub.Tests/Federation/ParticipationTests.cs new file mode 100644 index 0000000..7f8b90f --- /dev/null +++ b/PrivaPub.Tests/Federation/ParticipationTests.cs @@ -0,0 +1,100 @@ +using MongoDB.Entities; + +using PrivaPub.Models.Post; +using PrivaPub.Models.Social; +using PrivaPub.Tests.Support; + +using System.Text.Json.Nodes; + +using static PrivaPub.Tests.Support.FederatedSeeds; + +namespace PrivaPub.Tests.Federation +{ + // a persona joins a remote event (owner decision 2026-10-05): Join, answered Accept or Reject; Leave + [Trait("Category", "Integration")] + public sealed class ParticipationTests : IAsyncLifetime + { + Harness _harness; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _harness = await Harness.Start(); + } + + public async ValueTask DisposeAsync() + { + if (_harness != default) + await _harness.DisposeAsync(); + } + + async Task<(PrivaPub.Federation.Actors.LocalActor Alice, RemoteActor Group, Post Event)> Announced(string joinMode = "free") + { + var token = TestContext.Current.CancellationToken; + var (_, alice) = await _harness.Persona("alice"); + var group = new RemoteActor(_harness.Peer, "group", type: "Group"); + await Follows(alice.Id, group); + var happening = new JsonObject + { + ["id"] = NewId(group, "events"), ["type"] = "Event", ["name"] = "A picnic", ["content"] = "

bring bread

", + ["attributedTo"] = group.Id, ["startTime"] = DateTime.UtcNow.AddDays(3).ToString("O"), ["joinMode"] = joinMode, + ["to"] = new JsonArray(PrivaPub.Federation.Objects.Addressing.Public), ["cc"] = new JsonArray(group.Id + "/followers") + }; + await _harness.Deliver(group, "/human-centipede", Create(group, happening)); + return (alice, group, await DB.Default.Find().Match(p => p.ObjectURI == IdOf(happening)).ExecuteSingleAsync(token)); + } + + Task Of(PrivaPub.Federation.Actors.LocalActor alice, Post happening) => + DB.Default.Find().Match(p => p.AvatarId == alice.Id && p.PostId == happening.ID).ExecuteFirstAsync(TestContext.Current.CancellationToken); + + [Fact] + public async Task A_persona_joins_an_event_its_server_accepts_and_leaves_it() + { + var token = TestContext.Current.CancellationToken; + var (alice, group, happening) = await Announced(); + + Assert.True((await _harness.Participations.Join(alice, happening.ID, token)).Ok); + + var join = Assert.Single(await _harness.Outgoing(group.Id + "/inbox"), a => a["type"]!.GetValue() == "Join"); + Assert.Equal((alice.Uri, happening.ObjectURI), (join["actor"]!.GetValue(), join["object"]!.GetValue())); + Assert.Equal(ParticipationState.Pending, (await Of(alice, happening)).State); + + await _harness.Deliver(group, "/human-centipede", new JsonObject + { + ["id"] = NewId(group, "accepts"), ["type"] = "Accept", ["actor"] = group.Id, ["object"] = join["id"]!.GetValue() + }); + Assert.Equal(ParticipationState.Accepted, (await Of(alice, happening)).State); + + Assert.True((await _harness.Participations.Leave(alice, happening.ID, token)).Ok); + Assert.Null(await Of(alice, happening)); + var leave = Assert.Single(await _harness.Outgoing(group.Id + "/inbox"), a => a["type"]!.GetValue() == "Leave"); + Assert.Equal(happening.ObjectURI, leave["object"]!.GetValue()); + } + + [Fact] + public async Task A_refusal_counts_an_answer_from_another_server_does_not_and_an_invitation_only_event_is_not_joined() + { + var token = TestContext.Current.CancellationToken; + var (alice, group, happening) = await Announced(); + await _harness.Participations.Join(alice, happening.ID, token); + var join = Assert.Single(await _harness.Outgoing(group.Id + "/inbox"), a => a["type"]!.GetValue() == "Join"); + + var stranger = new RemoteActor(_harness.Peer, "stranger", _harness.Peer.B); + await _harness.Deliver(stranger, "/human-centipede", new JsonObject + { + ["id"] = NewId(stranger, "accepts"), ["type"] = "Accept", ["actor"] = stranger.Id, ["object"] = join["id"]!.GetValue() + }); + Assert.Equal(ParticipationState.Pending, (await Of(alice, happening)).State); + + await _harness.Deliver(group, "/human-centipede", new JsonObject + { + ["id"] = NewId(group, "rejects"), ["type"] = "Reject", ["actor"] = group.Id, + ["object"] = new JsonObject { ["id"] = join["id"]!.GetValue(), ["type"] = "Join", ["actor"] = alice.Uri, ["object"] = happening.ObjectURI } + }); + Assert.Equal(ParticipationState.Rejected, (await Of(alice, happening)).State); + + var (carol, _, invitationOnly) = await Announced("invite"); + Assert.Equal(422, (await _harness.Participations.Join(carol, invitationOnly.ID, token)).Status); + } + } +} diff --git a/PrivaPub.Tests/Federation/ReplyRelayTests.cs b/PrivaPub.Tests/Federation/ReplyRelayTests.cs new file mode 100644 index 0000000..8409a31 --- /dev/null +++ b/PrivaPub.Tests/Federation/ReplyRelayTests.cs @@ -0,0 +1,114 @@ +using MongoDB.Entities; + +using PrivaPub.Domain.Statuses; +using PrivaPub.Models.Post; +using PrivaPub.Tests.Support; + +using System.Text.Json.Nodes; + +using static PrivaPub.Tests.Support.FederatedSeeds; + +namespace PrivaPub.Tests.Federation +{ + // Inbox forwarding of replies (owner decision 2026-10-05): a reply to a persona's post reaches the persona's followers, + // who are not among those its author's server sends it to + [Trait("Category", "Integration")] + public sealed class ReplyRelayTests : IAsyncLifetime + { + Harness _harness; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _harness = await Harness.Start(); + } + + public async ValueTask DisposeAsync() + { + if (_harness != default) + await _harness.DisposeAsync(); + } + + // alice's post; bob replies from his server, fan follows alice from another, pal from bob's + async Task<(PrivaPub.Federation.Actors.LocalActor Alice, Post Post, RemoteActor Bob, RemoteActor Fan, RemoteActor Pal)> Thread() + { + var token = TestContext.Current.CancellationToken; + var (_, alice) = await _harness.Persona("alice"); + var bob = new RemoteActor(_harness.Peer, "bob"); + var fan = new RemoteActor(_harness.Peer, "fan", _harness.Peer.B); + var pal = new RemoteActor(_harness.Peer, "pal"); + await _harness.FollowedBy(alice, fan); + await _harness.FollowedBy(alice, pal); + var post = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "what do you think?" }, token)).Post; + return (alice, post, bob, fan, pal); + } + + JsonObject Reply(RemoteActor bob, Post post, string visibility = "public") + { + var reply = PublicNote(bob, "

I think so

", post.ActorURI); + reply["inReplyTo"] = post.ObjectURI; + if (visibility == "followers") + { + reply["to"] = new JsonArray(bob.Id + "/followers"); + reply["cc"] = new JsonArray(post.ActorURI); + } + return reply; + } + + Task> At(RemoteActor follower) => _harness.Outgoing(follower.SharedInbox); + + [Fact] + public async Task A_public_reply_to_a_personas_post_reaches_its_followers_as_it_came_and_not_its_authors_server() + { + var (_, post, bob, fan, pal) = await Thread(); + var create = Create(bob, Reply(bob, post)); + create["signature"] = new JsonObject { ["type"] = "RsaSignature2017", ["signatureValue"] = "kept as it came" }; + + await _harness.Deliver(bob, "/human-centipede", create); + + var relayed = Assert.Single(await At(fan), a => a["id"]!.GetValue() == IdOf(create)); + Assert.Equal(bob.Id, relayed["actor"]!.GetValue()); + Assert.Equal("kept as it came", relayed["signature"]!["signatureValue"]!.GetValue()); + Assert.DoesNotContain(await At(pal), a => a["id"]!.GetValue() == IdOf(create)); + } + + [Fact] + public async Task Its_edit_and_deletion_follow_it_and_a_followers_only_reply_stays_where_it_was_sent() + { + var (_, post, bob, fan, _) = await Thread(); + var reply = Reply(bob, post); + await _harness.Deliver(bob, "/human-centipede", Create(bob, reply)); + var edited = (JsonObject)reply.DeepClone(); + edited["content"] = "

I think so, on second thought

"; + edited["updated"] = DateTime.UtcNow.AddMinutes(1).ToString("O"); + var update = Activity(bob, "Update", edited); + await _harness.Deliver(bob, "/human-centipede", update); + var delete = Activity(bob, "Delete", JsonValue.Create(IdOf(reply))!); + await _harness.Deliver(bob, "/human-centipede", delete); + + var relayed = (await At(fan)).Select(a => a["id"]!.GetValue()).ToList(); + Assert.Contains(IdOf(update), relayed); + Assert.Contains(IdOf(delete), relayed); + + var quiet = Create(bob, Reply(bob, post, "followers")); + await _harness.Deliver(bob, "/human-centipede", quiet); + Assert.DoesNotContain(await At(fan), a => a["id"]!.GetValue() == IdOf(quiet)); + } + + [Fact] + public async Task A_reply_that_reached_us_forwarded_is_not_passed_on_again() + { + var (_, post, bob, fan, _) = await Thread(); + var other = new RemoteActor(_harness.Peer, "other", _harness.Peer.B); + var reply = Reply(bob, post); + _harness.Peer.Serve(new Uri(IdOf(reply)).AbsolutePath, reply.ToJsonString()); + var create = Create(bob, reply); + + var result = await _harness.Deliver(other, "/human-centipede", create); + + Assert.Equal("forwarded", result.Reason); + Assert.True(await DB.Default.Find().Match(p => p.ObjectURI == IdOf(reply)).ExecuteAnyAsync(TestContext.Current.CancellationToken)); + Assert.DoesNotContain(await At(fan), a => a["id"]!.GetValue() == IdOf(create)); + } + } +} diff --git a/PrivaPub.Tests/Support/Harness.cs b/PrivaPub.Tests/Support/Harness.cs index 9c73b4d..be48288 100644 --- a/PrivaPub.Tests/Support/Harness.cs +++ b/PrivaPub.Tests/Support/Harness.cs @@ -52,12 +52,13 @@ namespace PrivaPub.Tests.Support Outbox = new OutboxPublisher(Db, Local, Delivery); Quotes = new QuoteService(Db, Remote, RemotePosts, Local, Delivery, Outbox); Approvals = new InteractionApprovals(Db, Remote, Local, Delivery, Outbox); + Participations = new Participations(Db, Local, Delivery); Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger.Instance, Ledger); Handlers = new IActivityHandler[] { new FollowHandler(Db, Local, Remote, Delivery), - new AcceptHandler(Db, Local, Quotes, Approvals), - new RejectHandler(Db, Local, Quotes, Approvals), + new AcceptHandler(Db, Local, Quotes, Approvals, Participations), + new RejectHandler(Db, Local, Quotes, Approvals, Participations), new UndoHandler(Db, Local, Reactions), new LikeHandler(Db, Reactions), new EmojiReactHandler(Db, Reactions), @@ -67,7 +68,7 @@ namespace PrivaPub.Tests.Support new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote, Records, Quotes), new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls, new NoPreviews(), Quotes, Approvals), new DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes), - new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes), + new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes, Delivery), new FlagHandler(Db, Local), new BlockHandler(Db, Local) }; @@ -108,6 +109,7 @@ namespace PrivaPub.Tests.Support public Reactions Reactions { get; } public QuoteService Quotes { get; } public InteractionApprovals Approvals { get; } + public Participations Participations { get; } public TimelineService Timelines { get; } public RelationshipService Relationships { get; } public ReportService Reports { get; } diff --git a/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs b/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs index 8e86aa2..617f176 100644 --- a/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs @@ -33,9 +33,12 @@ namespace PrivaPub.Api.Mastodon.Controllers readonly PrivaPub.Infrastructure.Jobs.IJobQueue _jobs; readonly Domain.Media.IMediaService _media; + readonly IParticipations _participations; + public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache, IQuoteService quotes, - IOutboxPublisher outbox, PrivaPub.Infrastructure.Jobs.IJobQueue jobs, Domain.Media.IMediaService media) + IOutboxPublisher outbox, PrivaPub.Infrastructure.Jobs.IJobQueue jobs, Domain.Media.IMediaService media, IParticipations participations = default) { + _participations = participations; _jobs = jobs; _media = media; _quotes = quotes; @@ -248,6 +251,22 @@ namespace PrivaPub.Api.Mastodon.Controllers return post == default ? NotFoundError() : Json(new StatusSource { Id = post.ID, Text = post.Text ?? string.Empty, SpoilerText = post.SpoilerText ?? string.Empty }); } + // a persona joins or leaves a remote event (owner decision 2026-10-05); the status says how it stands + // (privapub.event.participation) + [HttpPost("/api/privapub/v1/statuses/{id}/join"), Scope("write:statuses")] + public async Task Join(string id, CancellationToken token) + { + var outcome = await _participations.Join(Me, id, token); + return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error); + } + + [HttpPost("/api/privapub/v1/statuses/{id}/leave"), Scope("write:statuses")] + public async Task Leave(string id, CancellationToken token) + { + var outcome = await _participations.Leave(Me, id, token); + return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error); + } + [HttpPost("/api/v1/statuses/{id}/favourite"), Scope("write:favourites")] public Task Favourite(string id, CancellationToken token) => Toggle(_statuses.Favourite(Me, id, true, token), id, token); diff --git a/PrivaPub/Api/Mastodon/Entities/Entities.cs b/PrivaPub/Api/Mastodon/Entities/Entities.cs index 8f9d9ce..8298152 100644 --- a/PrivaPub/Api/Mastodon/Entities/Entities.cs +++ b/PrivaPub/Api/Mastodon/Entities/Entities.cs @@ -148,6 +148,7 @@ public int? Capacity { get; set; } public string Status { get; set; } public string Category { get; set; } + public string Participation { get; set; }//the viewer's: "pending", "accepted" or "rejected" } public class PrivaPubPlace diff --git a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs index 85f321e..9739267 100644 --- a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs +++ b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs @@ -215,6 +215,11 @@ namespace PrivaPub.Api.Mastodon.Mappers }) .OrderByDescending(e => e.Count) .ToList()); + var eventIds = all.Where(p => p.Event != default && p.IsFederatedCopy).Select(p => p.ID).ToList(); + var participations = viewerId == default || eventIds.Count == 0 + ? new Dictionary() + : (await DB.Default.Find().Match(p => p.AvatarId == viewerId && eventIds.Contains(p.PostId)).ExecuteAsync(token)) + .ToDictionary(p => p.PostId, p => p.State); var pollIds = all.Where(p => p.Poll != default).Select(p => p.ID).ToList(); var ownVotes = viewerId == default || pollIds.Count == 0 ? new Dictionary>() @@ -289,6 +294,8 @@ namespace PrivaPub.Api.Mastodon.Mappers status.QuotesCount = post.QuotesCount; status.QuoteApproval = post.IsFederatedCopy ? Approval(post, viewerId) : LocalApproval(post, viewerId, followsAuthor.Contains(post.GroupUserId)); status.InteractionPolicy = Policies(post); + if (status.Privapub?.Event != default && participations.TryGetValue(post.ID, out var participation)) + status.Privapub.Event.Participation = participation.ToString().ToLowerInvariant(); if (post.QuoteState == QuoteState.None) return; var state = post.QuoteState.ToString().ToLowerInvariant(); diff --git a/PrivaPub/Domain/Statuses/Participations.cs b/PrivaPub/Domain/Statuses/Participations.cs new file mode 100644 index 0000000..1918b76 --- /dev/null +++ b/PrivaPub/Domain/Statuses/Participations.cs @@ -0,0 +1,131 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using MongoDB.Entities; + +using PrivaPub.Domain.Privacy; +using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Objects; +using PrivaPub.Federation.Outbox; +using PrivaPub.Federation.Rendering; +using PrivaPub.Models.Social; +using PrivaPub.Models.User; +using PrivaPub.StaticServices; + +using System.Text.Json.Nodes; + +using static PrivaPub.Federation.Objects.ActivityJson; + +using PostEntity = PrivaPub.Models.Post.Post; + +namespace PrivaPub.Domain.Statuses +{ + // A persona's participation in a remote event (owner decision 2026-10-05: personas may join events): a Join with the + // event as its object, sent to the event's server, which answers Accept (Mobilizon takes a free event's participants at + // once) or Reject; a Leave takes it back. A participation is public on the event's server: who comes is shown there. + public interface IParticipations + { + Task Join(LocalActor me, string postId, CancellationToken token); + Task Leave(LocalActor me, string postId, CancellationToken token); + Task Answered(JsonNode answer, ForeignAvatar actor, bool accepted, CancellationToken token); + } + + public class Participations : IParticipations + { + const string JoinPrefix = "join-"; + + readonly DbEntities _dbEntities; + readonly ILocalActorService _localActors; + readonly IDeliveryService _delivery; + + public Participations(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery) + { + _dbEntities = dbEntities; + _localActors = localActors; + _delivery = delivery; + } + + async Task Event(LocalActor me, string postId, CancellationToken token) + { + var post = await _dbEntities.Posts.Match(p => p.ID == postId && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); + return post is { IsFederatedCopy: true, Event: not null } && await VisibilityPolicy.CanSee(post, me.Id, token) ? post : default; + } + + public async Task Join(LocalActor me, string postId, CancellationToken token) + { + var post = await Event(me, postId, token); + if (post == default) + return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); + // an event by invitation, or one that takes its participants on another site, is not joined from here + if (post.Event.JoinMode is "invite" or "external") + return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This event does not take participants this way"); + if (await DB.Default.Find().Match(p => p.AvatarId == me.Id && p.PostId == post.ID).ExecuteFirstAsync(token) is { State: not ParticipationState.Rejected }) + return new StatusOutcome(post); + await DB.Default.DeleteAsync(p => p.AvatarId == me.Id && p.PostId == post.ID); + var participation = new Participation { ID = ObjectId.GenerateNewId().ToString(), AvatarId = me.Id, PostId = post.ID }; + participation.ActivityURI = me.ActivityUri(JoinPrefix + participation.ID); + try + { + await DB.Default.SaveAsync(participation, token); + } + catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + return new StatusOutcome(post); + } + await Send(me, post, new JsonObject + { + ["@context"] = ActivityPubRenderer.ActivityStreams, + ["id"] = participation.ActivityURI, + ["type"] = "Join", + ["actor"] = me.Uri, + ["object"] = post.ObjectURI, + ["to"] = new JsonArray(post.ActorURI) + }, token); + return new StatusOutcome(post); + } + + public async Task Leave(LocalActor me, string postId, CancellationToken token) + { + var post = await Event(me, postId, token); + if (post == default) + return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); + var participation = await DB.Default.Find().Match(p => p.AvatarId == me.Id && p.PostId == post.ID).ExecuteFirstAsync(token); + if (participation == default || (await DB.Default.DeleteAsync(participation.ID)).DeletedCount == 0) + return new StatusOutcome(post); + await Send(me, post, new JsonObject + { + ["@context"] = ActivityPubRenderer.ActivityStreams, + ["id"] = me.ActivityUri("leave-" + participation.ID), + ["type"] = "Leave", + ["actor"] = me.Uri, + ["object"] = post.ObjectURI, + ["to"] = new JsonArray(post.ActorURI) + }, token); + return new StatusOutcome(post); + } + + async Task Send(LocalActor me, PostEntity post, JsonObject activity, CancellationToken token) + { + var author = string.IsNullOrEmpty(post.AuthorAccountId) ? default : await _dbEntities.ForeignAvatars.MatchID(post.AuthorAccountId).ExecuteFirstAsync(token); + var inbox = !string.IsNullOrEmpty(author?.SharedInboxURL) ? author.SharedInboxURL : author?.InboxURL; + if (!string.IsNullOrEmpty(inbox)) + await _delivery.Enqueue(me, new[] { inbox }, activity, token); + } + + // the event's server answering our Join: Accept or Reject, naming it or carrying it + public async Task Answered(JsonNode answer, ForeignAvatar actor, bool accepted, CancellationToken token) + { + var joinId = Id(answer["object"]); + var marker = joinId?.LastIndexOf("/grunts/" + JoinPrefix, StringComparison.Ordinal) ?? -1; + if (marker < 0 || !joinId.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase)) + return false; + var participation = await DB.Default.Find().Match(p => p.ActivityURI == joinId).ExecuteFirstAsync(token); + var post = participation == default ? default : await _dbEntities.Posts.MatchID(participation.PostId).ExecuteFirstAsync(token); + if (post == default || !Origin.Same(post.ObjectURI, actor.ActorURI)) + return true; + await DB.Default.Update().MatchID(participation.ID) + .Modify(p => p.State, accepted ? ParticipationState.Accepted : ParticipationState.Rejected) + .ExecuteAsync(token); + return true; + } + } +} diff --git a/PrivaPub/Federation/Inbox/Arrival.cs b/PrivaPub/Federation/Inbox/Arrival.cs index 2771a65..e4e6354 100644 --- a/PrivaPub/Federation/Inbox/Arrival.cs +++ b/PrivaPub/Federation/Inbox/Arrival.cs @@ -5,8 +5,9 @@ using PrivaPub.Models.Post; namespace PrivaPub.Federation.Inbox { + // Raw: the activity as it came, when its own author sent it (ReplyRelay passes it on as it is) public sealed record Arrival(string ActivityId, string ActivityType, string ActorURI, string Inbox, string KeyId, string Algorithm, - IReadOnlyList SignedHeaders, DateTime ReceivedAt, string Context = default) + IReadOnlyList SignedHeaders, DateTime ReceivedAt, string Context = default, string Raw = default) { static readonly AsyncLocal current = new(); diff --git a/PrivaPub/Federation/Inbox/Handlers/AcceptHandler.cs b/PrivaPub/Federation/Inbox/Handlers/AcceptHandler.cs index 44e29be..5e0ae88 100644 --- a/PrivaPub/Federation/Inbox/Handlers/AcceptHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/AcceptHandler.cs @@ -18,9 +18,12 @@ namespace PrivaPub.Federation.Inbox.Handlers readonly ILocalActorService _localActors; readonly IQuoteService _quotes; readonly IInteractionApprovals _approvals; + readonly IParticipations _participations; - public AcceptHandler(DbEntities dbEntities, ILocalActorService localActors, IQuoteService quotes, IInteractionApprovals approvals = default) + public AcceptHandler(DbEntities dbEntities, ILocalActorService localActors, IQuoteService quotes, IInteractionApprovals approvals = default, + IParticipations participations = default) { + _participations = participations; _dbEntities = dbEntities; _localActors = localActors; _quotes = quotes; @@ -41,6 +44,11 @@ namespace PrivaPub.Federation.Inbox.Handlers Arrival.Accept("interaction-answer"); return; } + if (_participations != default && await _participations.Answered(activity, actor, accepted: Type == "Accept", token)) + { + Arrival.Accept("participation-answer"); + return; + } var following = await FindFollowing(activity["object"], actor, _dbEntities, _localActors, token); if (following == default) { @@ -79,8 +87,9 @@ namespace PrivaPub.Federation.Inbox.Handlers public class RejectHandler : AcceptHandler { - public RejectHandler(DbEntities dbEntities, ILocalActorService localActors, IQuoteService quotes, IInteractionApprovals approvals = default) - : base(dbEntities, localActors, quotes, approvals) + public RejectHandler(DbEntities dbEntities, ILocalActorService localActors, IQuoteService quotes, IInteractionApprovals approvals = default, + IParticipations participations = default) + : base(dbEntities, localActors, quotes, approvals, participations) { } diff --git a/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs b/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs index a9fb37f..abb1f88 100644 --- a/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs @@ -214,6 +214,7 @@ namespace PrivaPub.Federation.Inbox.Handlers if (parent != default && Domain.Privacy.Counted.Reply(post)) await DB.Default.Update().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token); await _fanout.Distribute(post, token); + await ReplyRelay.Pass(post, _dbEntities, _localActors, _delivery, token); if (conversation != default) await Domain.Statuses.ConversationStates.Posted(conversation.ID, post.ID, default, token); if (group is { IsCircle: false }) diff --git a/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs b/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs index 4f9d244..fc1e0c0 100644 --- a/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs @@ -99,6 +99,7 @@ namespace PrivaPub.Federation.Inbox.Handlers } Arrival.Accept("removed"); Arrival.About(post.ObjectType, post.Visibility, post.CreationDate); + await ReplyRelay.Pass(post, _dbEntities, _localActors, _delivery, token); await RemoteDeletes.Remove(post, objectUri, token); if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community) await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token); diff --git a/PrivaPub/Federation/Inbox/Handlers/UpdateHandler.cs b/PrivaPub/Federation/Inbox/Handlers/UpdateHandler.cs index 1e6540a..6ce7a6c 100644 --- a/PrivaPub/Federation/Inbox/Handlers/UpdateHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/UpdateHandler.cs @@ -25,10 +25,12 @@ namespace PrivaPub.Federation.Inbox.Handlers readonly IGroupDistributor _groups; readonly IObjectRecords _records; readonly IQuoteService _quotes; + readonly IDeliveryService _delivery; public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IGroupDistributor groups, - IObjectRecords records, IQuoteService quotes) + IObjectRecords records, IQuoteService quotes, IDeliveryService delivery = default) { + _delivery = delivery; _quotes = quotes; _records = records; _groups = groups; @@ -90,6 +92,8 @@ namespace PrivaPub.Federation.Inbox.Handlers var edited = await RemoteEdits.Apply(post, note, Id(activity), _localActors, _records, _quotes, token); Arrival.Accept(edited ? "edit" : "refresh"); + if (edited) + await ReplyRelay.Pass(post, _dbEntities, _localActors, _delivery, token); if (edited && !string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(Models.Federation.LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community) await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token); } diff --git a/PrivaPub/Federation/Inbox/InboxProcessor.cs b/PrivaPub/Federation/Inbox/InboxProcessor.cs index 8c435e4..e1b97cf 100644 --- a/PrivaPub/Federation/Inbox/InboxProcessor.cs +++ b/PrivaPub/Federation/Inbox/InboxProcessor.cs @@ -73,7 +73,8 @@ namespace PrivaPub.Federation.Inbox } var arrival = new Arrival(Id(activity), type, actor.ActorURI, payload.Inbox, payload.KeyId, payload.Algorithm, - payload.SignedHeaders ?? Array.Empty(), payload.ReceivedAt ?? job.CreatedAt, activity["@context"]?.ToJsonString()); + payload.SignedHeaders ?? Array.Empty(), payload.ReceivedAt ?? job.CreatedAt, activity["@context"]?.ToJsonString(), + payload.ForwardedBy == default ? payload.Activity : default); Arrival.Current = arrival; try { diff --git a/PrivaPub/Federation/Inbox/ReplyRelay.cs b/PrivaPub/Federation/Inbox/ReplyRelay.cs new file mode 100644 index 0000000..189c069 --- /dev/null +++ b/PrivaPub/Federation/Inbox/ReplyRelay.cs @@ -0,0 +1,43 @@ +using MongoDB.Entities; + +using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Outbox; +using PrivaPub.Infrastructure.Statistics; +using PrivaPub.Models.Federation; +using PrivaPub.Models.Post; +using PrivaPub.StaticServices; + +using System.Text.Json.Nodes; + +using PostEntity = PrivaPub.Models.Post.Post; + +namespace PrivaPub.Federation.Inbox +{ + // ActivityPub's inbox forwarding of replies (owner decision 2026-10-05): a public or unlisted reply to one of our + // personas' own posts reaches the persona's followers as its author sent it, and so do its edits and its deletion, as + // Mastodon forwards the replies to its accounts' posts. The author's server sends a reply to its own followers and to + // the persona, never to the persona's followers, who would otherwise see half of the thread. It goes as it came (an + // LD signature in it stays good), signed by the persona; never to the replier's own server, never once more when it + // reached us forwarded, and never for a followers-only reply or a DM, which were not for the persona's followers. + public static class ReplyRelay + { + public static async Task Pass(PostEntity reply, DbEntities db, ILocalActorService localActors, IDeliveryService delivery, CancellationToken token) + { + var raw = Arrival.Current?.Raw; + if (raw == default || delivery == default || localActors == default || reply is not { IsFederatedCopy: true } + || reply.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted) || string.IsNullOrEmpty(reply.AnsweringToPostId)) + return; + var parent = await db.Posts.MatchID(reply.AnsweringToPostId).ExecuteFirstAsync(token); + if (parent is not { IsFederatedCopy: false, IsLocalOnly: false } || !string.IsNullOrEmpty(parent.GroupId) + || parent.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly)) + return; + var owner = await localActors.FindById(LocalActorKind.Person, parent.GroupUserId, token); + if (owner is not { IsFederated: true } || JsonNode.Parse(raw) is not JsonObject activity) + return; + var replier = Interactions.HostOf(reply.ActorURI); + var inboxes = (await delivery.FollowerInboxes(owner, token)).Where(i => Interactions.HostOf(i) != replier).ToList(); + if (inboxes.Count > 0) + await delivery.Enqueue(owner, inboxes, activity, token); + } + } +} diff --git a/PrivaPub/Infrastructure/Data/Indexes.cs b/PrivaPub/Infrastructure/Data/Indexes.cs index 27e5fe2..dbcd1ce 100644 --- a/PrivaPub/Infrastructure/Data/Indexes.cs +++ b/PrivaPub/Infrastructure/Data/Indexes.cs @@ -93,6 +93,12 @@ namespace PrivaPub.Infrastructure.Data .Option(o => o.Unique = true) .CreateAsync(token); await Plain(token, f => f.ActivityURI); + await DB.Default.Index() + .Key(p => p.AvatarId, KeyType.Ascending) + .Key(p => p.PostId, KeyType.Ascending) + .Option(o => o.Unique = true) + .CreateAsync(token); + await Plain(token, p => p.ActivityURI); await DB.Default.Index() .Key(m => m.AvatarId, KeyType.Ascending) .Key(m => m.Timeline, KeyType.Ascending) diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index bdce69b..1f81b9d 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -102,6 +102,7 @@ namespace PrivaPub.Middleware .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/PrivaPub/Models/Social/Participation.cs b/PrivaPub/Models/Social/Participation.cs new file mode 100644 index 0000000..00db3cb --- /dev/null +++ b/PrivaPub/Models/Social/Participation.cs @@ -0,0 +1,22 @@ +using MongoDB.Entities; + +namespace PrivaPub.Models.Social +{ + // a persona's answer to a remote event (owner decision 2026-10-05): a Join its organiser has taken, refused or not + // yet answered; leaving deletes it + public class Participation : Entity + { + public string AvatarId { get; set; } + public string PostId { get; set; }//the event + public ParticipationState State { get; set; } + public string ActivityURI { get; set; }//our Join + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } + + public enum ParticipationState + { + Pending, + Accepted, + Rejected + } +} diff --git a/docs/INTEROP.md b/docs/INTEROP.md index 3d479bb..6e41601 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -794,8 +794,10 @@ FEP-8a8e (draft) is the common reference. - Other: `category`, `contacts`. - Attachments: the online link `Link{name: Website}`; `PropertyValue`s under `mz:` keys; a banner `Document`. - The event is attributed to the Group. - - **RSVP:** `Join{object: event}` with a stable id that can be fetched; Mobilizon answers `Accept` or `Reject`; - `Leave`. + - **RSVP:** `Join{object: event}` with a stable id; Mobilizon answers `Accept` or `Reject` naming it; `Leave`. A + persona joins and leaves since 2026-10-05 (owner decision): its `Join` goes to the organiser alone, and Mobilizon + takes a participant of an open event at once (`role` `participant`) and answers `Accept`. It never fetches the + `Join`, so PrivaPub does not serve it. `joinMode` `invite` and `external` are refused before anything is sent. - The organiser (`actor` on the event) sends its Create, Update and Delete; the event is attributed to the group, which announces the Event itself, not the activity. PrivaPub refused the organiser's activities as misattributed until 2026-10-05, so an edit was lost and a deletion left the event in place; it now takes them as Mobilizon has @@ -805,10 +807,11 @@ FEP-8a8e (draft) is the common reference. - Its NodeInfo names the software "Mobilizon" (NodeInfo wants lower case) and sends `Content-Type: application/json; profile=http://…#` with the URL unquoted, which .NET cannot parse: PrivaPub reads the raw media type, and lowercases software names. - - **Pasture evidence (2026-10-05, Mobilizon 5.2.4, `tools/pasture/scenarios/mobilizon.sh`):** 23 checks pass: + - **Pasture evidence (2026-10-05, Mobilizon 5.2.4, `tools/pasture/scenarios/mobilizon.sh`):** 27 checks pass: alice follows a group; the event its organiser makes arrives as an Event with its start, end and located place; - comments both ways; the organiser's edit, closing the comments (PrivaPub then refuses a reply) and deletes of a - comment and the event; a group post with its title; the unfollow; statistics. No RSVP yet. + she joins it (a participant row on Mobilizon, its `Accept` back as `privapub.event.participation: accepted`) and + leaves it (the row gone); comments both ways; the organiser's edit, closing the comments (PrivaPub then refuses a + reply) and deletes of a comment and the event; a group post with its title; the unfollow; statistics. - **Gancio:** a single Application actor; `location` is an **array** of `VirtualLocation` and `Place`; no RSVP. - **Pasture evidence (2026-10-05, Gancio 1.28.2, `tools/pasture/scenarios/gancio.sh`):** 17 checks pass: alice follows its actor `relay`; a published event arrives as an Event with its start, end and place; her reply is kept as one of diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7a596c2..8938b17 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -67,6 +67,8 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati RFC 9421 signatures verified; forwarded activities taken as their origin vouches; an id a sender reuses is no copy; a follow sent again under a new id (Lemmy); a group's event its organiser edits; NodeInfo read as Mobilizon and Funkwhale serve it; Funkwhale's answers named after our follow and its deletions of several uploads. + - GoToSocial's interaction policies both ways; personas join and leave remote events; third-party replies to a + persona's posts passed on to its followers (owner decisions 2026-10-05). - [ ] P7 Threads, communities, moderation, the social graph - [ ] P8 Signatures, discovery, the long tail - [x] P9 Reading at volume (owner decision 2026-10-04, back from "Cut"): lists, server-side filters, scheduled posts, @@ -254,6 +256,13 @@ and circles (see Owner decisions). | How work lands | Commits on master in both repositories; nothing is pushed, tagged or deployed without asking. | | Still asked first | Anything new that tells another server something about a persona, Web Push (traffic to push services), any admin endpoint in production, and anything acting from production or the owner's accounts. | +### Owner decisions on events and threads (2026-10-05) + +| Question | Decision | +|---|---| +| May a persona join a remote event? | **Yes.** A persona joins and leaves another server's event (`Join`/`Leave`, sent to the event's organiser only). Its participation is public on that server, as on every platform with events. Events that take participants only by invitation or on another site are refused before anything is sent. | +| Should PrivaPub pass on replies to a persona's posts? | **Yes, "the fediverse is broken without".** A public or unlisted reply that someone on another server writes to a persona's post goes on to the persona's followers, as Mastodon forwards it: the activity as its author signed it, never to the replier's own server, never for a post that is local-only, in a group or not shown to followers. Its edits and deletion follow the same way. | + ## Libraries (researched; no maintained .NET ActivityPub library exists, so Letterbook and Iceshrimp.NET both wrote their own) | Area | Choice | @@ -640,7 +649,8 @@ it, raw where it doesn't. - `indexable`/`discoverable`/`searchableBy`; - edit history from `formerRepresentations`; - PeerTube reply rules and `ApproveReply`. -- **Events:** structured RSVP (`Join`/`Leave` with stable ids). +- **Events:** structured RSVP (`Join`/`Leave` with stable ids): **done 2026-10-05** (owner decision above), checked live + against Mobilizon. #### P8 Signatures, discovery and the long tail - **Signatures:** diff --git a/tools/pasture/scenarios/mobilizon.sh b/tools/pasture/scenarios/mobilizon.sh index 27f8e4d..a0c4905 100644 --- a/tools/pasture/scenarios/mobilizon.sh +++ b/tools/pasture/scenarios/mobilizon.sh @@ -1,6 +1,6 @@ # Mobilizon 5.2.4: a group and its events. alice_mobilizon follows the group, which announces the events its members -# organise; an event arrives as an Event with its dates and place; comments both ways; an edit and comments closed; -# deletes; a group post; the unfollow. An event made through its API without options has its comments closed +# organise; an event arrives as an Event with its dates and place; she joins it and leaves; comments both ways; an edit +# and comments closed; deletes; a group post; an event kept for decePubClient's e2e; the unfollow. An event made through its API without options has its comments closed # (`commentsEnabled: false`), which PrivaPub honours, so this one opens them. Mobilizon's API is GraphQL (/api), signed in by its login mutation; what it holds is read from # its PostGIS (database mobilizon). MZ=https://mobilizon.test:6443 @@ -51,6 +51,17 @@ event_json=$(curl -s -H "$PH" "$P/api/v1/statuses/$e_on_p") echo "$event_json" | j "e = (d.get('privapub') or {}).get('event') or {}; print(any('Borghese' in ((p.get('name') or '') + (p.get('address') or '')) and p.get('latitude') for p in e.get('places', [])))" | grep -q True \ && ok "and with its place, located" || ko "the event's place is missing" +echo " participation" +# alice joins the event (owner decision 2026-10-05): a Join Mobilizon takes at once for a free event, then a Leave +participation() { curl -s -H "$PH" "$P/api/v1/statuses/$e_on_p" | j "print(((d.get('privapub') or {}).get('event') or {}).get('participation'))"; } +mz_participant() { mz_sql "select p.role from participants p join actors a on a.id = p.actor_id join events e on e.id = p.event_id where a.url like '%alice_mobilizon%' and e.id = $event_id"; } +curl -s -o /dev/null -X POST -H "$PH" "$P/api/privapub/v1/statuses/$e_on_p/join" +until_true 45 '[ "$(mz_participant)" = "participant" ]' && ok "alice_mobilizon's Join makes her a participant on Mobilizon" || ko "the Join never made a participant ($(mz_participant))" +until_true 45 '[ "$(participation)" = "accepted" ]' && ok "Mobilizon's Accept reaches PrivaPub" || ko "the participation stays $(participation)" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/privapub/v1/statuses/$e_on_p/leave" +until_true 45 '[ -z "$(mz_participant)" ]' && ok "her Leave takes her off the event on Mobilizon" || ko "she is still a participant after leaving" +[ "$(participation)" = "None" ] && ok "and PrivaPub forgets it" || ko "PrivaPub still shows a participation" + echo " comments" curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses" -d "status=a PrivaPub comment on the picnic&in_reply_to_id=$e_on_p&visibility=public" until_true 45 '[ "$(mz_sql "select count(*) from comments where text like '"'%a PrivaPub comment on the picnic%'"' and deleted_at is null")" -ge 1 ]' \ @@ -80,6 +91,16 @@ mz 'mutation($g:ID!,$t:String!){createPost(attributedToId:$g,title:$t,body:"

t "{\"g\":\"$group\",\"t\":\"$post_title\"}" >/dev/null until_true 60 '[ -n "$(p_home_has "$post_title")" ]' && ok "the group's post reaches alice_mobilizon's home, with its title" || ko "the group's post never arrived" +echo " an event kept for decePub's tests" +# decePubClient's e2e joins it from its card (tests/e2e/Actions/EventTests.cs); the one a run before this one kept goes +kept="$here/.state/mobilizon-kept-event.json" +[ -s "$kept" ] && mz 'mutation($e:ID!){deleteEvent(eventId:$e){id}}' "{\"e\":\"$(j "print(d['id'])" < "$kept")\"}" >/dev/null +kept_title="A concert kept for decePub $(date +%s)" +kept_begins=$(date -u -d '+10 days' +%Y-%m-%dT19:00:00Z); kept_ends=$(date -u -d '+10 days' +%Y-%m-%dT22:00:00Z) +mz 'mutation($t:String!,$b:DateTime!,$e:DateTime!,$o:ID!,$g:ID){createEvent(title:$t,description:"

come and listen

",beginsOn:$b,endsOn:$e,organizerActorId:$o,attributedToId:$g,visibility:PUBLIC,joinOptions:FREE,options:{commentModeration:ALLOW_ALL},physicalAddress:{description:"Piazza del Popolo",locality:"Roma",country:"Italia",geom:"12.4763;41.9109"}){id url}}' \ + "{\"t\":\"$kept_title\",\"b\":\"$kept_begins\",\"e\":\"$kept_ends\",\"o\":\"$me\",\"g\":\"$group\"}" | j "print(json.dumps(d['data']['createEvent']))" > "$kept" +until_true 60 '[ -n "$(p_home_has "$kept_title")" ]' && ok "an event kept for decePub's tests reaches PrivaPub" || ko "the kept event never arrived" + echo " unfollow" curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$group_on_p/unfollow" until_true 45 '[ "$(mz_sql "select count(*) from followers f join actors a on a.id = f.actor_id where a.url like '"'%alice_mobilizon%'"'")" = "0" ]' \ diff --git a/tools/pasture/town/check.py b/tools/pasture/town/check.py index 649cc9f..cf6ea68 100644 --- a/tools/pasture/town/check.py +++ b/tools/pasture/town/check.py @@ -3,7 +3,7 @@ one does hold (its database) and show (its API, as one account). Writes out/ deadline: break print(f" {len(failing)} checks still failing; sweeping again in 20s", flush=True) @@ -225,6 +231,28 @@ class Sweep: elif kind in ("followers", "direct", "circle", "located") and p not in want: self.add(f"confine.{kind}", o["origin"], p, not held, ref, "absent", "held" if held else "absent", through) + # -- a peer's public or unlisted reply to a persona's post, which PrivaPub passes on to the persona's followers (owner + # decision 2026-10-05): held on every server where a follower of the persona lives and that the reply was not + # addressed to already + def relayed(self, stored): + for ref, o in self.w.objects.items(): + parent = self.w.objects.get(o.get("parent") or "") + if ref in self.w.deleted or parent is None or parent["ref"] in self.w.deleted or o["origin"] == "privapub" \ + or parent["origin"] != "privapub" or parent.get("group") or o["visibility"] not in ("public", "unlisted") \ + or parent["visibility"] not in ("public", "unlisted", "followers"): + continue + addressed = {self.w.platform(k) for k in self.w.recipients(o)} | {o["origin"], "privapub"} + for p in sorted({self.w.platform(k) for k in self.w.follows.get(parent["author"], ())} - addressed): + # (a reply the seeder had p fetch proves nothing about passing it on) + if p not in self.platforms or RECEIVES.get(p) is not None or p in o.get("fetched_on", ()) \ + or (p in NEEDS_LD_SIGNATURE and o["origin"] not in LD_SIGNS): + continue + if p in NEEDS_PICTURE and not (stored.get(p, {}).get(parent["uri"]) and stored[p][parent["uri"]].exists): + continue + row = stored.get(p, {}).get(o["uri"]) + held = bool(row and row.exists and not row.deleted) + self.add("relay.reply", o["origin"], p, held, ref, "held", "held" if held else "missing", True) + # -- counts on the object's own server, and PrivaPub's exact counts def counts(self, stored): likes, reacts, boosts, votes = defaultdict(int), defaultdict(lambda: defaultdict(int)), defaultdict(int), defaultdict(lambda: defaultdict(int)) diff --git a/tools/pasture/town/specs/relay-five.json b/tools/pasture/town/specs/relay-five.json new file mode 100644 index 0000000..7fe5a61 --- /dev/null +++ b/tools/pasture/town/specs/relay-five.json @@ -0,0 +1,100 @@ +{ + "schema": "pasture-town/1", + "name": "relay-five", + "seed": 20261051, + "peers": { + "privapub": { + "roots": 1, + "personas": [ + 4, + 4 + ], + "circles": 0, + "communities": 0 + }, + "gts": { + "accounts": 2 + }, + "mastodon": { + "accounts": 2 + }, + "akkoma": { + "accounts": 2 + }, + "misskey": { + "accounts": 2 + } + }, + "profiles": { + "locked": 0.2, + "bot": 0.0, + "fields": [ + 0, + 2 + ], + "avatar": 0.9, + "header": 0.3, + "bioWords": [ + 5, + 12 + ], + "langs": { + "en": 80, + "it": 20 + } + }, + "graph": { + "follows": [ + 3, + 5 + ], + "mutual": 0.5, + "pending": 0.0, + "rejected": 0.0 + }, + "circleMembers": 0, + "content": { + "posts": 36, + "mix": { + "text": 60, + "cw": 10, + "tags": 10, + "mention": 10, + "image": 10 + }, + "visibility": { + "public": 70, + "unlisted": 15, + "followers": 15 + }, + "replyRounds": 2, + "replies": 0.6, + "deeperReplies": 0.4 + }, + "interactions": { + "likes": [ + 0, + 2 + ], + "boosts": [ + 0, + 1 + ], + "react": 0.2, + "vote": 0.0, + "bookmark": 0.0 + }, + "mutations": { + "edit": 0.1, + "delete": 0.05, + "blocks": 0, + "mutes": 0, + "reports": 0 + }, + "time": { + "roundSettleSeconds": 15, + "graphSettleSeconds": 20, + "settleSeconds": 30, + "deadlineSeconds": 120 + } +}