diff --git a/CLAUDE.md b/CLAUDE.md index 26ab04b..a65971d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -494,8 +494,9 @@ tools/pasture/run.sh down # removes e activity at `warn` (`LEMMY_LOG` sets `RUST_LOG`); the reason is in the 400's body. It answers our community's echo of its own activity and every bare `Announce{object}` 400 by design, and the echo is still needed (see `docs/INTEROP.md`, Lemmy). A new Lemmy never sends what it queued for a server before its send worker for that - server started, so the scenario waits for that worker (`lm_worker`) before its first follow, and sends what it queued every 30 seconds, so a vote takes up to a minute. - 22 checks; a moderator's removal is an expected failure (P7). + server started, so the scenario waits for that worker (`lm_worker`) before its first follow, and it sends what it + queued every 30 seconds, so a vote or a moderator's act takes up to a minute. Its lock, unlock and unban need a + `reason`, or it refuses them and federates nothing. 29 checks, among them a moderator's lock, ban and removal. - **Hollo (0.9.19):** Fedify's microblog server on the shared Postgres, set up through its web form (which checks `Origin` against `Host`, so the request names `hollo.test` without the port). It needs a 44-character `SECRET_KEY`, a media directory and a `themeColor`; statuses and votes go as JSON. Town only, no scenario. diff --git a/FEDERATION.md b/FEDERATION.md index d60e3c5..67766b8 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -98,6 +98,10 @@ A group is either a **community** or a **circle**. Mastodon member's replies reach only the people they mention. - Announces from **remote** groups (Lemmy communities) are followed through to the activity: the object is fetched from its own origin, never taken from the announce. +- A remote community's **moderation** reaches the posts it holds. A removal (`Announce{Delete}`) is believed once the + post's origin answers it gone. A lock (`Announce{Lock}`, or `commentsEnabled: false` on the post) refuses replies, + ours included, until `Undo{Lock}`. A ban of a persona (`Announce{Block}` with the community as `target`) shows as + `blocked_by` on the community and refuses the persona's posts and replies there until the `Undo`. ## Activities diff --git a/PrivaPub.Tests/Federation/InboxGapTests.cs b/PrivaPub.Tests/Federation/InboxGapTests.cs index cb4efe4..d07ce16 100644 --- a/PrivaPub.Tests/Federation/InboxGapTests.cs +++ b/PrivaPub.Tests/Federation/InboxGapTests.cs @@ -184,6 +184,71 @@ namespace PrivaPub.Tests.Federation Assert.False(await DB.Default.Find().Match(f => f.PostId == post.ID).ExecuteAnyAsync(token)); } + // Lemmy's moderators lock a community's post and ban members, and the community relays both inside its Announce + // (G-0006): a locked post takes no reply, a banned persona posts nothing there, until the Undo + [Fact] + public async Task A_community_locks_a_post_and_bans_a_persona_until_it_undoes_either() + { + var token = TestContext.Current.CancellationToken; + var (root, alice) = await _harness.Persona("alice"); + var community = new RemoteActor(_harness.Peer, "cats", type: "Group"); + var poster = new RemoteActor(_harness.Peer, "poster"); + var moderator = new RemoteActor(_harness.Peer, "moderator"); + await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token); + await DB.Default.Update().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token); + JsonObject Page(string text, bool commentsEnabled = true) + { + var page = PublicNote(poster, $"

{text}

