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
@@ -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<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
|
||||
// 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
|
||||
|
||||
@@ -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<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)
|
||||
{
|
||||
var by = Community(inner, actor) ?? actor.ActorURI;
|
||||
var block = innerId == 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
|
||||
&& 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)
|
||||
return false;
|
||||
await DB.Default.DeleteAsync<BlockedBy>(block.ID);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<bool>(out var enabled) ? enabled : null
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user