A community's moderators lock threads and ban members
Lemmy's moderation reached PrivaPub only as removals. Now a remote
community's lock and ban, relayed in its Announce, apply too:
- a lock (Announce{Lock}, or commentsEnabled false on the post) refuses
replies to the thread, ours included, until Undo{Lock}; statuses say so
in privapub.locked;
- a ban of a persona (Announce{Block} with the community as target, or the
moderator's own Block sent straight to us, which is the community's ban
and never the moderator's block of the persona) shows as blocked_by on
the community and refuses the persona's posts and replies there until
the Undo.
The Lemmy scenario's removal was an expected failure only because it gave
up before Lemmy's 30-second batch; it now waits, and checks the lock and
the ban live (Lemmy refuses a lock or an unban without a reason): 29
checks, none expected to fail. G-0003 and G-0006 are closed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
4a9a3d3235
commit
34c0f696af
16 files changed
+231
-20
No files matched your search
@@ -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
|
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
|
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
|
`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.
|
server started, so the scenario waits for that worker (`lm_worker`) before its first follow, and it sends what it
|
||||||
22 checks; a moderator's removal is an expected failure (P7).
|
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
|
- **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
|
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.
|
`SECRET_KEY`, a media directory and a `themeColor`; statuses and votes go as JSON. Town only, no scenario.
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ A group is either a **community** or a **circle**.
|
|||||||
Mastodon member's replies reach only the people they mention.
|
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
|
- 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.
|
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
|
## Activities
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,71 @@ namespace PrivaPub.Tests.Federation
|
|||||||
Assert.False(await DB.Default.Find<Favourite>().Match(f => f.PostId == post.ID).ExecuteAnyAsync(token));
|
Assert.False(await DB.Default.Find<Favourite>().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<Following>().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, $"<p>{text}</p>", 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<Post> Announced(JsonObject page)
|
||||||
|
{
|
||||||
|
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, page)));
|
||||||
|
return await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(page)).ExecuteSingleAsync(token);
|
||||||
|
}
|
||||||
|
Task<StatusOutcome> 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<Post>().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<BlockedBy>().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
|
// 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
|
// anything else only once its vote is fetched from its own origin
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
public class PrivaPubStatus
|
public class PrivaPubStatus
|
||||||
{
|
{
|
||||||
public string ObjectType { get; set; }
|
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 Title { get; set; }
|
||||||
public string Excerpt { get; set; }
|
public string Excerpt { get; set; }
|
||||||
public string Cover { get; set; }
|
public string Cover { get; set; }
|
||||||
|
|||||||
@@ -447,6 +447,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
|||||||
PrivaPubStatus Extension(PostEntity post) => new()
|
PrivaPubStatus Extension(PostEntity post) => new()
|
||||||
{
|
{
|
||||||
ObjectType = post.ObjectType,
|
ObjectType = post.ObjectType,
|
||||||
|
Locked = post.LockedAt.HasValue,
|
||||||
Title = post.IsFederatedCopy ? post.Title : default,
|
Title = post.IsFederatedCopy ? post.Title : default,
|
||||||
Excerpt = post.Excerpt,
|
Excerpt = post.Excerpt,
|
||||||
Cover = Proxied(post.CoverURL),
|
Cover = Proxied(post.CoverURL),
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
||||||
if (parent != default && !await VisibilityPolicy.CanSee(parent, author.Id, token))
|
if (parent != default && !await VisibilityPolicy.CanSee(parent, author.Id, token))
|
||||||
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
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
|
// 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)
|
if (group == default && parent is { Visibility: PostVisibility.Circle } && !string.IsNullOrEmpty(parent.GroupId)
|
||||||
&& await _dbEntities.Groups.MatchID(parent.GroupId).ExecuteFirstAsync(token) is { DeletionAt: null } parentCircle
|
&& await _dbEntities.Groups.MatchID(parent.GroupId).ExecuteFirstAsync(token) is { DeletionAt: null } parentCircle
|
||||||
@@ -172,6 +175,10 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
break;
|
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<BlockedBy>().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 isLocalOnly = located;
|
||||||
var visibility = located ? PostVisibility.LocalGeo
|
var visibility = located ? PostVisibility.LocalGeo
|
||||||
: group is { IsCircle: true } ? PostVisibility.Circle
|
: group is { IsCircle: true } ? PostVisibility.Circle
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using PrivaPub.Domain.Timelines;
|
|||||||
using PrivaPub.Federation.Actors;
|
using PrivaPub.Federation.Actors;
|
||||||
using PrivaPub.Federation.Objects;
|
using PrivaPub.Federation.Objects;
|
||||||
using PrivaPub.Infrastructure.Ids;
|
using PrivaPub.Infrastructure.Ids;
|
||||||
|
using PrivaPub.Models.Federation;
|
||||||
using PrivaPub.Models.Post;
|
using PrivaPub.Models.Post;
|
||||||
using PrivaPub.Models.Social;
|
using PrivaPub.Models.Social;
|
||||||
using PrivaPub.Models.User;
|
using PrivaPub.Models.User;
|
||||||
@@ -55,7 +56,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
|
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
|
||||||
{
|
{
|
||||||
var inner = activity["object"];
|
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);
|
await GroupActivity(inner, actor, token);
|
||||||
return;
|
return;
|
||||||
@@ -205,6 +206,21 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
Arrival.Accept("removed");
|
Arrival.Accept("removed");
|
||||||
await RemoteDeletes.Remove(deleted, objectUri, token);
|
await RemoteDeletes.Remove(deleted, objectUri, token);
|
||||||
break;
|
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":
|
case "Like" or "Dislike" or "Undo":
|
||||||
await Relayed(inner, group, token);
|
await Relayed(inner, group, token);
|
||||||
break;
|
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<PostEntity>().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
|
// 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
|
// 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
|
// would not scale); anything else is believed only once fetched from its own origin. It is then handled as if its
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using MongoDB.Driver;
|
|||||||
using MongoDB.Entities;
|
using MongoDB.Entities;
|
||||||
|
|
||||||
using PrivaPub.Federation.Actors;
|
using PrivaPub.Federation.Actors;
|
||||||
|
using PrivaPub.Federation.Objects;
|
||||||
using PrivaPub.Models.Federation;
|
using PrivaPub.Models.Federation;
|
||||||
using PrivaPub.Models.Social;
|
using PrivaPub.Models.Social;
|
||||||
using PrivaPub.Models.User;
|
using PrivaPub.Models.User;
|
||||||
@@ -37,6 +38,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
Arrival.Drop("unknown-object");
|
Arrival.Drop("unknown-object");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (Community(activity, actor) is { } community)
|
||||||
|
{
|
||||||
|
await Ban(activity, target, community, token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await DB.Default.SaveAsync(new BlockedBy { AvatarId = target.Id, ActorURI = actor.ActorURI, AccountId = actor.ID, ActivityURI = Id(activity) }, token);
|
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<Notification>(n => n.AvatarId == target.Id && n.FromAccountId == actor.ID);
|
await DB.Default.DeleteAsync<Notification>(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<bool> Undo(JsonNode inner, string innerId, ForeignAvatar actor, ILocalActorService localActors, CancellationToken token)
|
public static async Task<bool> Undo(JsonNode inner, string innerId, ForeignAvatar actor, ILocalActorService localActors, CancellationToken token)
|
||||||
{
|
{
|
||||||
|
var by = Community(inner, actor) ?? actor.ActorURI;
|
||||||
var block = innerId == default
|
var block = innerId == default
|
||||||
? default
|
? default
|
||||||
: await DB.Default.Find<BlockedBy>().Match(b => b.ActivityURI == innerId && b.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
|
: await DB.Default.Find<BlockedBy>().Match(b => b.ActivityURI == innerId && b.ActorURI == by).ExecuteFirstAsync(token);
|
||||||
if (block == default && inner is JsonObject && Id(inner["object"]) is { } objectUri
|
if (block == default && inner is JsonObject && Id(inner["object"]) is { } objectUri
|
||||||
&& await localActors.FindByUri(objectUri, token) is { Kind: LocalActorKind.Person } persona)
|
&& await localActors.FindByUri(objectUri, token) is { Kind: LocalActorKind.Person } persona)
|
||||||
block = await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == persona.Id && b.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
|
block = await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == persona.Id && b.ActorURI == by).ExecuteFirstAsync(token);
|
||||||
if (block == default)
|
if (block == default)
|
||||||
return false;
|
return false;
|
||||||
await DB.Default.DeleteAsync<BlockedBy>(block.ID);
|
await DB.Default.DeleteAsync<BlockedBy>(block.ID);
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
post.Audio = note.Audio ?? post.Audio;
|
post.Audio = note.Audio ?? post.Audio;
|
||||||
post.Event = note.Event ?? post.Event;
|
post.Event = note.Event ?? post.Event;
|
||||||
post.Place = note.Place;
|
post.Place = note.Place;
|
||||||
|
if (note.CommentsEnabled is { } enabled)
|
||||||
|
post.LockedAt = enabled ? null : post.LockedAt ?? DateTime.UtcNow;
|
||||||
if (!IsEdit(note, post))
|
if (!IsEdit(note, post))
|
||||||
{
|
{
|
||||||
await DB.Default.SaveAsync(post, token);
|
await DB.Default.SaveAsync(post, token);
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
Video = note.Video,
|
Video = note.Video,
|
||||||
Audio = note.Audio,
|
Audio = note.Audio,
|
||||||
Event = note.Event,
|
Event = note.Event,
|
||||||
|
LockedAt = note.CommentsEnabled == false ? DateTime.UtcNow : null,
|
||||||
Place = note.Place,
|
Place = note.Place,
|
||||||
HasContentWarning = note.Sensitive,
|
HasContentWarning = note.Sensitive,
|
||||||
Text = note.ContentHtml,
|
Text = note.ContentHtml,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ namespace PrivaPub.Federation.Objects
|
|||||||
public AudioDetails Audio { get; init; }
|
public AudioDetails Audio { get; init; }
|
||||||
public EventDetails Event { get; init; }
|
public EventDetails Event { get; init; }
|
||||||
public PostPlace Place { 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
|
public static class NoteParser
|
||||||
@@ -112,7 +113,8 @@ namespace PrivaPub.Federation.Objects
|
|||||||
Video = ObjectShapes.Video(note),
|
Video = ObjectShapes.Video(note),
|
||||||
Audio = ObjectShapes.Audio(note),
|
Audio = ObjectShapes.Audio(note),
|
||||||
Event = ObjectShapes.Event(note),
|
Event = ObjectShapes.Event(note),
|
||||||
Place = ObjectShapes.NotePlace(note)
|
Place = ObjectShapes.NotePlace(note),
|
||||||
|
CommentsEnabled = note["commentsEnabled"] is JsonValue comments && comments.TryGetValue<bool>(out var enabled) ? enabled : null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ namespace PrivaPub.Models.Post
|
|||||||
public string ActorURI { get; set; }//attributedTo
|
public string ActorURI { get; set; }//attributedTo
|
||||||
public string Url { get; set; }
|
public string Url { get; set; }
|
||||||
public string ContextURI { 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 QuoteURI { get; set; }
|
||||||
public string QuotedPostId { get; set; }//our copy of the quoted post, once fetched
|
public string QuotedPostId { get; set; }//our copy of the quoted post, once fetched
|
||||||
public QuoteState QuoteState { get; set; }
|
public QuoteState QuoteState { get; set; }
|
||||||
|
|||||||
+7
-3
@@ -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 | — |
|
| 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
|
**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;
|
- 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;
|
- 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;
|
- 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
|
- **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
|
activity), as Lemmy answers the compatibility `Announce(Page)` it sends itself. Both 400s show up as dead deliveries
|
||||||
in the statistics.
|
in the statistics.
|
||||||
- **Votes travel only to the community**, which relays them as `Announce{Like}` and `Announce{Dislike}`. They are
|
- **Votes travel only to the community**, which relays them as `Announce{Like}` and `Announce{Dislike}`: counted since
|
||||||
dropped as `unsupported` until P7 (expected failures), as is a moderator's removal.
|
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
|
- 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
|
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`.
|
`account/notification/list`, and `resolve_object` takes both `!community@host` and `@user@host`.
|
||||||
|
|||||||
+2
-1
@@ -618,7 +618,8 @@ it, raw where it doesn't.
|
|||||||
- group by the root post;
|
- group by the root post;
|
||||||
- publish our own `context` and a paged `replies`.
|
- publish our own `context` and a paged `replies`.
|
||||||
- **Lemmy, PieFed and Mbin:**
|
- **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`;
|
- votes in and out; link posts; flairs; `Feed` actors; community polls; post `Move`;
|
||||||
- the outbound shape Lemmy requires;
|
- the outbound shape Lemmy requires;
|
||||||
- communities we host announce to the author's own instance too;
|
- communities we host announce to the author's own instance too;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Lemmy 1.0: communities both ways (FEP-1b12), threads with titles, comments both ways, votes up and down both ways,
|
# 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
|
LM=https://lemmy.test:6443
|
||||||
LT=$(cat "$here/.state/lemmy.token" 2>/dev/null)
|
LT=$(cat "$here/.state/lemmy.token" 2>/dev/null)
|
||||||
# lm <method> <path> [json]: a Lemmy v4 API call as lemmyuser
|
# lm <method> <path> [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"
|
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"
|
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
|
lm POST post/remove "{\"post_id\":$lm_cats_id,\"removed\":true,\"reason\":\"pasture\"}" >/dev/null
|
||||||
sleep 10
|
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' \
|
||||||
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" || ko "a moderator's removal did not reach PrivaPub"
|
||||||
ok "a moderator's removal reaches PrivaPub"
|
|
||||||
else
|
|
||||||
xf "a moderator's removal reaches PrivaPub (the Lemmy moderation set is P7)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo " statistics"
|
echo " statistics"
|
||||||
stats_check lemmy.test lemmy
|
stats_check lemmy.test lemmy
|
||||||
@@ -43,8 +43,10 @@
|
|||||||
"phase": "P7",
|
"phase": "P7",
|
||||||
"code": "PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs:203",
|
"code": "PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs:203",
|
||||||
"opened": "2026-10-01",
|
"opened": "2026-10-01",
|
||||||
"status": "open",
|
"status": "closed",
|
||||||
"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."
|
"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",
|
"id": "G-0004",
|
||||||
@@ -74,5 +76,22 @@
|
|||||||
"opened": "2026-10-05",
|
"opened": "2026-10-05",
|
||||||
"status": "open",
|
"status": "open",
|
||||||
"note": "the renote row is gone after our Undo (checked in its database); renoteCount stays up. Sharkey decrements it."
|
"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)"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
Reference in new issue
Block a user