", community.Id); + page["type"] = "Page"; + page["name"] = text; + page["audience"] = community.Id; + page["commentsEnabled"] = commentsEnabled; + _harness.Peer.Serve(new Uri(IdOf(page)).AbsolutePath, page.ToJsonString()); + return page; + } + async Task Announced(JsonObject page) + { + await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, page))); + return await DB.Default.Find().Match(p => p.ObjectURI == IdOf(page)).ExecuteSingleAsync(token); + } + Task Reply(Post post) => _harness.Statuses.Publish(alice, new StatusDraft { Text = "a reply", InReplyTo = post.ID }, token); + var post = await Announced(Page("a thread")); + var lockIt = new JsonObject { ["id"] = NewId(moderator, "locks"), ["type"] = "Lock", ["actor"] = moderator.Id, ["object"] = post.ObjectURI }; + + await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", lockIt)); + Assert.Equal(("accepted", "locked"), (Processed("Announce").Outcome, Processed("Announce").Reason)); + var refused = await Reply(post); + Assert.Equal((422, "Validation failed: This thread is locked"), (refused.Status, refused.Error)); + await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(moderator, "Undo", lockIt))); + Assert.Null((await DB.Default.Find().OneAsync(post.ID, token)).LockedAt); + Assert.True((await Reply(post)).Ok); + // a post its community serves locked arrives locked + Assert.NotNull((await Announced(Page("born locked", commentsEnabled: false))).LockedAt); + + var ban = new JsonObject { ["id"] = NewId(moderator, "bans"), ["type"] = "Block", ["actor"] = moderator.Id, ["object"] = alice.Uri, ["target"] = community.Id }; + await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", ban)); + Assert.Equal(("accepted", "banned"), (Processed("Announce").Outcome, Processed("Announce").Reason)); + var banned = await Reply(post); + Assert.Equal((422, "Validation failed: This community banned you"), (banned.Status, banned.Error)); + await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(moderator, "Undo", ban))); + Assert.Equal(("accepted", "unbanned"), (Processed("Announce").Outcome, Processed("Announce").Reason)); + Assert.True((await Reply(post)).Ok); + + // Lemmy also sends the ban straight to the persona's server, as its moderator's Block: still the community's ban, + // never the moderator's own block of the persona + var direct = new JsonObject { ["id"] = NewId(moderator, "bans"), ["type"] = "Block", ["actor"] = moderator.Id, ["object"] = alice.Uri, ["target"] = community.Id }; + await _harness.Deliver(moderator, "/human-centipede", direct); + Assert.Equal(("accepted", "banned"), (Processed("Block").Outcome, Processed("Block").Reason)); + Assert.False(await DB.Default.Find().Match(b => b.AvatarId == alice.Id && b.ActorURI == moderator.Id).ExecuteAnyAsync(token)); + Assert.Equal(422, (await Reply(post)).Status); + await _harness.Deliver(moderator, "/human-centipede", Activity(moderator, "Undo", direct)); + Assert.True((await Reply(post)).Ok); + + var elsewhere = new JsonObject { ["id"] = NewId(moderator, "bans"), ["type"] = "Block", ["actor"] = moderator.Id, ["object"] = poster.Id, ["target"] = community.Id }; + await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", elsewhere)); + Assert.Equal(("dropped", "not-ours"), (Processed("Announce").Outcome, Processed("Announce").Reason)); + } + // a voter on another server than the community is believed for the community's own posts, as Lemmy trusts it; for // anything else only once its vote is fetched from its own origin [Fact] diff --git a/PrivaPub/Api/Mastodon/Entities/Entities.cs b/PrivaPub/Api/Mastodon/Entities/Entities.cs index e98d7b3..6762d95 100644 --- a/PrivaPub/Api/Mastodon/Entities/Entities.cs +++ b/PrivaPub/Api/Mastodon/Entities/Entities.cs @@ -65,6 +65,7 @@ public class PrivaPubStatus { public string ObjectType { get; set; } + public bool Locked { get; set; }//its community's moderators locked it: a reply is refused public string Title { get; set; } public string Excerpt { get; set; } public string Cover { get; set; } diff --git a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs index f325496..0523aaa 100644 --- a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs +++ b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs @@ -447,6 +447,7 @@ namespace PrivaPub.Api.Mastodon.Mappers PrivaPubStatus Extension(PostEntity post) => new() { ObjectType = post.ObjectType, + Locked = post.LockedAt.HasValue, Title = post.IsFederatedCopy ? post.Title : default, Excerpt = post.Excerpt, Cover = Proxied(post.CoverURL), diff --git a/PrivaPub/Domain/Statuses/StatusService.cs b/PrivaPub/Domain/Statuses/StatusService.cs index f46fab0..d42230f 100644 --- a/PrivaPub/Domain/Statuses/StatusService.cs +++ b/PrivaPub/Domain/Statuses/StatusService.cs @@ -129,6 +129,9 @@ namespace PrivaPub.Domain.Statuses return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); if (parent != default && !await VisibilityPolicy.CanSee(parent, author.Id, token)) return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); + // its community's moderators locked the thread + if (parent is { LockedAt: not null }) + return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This thread is locked"); // a reply in a circle stays in the circle, whichever client wrote it: Mastodon clients know nothing of groups if (group == default && parent is { Visibility: PostVisibility.Circle } && !string.IsNullOrEmpty(parent.GroupId) && await _dbEntities.Groups.MatchID(parent.GroupId).ExecuteFirstAsync(token) is { DeletionAt: null } parentCircle @@ -172,6 +175,10 @@ namespace PrivaPub.Domain.Statuses break; } } + // a community that banned the persona takes nothing from it, a post or a reply + var community = audienceUri ?? parent?.AudienceURI; + if (community != default && await DB.Default.Find().Match(b => b.AvatarId == author.Id && b.ActorURI == community).ExecuteAnyAsync(token)) + return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This community banned you"); var isLocalOnly = located; var visibility = located ? PostVisibility.LocalGeo : group is { IsCircle: true } ? PostVisibility.Circle diff --git a/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs b/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs index 44d9bce..eb02e6b 100644 --- a/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs @@ -7,6 +7,7 @@ using PrivaPub.Domain.Timelines; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Objects; using PrivaPub.Infrastructure.Ids; +using PrivaPub.Models.Federation; using PrivaPub.Models.Post; using PrivaPub.Models.Social; using PrivaPub.Models.User; @@ -55,7 +56,7 @@ namespace PrivaPub.Federation.Inbox.Handlers public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) { var inner = activity["object"]; - if (inner is JsonObject && Value(inner, "type") is "Create" or "Update" or "Delete" or "Like" or "Dislike" or "Undo" or "Add" or "Remove" or "Block") + if (inner is JsonObject && Value(inner, "type") is "Create" or "Update" or "Delete" or "Like" or "Dislike" or "Undo" or "Add" or "Remove" or "Block" or "Lock") { await GroupActivity(inner, actor, token); return; @@ -205,6 +206,21 @@ namespace PrivaPub.Federation.Inbox.Handlers Arrival.Accept("removed"); await RemoteDeletes.Remove(deleted, objectUri, token); break; + case "Lock" when objectUri != default: + await Lock(objectUri, group, true, token); + break; + case "Undo" when Value(inner["object"], "type") == "Lock": + await Lock(Id(inner["object"]?["object"]), group, false, token); + break; + case "Block" when Id(inner["target"]) == group.ActorURI: + await Ban(inner, group, token); + break; + case "Undo" when Value(inner["object"], "type") == "Block" && Id(inner["object"]?["target"]) == group.ActorURI: + if (await BlockHandler.Undo(inner["object"], Id(inner["object"]), group, _localActors, token)) + Arrival.Accept("unbanned"); + else + Arrival.Drop("unknown-object"); + break; case "Like" or "Dislike" or "Undo": await Relayed(inner, group, token); break; @@ -214,6 +230,44 @@ namespace PrivaPub.Federation.Inbox.Handlers } } + // A community's moderators lock one of its posts, or unlock it: no more replies, ours included. The community vouches + // for what is done to its own posts. + async Task Lock(string objectUri, ForeignAvatar group, bool locked, CancellationToken token) + { + var post = objectUri == default + ? default + : await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI).ExecuteFirstAsync(token); + if (post == default) + { + Arrival.Drop("unknown-object"); + return; + } + Arrival.About(visibility: post.Visibility, created: post.CreationDate); + await DB.Default.Update().MatchID(post.ID).Modify(p => p.LockedAt, locked ? DateTime.UtcNow : null).ExecuteAsync(token); + Arrival.Accept(locked ? "locked" : "unlocked"); + } + + // A community bans one of our personas (Lemmy's Block with the community as its target): kept as the community + // blocking the persona, so its relationship says blocked_by and nothing of the persona's is posted there until the + // Undo. A ban of someone from another server is no business of ours. + async Task Ban(JsonNode inner, ForeignAvatar group, CancellationToken token) + { + if (Id(inner["object"]) is not { } uri || await _localActors.FindByUri(uri, token) is not { Kind: LocalActorKind.Person } persona) + { + Arrival.Drop("not-ours"); + return; + } + try + { + await DB.Default.SaveAsync(new BlockedBy { AvatarId = persona.Id, ActorURI = group.ActorURI, AccountId = group.ID, ActivityURI = Id(inner) }, token); + Arrival.Accept("banned"); + } + catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + Arrival.Drop("duplicate"); + } + } + // A vote, or its undoing, that a community relays (Lemmy, PieFed and Mbin send them so). The community vouches for // what accounts on its own server do and for what is done to its own posts, as Lemmy trusts it (refetching every vote // would not scale); anything else is believed only once fetched from its own origin. It is then handled as if its diff --git a/PrivaPub/Federation/Inbox/Handlers/BlockHandler.cs b/PrivaPub/Federation/Inbox/Handlers/BlockHandler.cs index 41152e9..5915960 100644 --- a/PrivaPub/Federation/Inbox/Handlers/BlockHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/BlockHandler.cs @@ -2,6 +2,7 @@ using MongoDB.Driver; using MongoDB.Entities; using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Objects; using PrivaPub.Models.Federation; using PrivaPub.Models.Social; using PrivaPub.Models.User; @@ -37,6 +38,11 @@ namespace PrivaPub.Federation.Inbox.Handlers Arrival.Drop("unknown-object"); return; } + if (Community(activity, actor) is { } community) + { + await Ban(activity, target, community, token); + return; + } try { await DB.Default.SaveAsync(new BlockedBy { AvatarId = target.Id, ActorURI = actor.ActorURI, AccountId = actor.ID, ActivityURI = Id(activity) }, token); @@ -54,15 +60,44 @@ namespace PrivaPub.Federation.Inbox.Handlers await DB.Default.DeleteAsync(n => n.AvatarId == target.Id && n.FromAccountId == actor.ID); } - // Undo{Block}: the block it names by id, or else the blocker's block of the persona the inner Block names + // Lemmy also sends a community's ban to the banned account's own server, as its moderator's Block with the community + // as `target`: a ban from that community, as the community's Announce brings it, never the moderator's own block. The + // moderator must be on the community's server. + static string Community(JsonNode block, ForeignAvatar actor) => + block is JsonObject && Id(block["target"]) is { } community && community != actor.ActorURI && Origin.Same(community, actor.ActorURI) + ? community + : default; + + async Task Ban(JsonNode activity, LocalActor persona, string communityUri, CancellationToken token) + { + var community = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == communityUri && f.AvatarType == AvatarType.Group).ExecuteFirstAsync(token); + if (community == default) + { + Arrival.Drop("unknown-community"); + return; + } + try + { + await DB.Default.SaveAsync(new BlockedBy { AvatarId = persona.Id, ActorURI = community.ActorURI, AccountId = community.ID, ActivityURI = Id(activity) }, token); + Arrival.Accept("banned"); + } + catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + Arrival.Drop("duplicate"); + } + } + + // Undo{Block}: the block it names by id, or else the blocker's block of the persona the inner Block names; a + // community's ban sent by its moderator is the community's public static async Task Undo(JsonNode inner, string innerId, ForeignAvatar actor, ILocalActorService localActors, CancellationToken token) { + var by = Community(inner, actor) ?? actor.ActorURI; var block = innerId == default ? default - : await DB.Default.Find().Match(b => b.ActivityURI == innerId && b.ActorURI == actor.ActorURI).ExecuteFirstAsync(token); + : await DB.Default.Find().Match(b => b.ActivityURI == innerId && b.ActorURI == by).ExecuteFirstAsync(token); if (block == default && inner is JsonObject && Id(inner["object"]) is { } objectUri && await localActors.FindByUri(objectUri, token) is { Kind: LocalActorKind.Person } persona) - block = await DB.Default.Find().Match(b => b.AvatarId == persona.Id && b.ActorURI == actor.ActorURI).ExecuteFirstAsync(token); + block = await DB.Default.Find().Match(b => b.AvatarId == persona.Id && b.ActorURI == by).ExecuteFirstAsync(token); if (block == default) return false; await DB.Default.DeleteAsync(block.ID); diff --git a/PrivaPub/Federation/Inbox/RemoteEdits.cs b/PrivaPub/Federation/Inbox/RemoteEdits.cs index afb9327..3a4507b 100644 --- a/PrivaPub/Federation/Inbox/RemoteEdits.cs +++ b/PrivaPub/Federation/Inbox/RemoteEdits.cs @@ -34,6 +34,8 @@ namespace PrivaPub.Federation.Inbox post.Audio = note.Audio ?? post.Audio; post.Event = note.Event ?? post.Event; post.Place = note.Place; + if (note.CommentsEnabled is { } enabled) + post.LockedAt = enabled ? null : post.LockedAt ?? DateTime.UtcNow; if (!IsEdit(note, post)) { await DB.Default.SaveAsync(post, token); diff --git a/PrivaPub/Federation/Inbox/RemotePosts.cs b/PrivaPub/Federation/Inbox/RemotePosts.cs index 87b8bb1..7032db0 100644 --- a/PrivaPub/Federation/Inbox/RemotePosts.cs +++ b/PrivaPub/Federation/Inbox/RemotePosts.cs @@ -108,6 +108,7 @@ namespace PrivaPub.Federation.Inbox Video = note.Video, Audio = note.Audio, Event = note.Event, + LockedAt = note.CommentsEnabled == false ? DateTime.UtcNow : null, Place = note.Place, HasContentWarning = note.Sensitive, Text = note.ContentHtml, diff --git a/PrivaPub/Federation/Objects/NoteParser.cs b/PrivaPub/Federation/Objects/NoteParser.cs index a5ff96b..ebd3bda 100644 --- a/PrivaPub/Federation/Objects/NoteParser.cs +++ b/PrivaPub/Federation/Objects/NoteParser.cs @@ -45,6 +45,7 @@ namespace PrivaPub.Federation.Objects public AudioDetails Audio { get; init; } public EventDetails Event { get; init; } public PostPlace Place { get; init; } + public bool? CommentsEnabled { get; init; }//Lemmy, PieFed and Mbin: false once its moderators lock it; null when not said } public static class NoteParser @@ -112,7 +113,8 @@ namespace PrivaPub.Federation.Objects Video = ObjectShapes.Video(note), Audio = ObjectShapes.Audio(note), Event = ObjectShapes.Event(note), - Place = ObjectShapes.NotePlace(note) + Place = ObjectShapes.NotePlace(note), + CommentsEnabled = note["commentsEnabled"] is JsonValue comments && comments.TryGetValue(out var enabled) ? enabled : null }; } diff --git a/PrivaPub/Models/Post/Post.cs b/PrivaPub/Models/Post/Post.cs index 05710dc..77fd592 100644 --- a/PrivaPub/Models/Post/Post.cs +++ b/PrivaPub/Models/Post/Post.cs @@ -60,6 +60,7 @@ namespace PrivaPub.Models.Post public string ActorURI { get; set; }//attributedTo public string Url { get; set; } public string ContextURI { get; set; } + public DateTime? LockedAt { get; set; }//no more replies: its community's moderators locked it (Lemmy's Lock, or commentsEnabled false) public string QuoteURI { get; set; } public string QuotedPostId { get; set; }//our copy of the quoted post, once fetched public QuoteState QuoteState { get; set; } diff --git a/docs/INTEROP.md b/docs/INTEROP.md index 943fdf4..140e45f 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -512,7 +512,9 @@ on a Page: pins live in `featured`, locks in `Lock`. | Read 1.0 `context`, grouped by root post; cross-post detection by URL | P3 | — | **Pasture evidence (2026-10-03, Lemmy 1.0.0-beta.2, `tools/pasture/scenarios/lemmy.sh`):** 20 checks pass and 3 are -expected failures: +expected failures. On 2026-10-05 the scenario runs 29 checks, all passing: the relayed votes, and a moderator's lock +(refusing our reply, then lifted), a ban of alice (`blocked_by` on the community, refusing her reply, then lifted) and a +removal. The 2026-10-03 run covered: - communities both ways: Lemmy follows ours and alice follows Lemmy's, each Accept arriving; - a Lemmy thread in our community arrives with its title, and our titled community post reaches Lemmy; - the Lemmy community's Announce brings its thread to alice's home; @@ -529,8 +531,10 @@ What it showed: - **Every bare `Announce{object}` is answered 400** (`Failed to parse object`: Lemmy dereferences it expecting an activity), as Lemmy answers the compatibility `Announce(Page)` it sends itself. Both 400s show up as dead deliveries in the statistics. -- **Votes travel only to the community**, which relays them as `Announce{Like}` and `Announce{Dislike}`. They are - dropped as `unsupported` until P7 (expected failures), as is a moderator's removal. +- **Votes travel only to the community**, which relays them as `Announce{Like}` and `Announce{Dislike}`: counted since + ed08f80, and a vote turned the other way replaces the first since bbeeda7. A moderator's removal, lock and ban come + the same way and are applied since 2026-10-05 (the removal was applied all along; the scenario gave up before Lemmy's + 30-second batch). - Lemmy logs no refused activity at `warn`; the reason is in the 400's body, which our delivery does not keep. The scenario's API notes: `sort` values are lowercase (`new`), private messages and mentions are in `account/notification/list`, and `resolve_object` takes both `!community@host` and `@user@host`. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e759158..627e122 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -618,7 +618,8 @@ it, raw where it doesn't. - group by the root post; - publish our own `context` and a paged `replies`. - **Lemmy, PieFed and Mbin:** - - the moderation set: removals, locks, bans, featured, moderators, `Warn`, `Resolve`; + - the moderation set: removals, locks, bans (**done 2026-10-05**, relayed by the community: a lock refuses replies, a + ban of a persona refuses its posts there and shows as `blocked_by`), featured, moderators, `Warn`, `Resolve`; - votes in and out; link posts; flairs; `Feed` actors; community polls; post `Move`; - the outbound shape Lemmy requires; - communities we host announce to the author's own instance too; diff --git a/tools/pasture/scenarios/lemmy.sh b/tools/pasture/scenarios/lemmy.sh index 0f4c6df..389c818 100644 --- a/tools/pasture/scenarios/lemmy.sh +++ b/tools/pasture/scenarios/lemmy.sh @@ -1,5 +1,5 @@ # Lemmy 1.0: communities both ways (FEP-1b12), threads with titles, comments both ways, votes up and down both ways, -# private messages both ways, a moderator's removal (P7), statistics. Lemmy is driven through its v4 API. +# private messages both ways, a moderator's lock, ban and removal, statistics. Lemmy is driven through its v4 API. LM=https://lemmy.test:6443 LT=$(cat "$here/.state/lemmy.token" 2>/dev/null) # lm [json]: a Lemmy v4 API call as lemmyuser @@ -97,13 +97,26 @@ curl -s -o /dev/null -X POST -H "$LAH" $P/api/v1/statuses -d 'status=@lemmyuser@ until_true 30 'lm GET "account/notification/list?limit=50" | grep -q "a secret from PrivaPub"' && ok "alice's DM arrives as a Lemmy private message" || ko "DM missing on Lemmy" echo " moderation" +# a lock and a ban, relayed by the community inside its Announce (G-0006); Lemmy sends what it queued every 30 seconds +# (Lemmy 1.0 refuses a lock, an unlock or an unban without a reason, and then federates nothing) +p_locked() { curl -s -H "$LAH" "$P/api/v1/statuses/$lm_cats_on_p" | j "print((d.get('privapub') or {}).get('locked'))"; } +p_reply() { curl -s -o /dev/null -w '%{http_code}' -X POST -H "$LAH" $P/api/v1/statuses -d "status=$1&in_reply_to_id=$lm_cats_on_p&visibility=public"; } +p_banned() { curl -s -H "$LAH" "$P/api/v1/accounts/relationships?id[]=$cats_on_p" | j "print(d[0]['blocked_by'])"; } +lm POST post/lock "{\"post_id\":$lm_cats_id,\"locked\":true,\"reason\":\"pasture\"}" >/dev/null +until_true 45 '[ "$(p_locked)" = "True" ]' && ok "a moderator's lock reaches PrivaPub" || ko "a moderator's lock did not reach PrivaPub" +[ "$(p_reply "too late")" = "422" ] && ok "a reply to the locked thread is refused" || ko "a reply to the locked thread was taken" +lm POST post/lock "{\"post_id\":$lm_cats_id,\"locked\":false,\"reason\":\"pasture\"}" >/dev/null +until_true 45 '[ "$(p_locked)" = "False" ]' && ok "a moderator's unlock reaches PrivaPub" || ko "a moderator's unlock did not reach PrivaPub" +lm POST community/ban_user "{\"community_id\":$cats_on_lm,\"person_id\":$alice_on_lm,\"ban\":true,\"reason\":\"pasture\"}" >/dev/null +until_true 45 '[ "$(p_banned)" = "True" ]' && ok "the community's ban of alice_lemmy reaches PrivaPub (blocked_by)" || ko "the community's ban did not reach PrivaPub" +[ "$(p_reply "while banned")" = "422" ] && ok "a banned persona's reply in the community is refused" || ko "a banned persona's reply was taken" +lm POST community/ban_user "{\"community_id\":$cats_on_lm,\"person_id\":$alice_on_lm,\"ban\":false,\"reason\":\"pasture\"}" >/dev/null +until_true 45 '[ "$(p_banned)" = "False" ]' && ok "the community's unban reaches PrivaPub" || ko "the community's unban did not reach PrivaPub" +# the community relays it as Announce{Delete}, believed once Lemmy answers 410 for the post; Lemmy sends what it queued +# every 30 seconds lm POST post/remove "{\"post_id\":$lm_cats_id,\"removed\":true,\"reason\":\"pasture\"}" >/dev/null -sleep 10 -if curl -s -H "$LAH" "$P/api/v1/timelines/home" | j "print(any((s.get('reblog') or s)['uri']=='$lm_cats_thread' for s in d))" | grep -q False; then - ok "a moderator's removal reaches PrivaPub" -else - xf "a moderator's removal reaches PrivaPub (the Lemmy moderation set is P7)" -fi +until_true 45 'curl -s -H "$LAH" "$P/api/v1/timelines/home" | j "print(any((s.get(\"reblog\") or s)[\"uri\"]==\"$lm_cats_thread\" for s in d))" | grep -q False' \ + && ok "a moderator's removal reaches PrivaPub" || ko "a moderator's removal did not reach PrivaPub" echo " statistics" stats_check lemmy.test lemmy diff --git a/tools/pasture/town/gaps.json b/tools/pasture/town/gaps.json index b4b5696..6d82cc1 100644 --- a/tools/pasture/town/gaps.json +++ b/tools/pasture/town/gaps.json @@ -43,8 +43,10 @@ "phase": "P7", "code": "PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs:203", "opened": "2026-10-01", - "status": "open", - "note": "Relayed likes count since ed08f80; what stays open is moderation relayed by a Lemmy community (Delete, Block, Lock). Communities hosted here count their likes as any post does." + "status": "closed", + "note": "Relayed likes count since ed08f80, and a vote turned the other way replaces the first since bbeeda7. A moderator's removal, relayed as Announce{Delete}, was handled all along (believed once Lemmy answers 410); the scenario only gave up before Lemmy's 30-second batch. Locks and community bans are G-0006.", + "closed": "2026-10-05", + "fixed_in": "ed08f80 (relayed votes), bbeeda7 (a vote turned the other way)" }, { "id": "G-0004", @@ -74,5 +76,22 @@ "opened": "2026-10-05", "status": "open", "note": "the renote row is gone after our Undo (checked in its database); renoteCount stays up. Sharkey decrements it." + }, + { + "id": "G-0006", + "title": "Locks and community bans that a Lemmy community relays inside Announce (Lock, Block) are dropped", + "match": { + "feature": "moderation\\.(lock|ban)\\.community", + "observer": "privapub", + "origin": "lemmy" + }, + "kind": "server", + "phase": "P7", + "code": "PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs (the default case)", + "opened": "2026-10-05", + "status": "closed", + "note": "Locks (Announce{Lock}, its Undo, or commentsEnabled false) refuse replies; a ban (Announce{Block} with the community as target, or the moderator's own Block sent straight to us) shows as blocked_by on the community and refuses the persona there until the Undo. Checked live by the Lemmy scenario; no town cell yet.", + "closed": "2026-10-05", + "fixed_in": "A community's moderators lock threads and ban members (2026-10-05)" } ]