From 587997dd671360d9e1a8017418e79496e9d4e1ad Mon Sep 17 00:00:00 2001 From: thepra Date: Wed, 7 Oct 2026 21:10:58 +0200 Subject: [PATCH] P7: Lemmy's Warn and Resolve{Flag} - A Warn from a community's moderator about a persona's own post there becomes that persona's moderation_warning notification (Mastodon's AccountWarning, with the reason and the post), believed from the community's own server or from an account the community's moderators collection lists. Remote communities keep that collection's address (attributedTo) as ForeignAvatar.ModeratorsURL; it is read only when a warning needs it. - A Resolve{Flag} for one of our reports (`/grunts/flag-`) is kept as the report's remote resolution when it comes from the server holding what was reported, or the community it was reported to; our own moderators' resolution is never replaced. The moderators' report list shows both. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB --- FEDERATION.md | 2 + PrivaPub.ClientModels/Admin/ViewReport.cs | 2 + .../Federation/CommunityModerationTests.cs | 115 ++++++++++++++++++ PrivaPub.Tests/Support/Harness.cs | 2 + .../Controllers/TimelinesController.cs | 6 +- PrivaPub/Api/Mastodon/Entities/Entities.cs | 12 ++ .../Api/Mastodon/Mappers/MastodonMapper.cs | 13 +- .../ClientToServer/ModeratorController.cs | 4 +- PrivaPub/Domain/Social/Notifications.cs | 3 +- PrivaPub/Federation/Actors/ActorDocument.cs | 2 + .../Federation/Actors/RemoteActorService.cs | 1 + PrivaPub/Federation/Inbox/Communities.cs | 25 ++++ .../Inbox/Handlers/ResolveHandler.cs | 66 ++++++++++ .../Federation/Inbox/Handlers/WarnHandler.cs | 86 +++++++++++++ .../Middleware/SocialPubConfigurations.cs | 2 + PrivaPub/Models/Social/Notification.cs | 4 +- PrivaPub/Models/Social/Relationships.cs | 2 + PrivaPub/Models/User/Avatar.cs | 1 + docs/ROADMAP.md | 4 +- 19 files changed, 344 insertions(+), 8 deletions(-) create mode 100644 PrivaPub.Tests/Federation/CommunityModerationTests.cs create mode 100644 PrivaPub/Federation/Inbox/Handlers/ResolveHandler.cs create mode 100644 PrivaPub/Federation/Inbox/Handlers/WarnHandler.cs diff --git a/FEDERATION.md b/FEDERATION.md index d2848ce..83949a9 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -185,6 +185,8 @@ 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 | +| `Warn` (Lemmy 1.0) | a community moderator's warning about a persona's post there becomes the persona's `moderation_warning` notification, believed from the community's own server or an account its moderators collection lists | +| `Resolve{Flag}` (Lemmy 1.0) | a report we sent is marked resolved by the server holding what was reported (kept beside our own moderators' resolution) | | `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, though a new post its server publishes under that id after the deletion (Gancio numbers events from the last one kept) is kept | diff --git a/PrivaPub.ClientModels/Admin/ViewReport.cs b/PrivaPub.ClientModels/Admin/ViewReport.cs index 3278918..488dcf2 100644 --- a/PrivaPub.ClientModels/Admin/ViewReport.cs +++ b/PrivaPub.ClientModels/Admin/ViewReport.cs @@ -16,5 +16,7 @@ namespace PrivaPub.ClientModels.Admin public bool IsResolved { get; set; } public DateTime CreatedAt { get; set; } public DateTime? ResolvedAt { get; set; } + public DateTime? RemoteResolvedAt { get; set; }//the server holding what was reported said it dealt with it + public string RemoteResolvedBy { get; set; } } } diff --git a/PrivaPub.Tests/Federation/CommunityModerationTests.cs b/PrivaPub.Tests/Federation/CommunityModerationTests.cs new file mode 100644 index 0000000..5131122 --- /dev/null +++ b/PrivaPub.Tests/Federation/CommunityModerationTests.cs @@ -0,0 +1,115 @@ +using MongoDB.Entities; + +using PrivaPub.ClientModels.Post; +using PrivaPub.Models.Post; +using PrivaPub.Models.Social; +using PrivaPub.Tests.Support; + +using System.Text.Json.Nodes; + +namespace PrivaPub.Tests.Federation +{ + [Trait("Category", "Integration")] + public sealed class CommunityModerationTests : 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(); + } + + static string Origin(string id) => new Uri(id).GetLeftPart(UriPartial.Authority); + + // a Lemmy community on the peer's first origin whose moderators collection lists the given accounts + RemoteActor Community(params RemoteActor[] moderators) + { + var community = new RemoteActor(_harness.Peer, "comm", type: "Group"); + var document = community.Document(); + document["attributedTo"] = community.Id + "/moderators"; + _harness.Peer.Serve($"/users/{community.Name}", document.ToJsonString()); + _harness.Peer.Serve($"/users/{community.Name}/moderators", new JsonObject + { + ["id"] = community.Id + "/moderators", ["type"] = "OrderedCollection", + ["orderedItems"] = new JsonArray(moderators.Select(m => (JsonNode)m.Id).ToArray()) + }.ToJsonString()); + return community; + } + + async Task<(Post Post, PrivaPub.Federation.Actors.LocalActor Alice)> PostIn(RemoteActor community) + { + var token = TestContext.Current.CancellationToken; + var (root, alice) = await _harness.Persona("alice"); + await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "in the community" }, token); + var post = await DB.Default.Find().Match(p => p.GroupUserId == alice.Id).ExecuteFirstAsync(token); + await DB.Default.Update().MatchID(post.ID).Modify(p => p.AudienceURI, community.Id).ExecuteAsync(token); + await _harness.Remote.GetActor(community.Id, refresh: true, token); + return (post, alice); + } + + JsonObject Warn(RemoteActor moderator, RemoteActor community, string persona, string post) => new() + { + ["id"] = $"{Origin(moderator.Id)}/activities/warn/{Guid.NewGuid():N}", ["type"] = "Warn", ["actor"] = moderator.Id, + ["to"] = new JsonArray(persona), ["object"] = post, ["summary"] = "please keep it civil", ["audience"] = community.Id + }; + + [Fact] + public async Task A_warning_from_the_communitys_moderators_reaches_the_persona_and_a_strangers_does_not() + { + var token = TestContext.Current.CancellationToken; + var listed = new RemoteActor(_harness.Peer, "listedmod", origin: _harness.Peer.B); + var stranger = new RemoteActor(_harness.Peer, "stranger", origin: _harness.Peer.B); + var community = Community(listed); + var admin = new RemoteActor(_harness.Peer, "admin"); + var (post, alice) = await PostIn(community); + + await _harness.Deliver(stranger, "/human-centipede", Warn(stranger, community, alice.Uri, post.ObjectURI)); + Assert.False(await DB.Default.Find().Match(n => n.AvatarId == alice.Id && n.Type == NotificationType.ModerationWarning).ExecuteAnyAsync(token)); + + await _harness.Deliver(listed, "/human-centipede", Warn(listed, community, alice.Uri, post.ObjectURI)); + var warning = await DB.Default.Find().Match(n => n.AvatarId == alice.Id && n.Type == NotificationType.ModerationWarning).ExecuteFirstAsync(token); + Assert.Equal("please keep it civil", warning.Text); + Assert.Equal(post.ID, warning.PostId); + + var adminWarning = Warn(admin, community, alice.Uri, post.ObjectURI); + adminWarning["summary"] = "from the server's admin"; + await DB.Default.DeleteAsync(n => n.AvatarId == alice.Id); + await _harness.Deliver(admin, "/human-centipede", adminWarning); + Assert.Equal("from the server's admin", (await DB.Default.Find().Match(n => n.AvatarId == alice.Id && n.Type == NotificationType.ModerationWarning).ExecuteFirstAsync(token)).Text); + } + + [Fact] + public async Task A_reports_resolution_is_kept_only_from_the_server_holding_what_was_reported() + { + var token = TestContext.Current.CancellationToken; + var community = Community(); + var (post, _) = await PostIn(community); + var report = new Report { PostIds = new() { post.ID }, Comment = "spam" }; + await DB.Default.SaveAsync(report, token); + var flag = $"{_harness.Local.BaseAddress}/peasants/privapub_reports/grunts/flag-{report.ID}-1"; + JsonObject Resolve(RemoteActor by) => new() + { + ["id"] = $"{Origin(by.Id)}/activities/resolve/{Guid.NewGuid():N}", ["type"] = "Resolve", ["actor"] = by.Id, ["to"] = new JsonArray(community.Id), + ["object"] = new JsonObject { ["id"] = flag, ["type"] = "Flag", ["actor"] = $"{_harness.Local.BaseAddress}/peasants/privapub_reports", ["object"] = post.ObjectURI } + }; + var elsewhere = new RemoteActor(_harness.Peer, "elsewhere", origin: _harness.Peer.B); + var moderator = new RemoteActor(_harness.Peer, "moderator"); + + await _harness.Deliver(elsewhere, "/human-centipede", Resolve(elsewhere)); + Assert.Null((await DB.Default.Find().OneAsync(report.ID, token)).RemoteResolvedAt); + + await _harness.Deliver(moderator, "/human-centipede", Resolve(moderator)); + var resolved = await DB.Default.Find().OneAsync(report.ID, token); + Assert.NotNull(resolved.RemoteResolvedAt); + Assert.Equal(moderator.Id, resolved.RemoteResolvedBy); + Assert.False(resolved.IsResolved); + } + } +} diff --git a/PrivaPub.Tests/Support/Harness.cs b/PrivaPub.Tests/Support/Harness.cs index 983ce71..1b5916b 100644 --- a/PrivaPub.Tests/Support/Harness.cs +++ b/PrivaPub.Tests/Support/Harness.cs @@ -71,6 +71,8 @@ namespace PrivaPub.Tests.Support new LikeHandler(Db, Reactions), new EmojiReactHandler(Db, Reactions), new QuoteRequestHandler(Quotes), + new WarnHandler(Db, Local, Remote), + new ResolveHandler(Db, Local), new DislikeHandler(Db), new JoinHandler(Db, Local, Delivery), new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote, Records, Quotes, relays: Relays), diff --git a/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs b/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs index 1d53d40..1ba7311 100644 --- a/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs @@ -217,7 +217,8 @@ namespace PrivaPub.Api.Mastodon.Controllers [NotificationType.Update] = "update", [NotificationType.Poll] = "poll", [NotificationType.Reaction] = "pleroma:emoji_reaction", - [NotificationType.Quote] = "quote" + [NotificationType.Quote] = "quote", + [NotificationType.ModerationWarning] = "moderation_warning" }; readonly MastodonMapper _mapper; @@ -317,7 +318,8 @@ namespace PrivaPub.Api.Mastodon.Controllers sample_account_ids = group.Select(n => mapped[n.ID].Account.Id).Distinct().Take(8).ToList(), status_id = newest.Status?.Id, emoji = newest.Emoji, - emoji_url = newest.EmojiUrl + emoji_url = newest.EmojiUrl, + moderation_warning = newest.ModerationWarning }); } return new diff --git a/PrivaPub/Api/Mastodon/Entities/Entities.cs b/PrivaPub/Api/Mastodon/Entities/Entities.cs index 6bb439c..aaefb2f 100644 --- a/PrivaPub/Api/Mastodon/Entities/Entities.cs +++ b/PrivaPub/Api/Mastodon/Entities/Entities.cs @@ -405,6 +405,18 @@ public Status Status { get; set; } public string Emoji { get; set; } public string EmojiUrl { get; set; } + public AccountWarning ModerationWarning { get; set; } + } + + public class AccountWarning + { + public string Id { get; set; } + public string Action { get; set; } = "none"; + public string Text { get; set; } = string.Empty; + public List StatusIds { get; set; } = new(); + public Account TargetAccount { get; set; } + public object Appeal { get; set; } + public string CreatedAt { get; set; } } public class Context diff --git a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs index bf68a26..a56029c 100644 --- a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs +++ b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs @@ -49,6 +49,7 @@ namespace PrivaPub.Api.Mastodon.Mappers ? new List() : await _dbEntities.Posts.Match(p => postIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token); var statuses = (await Statuses(posts, viewerId, token)).ToDictionary(s => s.Id); + var warned = notifications.Any(n => n.Type == NotificationType.ModerationWarning) && viewerId != default ? await Account(viewerId, token) : default; return notifications .Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty) && (n.PostId == default || statuses.ContainsKey(n.PostId))) .Select(n => new Entities.Notification @@ -59,9 +60,17 @@ namespace PrivaPub.Api.Mastodon.Mappers //the group it belongs to by default, so a notification told alone (v1, a stream) joins its group in a client GroupKey = Controllers.NotificationsController.GroupKey(n, Controllers.NotificationsController.DefaultGrouped), Account = accounts[n.FromAccountId], - Status = n.PostId == default ? default : statuses[n.PostId], + Status = n.PostId == default || n.Type == NotificationType.ModerationWarning ? default : statuses[n.PostId], Emoji = n.Emoji == default ? default : n.EmojiURL == default ? n.Emoji : $":{n.Emoji}:", - EmojiUrl = Proxied(n.EmojiURL) + EmojiUrl = Proxied(n.EmojiURL), + ModerationWarning = n.Type != NotificationType.ModerationWarning ? default : new AccountWarning + { + Id = n.ID, + Text = n.Text ?? string.Empty, + StatusIds = n.PostId == default ? new List() : new List { n.PostId }, + TargetAccount = warned, + CreatedAt = MastodonJson.Time(n.CreatedAt) + } }) .ToList(); } diff --git a/PrivaPub/Controllers/ClientToServer/ModeratorController.cs b/PrivaPub/Controllers/ClientToServer/ModeratorController.cs index 9775170..8410227 100644 --- a/PrivaPub/Controllers/ClientToServer/ModeratorController.cs +++ b/PrivaPub/Controllers/ClientToServer/ModeratorController.cs @@ -31,7 +31,9 @@ namespace PrivaPub.Controllers.ClientToServer Forwarded = r.Forwarded, IsResolved = r.IsResolved, CreatedAt = r.CreatedAt, - ResolvedAt = r.ResolvedAt + ResolvedAt = r.ResolvedAt, + RemoteResolvedAt = r.RemoteResolvedAt, + RemoteResolvedBy = r.RemoteResolvedBy })); [HttpPost, Route("/clientapi/moderator/reports/{id}/resolve")] diff --git a/PrivaPub/Domain/Social/Notifications.cs b/PrivaPub/Domain/Social/Notifications.cs index 52d35a9..696eeaa 100644 --- a/PrivaPub/Domain/Social/Notifications.cs +++ b/PrivaPub/Domain/Social/Notifications.cs @@ -8,7 +8,7 @@ namespace PrivaPub.Domain.Social public static class Notifications { public static async Task Add(string avatarId, NotificationType type, string fromAccountId, string fromActorUri, string postId, - CancellationToken token, string emoji = default, string emojiUrl = default) + CancellationToken token, string emoji = default, string emojiUrl = default, string text = default) { if (string.IsNullOrEmpty(avatarId) || avatarId == fromAccountId && type != NotificationType.Poll) return; @@ -28,6 +28,7 @@ namespace PrivaPub.Domain.Social PostId = postId, Emoji = emoji, EmojiURL = emojiUrl, + Text = text, DedupeKey = emoji == default ? $"{type}|{avatarId}|{fromActorUri}|{postId}" : $"{type}|{avatarId}|{fromActorUri}|{postId}|{emoji}", Filtered = action == NotificationAction.Filter }; diff --git a/PrivaPub/Federation/Actors/ActorDocument.cs b/PrivaPub/Federation/Actors/ActorDocument.cs index fab4026..fab1475 100644 --- a/PrivaPub/Federation/Actors/ActorDocument.cs +++ b/PrivaPub/Federation/Actors/ActorDocument.cs @@ -36,6 +36,7 @@ namespace PrivaPub.Federation.Actors public string IconDescription { get; init; } public string HeaderDescription { get; init; } public string MovedTo { get; init; } + public string Moderators { get; init; } public List AlsoKnownAs { get; init; } = new();//the accounts it says it also is (a Move's target names the moved one) public DateTime? Published { get; init; } public List Emojis { get; init; } = new(); @@ -92,6 +93,7 @@ namespace PrivaPub.Federation.Actors IconDescription = root.TryGetProperty("icon", out var described) ? Alt(described) : default, HeaderDescription = root.TryGetProperty("image", out var headerImage) ? Alt(headerImage) : default, MovedTo = RemoteActorService.Text(root, "movedTo"), + Moderators = type == "Group" ? RemoteActorService.Text(root, "attributedTo") ?? RemoteActorService.Text(root, "moderators") : default, AlsoKnownAs = root.TryGetProperty("alsoKnownAs", out var aliases) ? Uris(aliases) : new(), Published = DateTimeOffset.TryParse(RemoteActorService.Text(root, "published"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var published) ? published.UtcDateTime diff --git a/PrivaPub/Federation/Actors/RemoteActorService.cs b/PrivaPub/Federation/Actors/RemoteActorService.cs index 4806649..8a7a87e 100644 --- a/PrivaPub/Federation/Actors/RemoteActorService.cs +++ b/PrivaPub/Federation/Actors/RemoteActorService.cs @@ -234,6 +234,7 @@ namespace PrivaPub.Federation.Actors .Modify(a => a.IsLocked, actor.Locked) .Modify(a => a.IsMemorial, actor.Memorial) .Modify(a => a.MovedToURL, Origin.Of(actor.MovedTo) == default ? default : actor.MovedTo) + .Modify(a => a.ModeratorsURL, Origin.Same(actor.Moderators, actor.Id) ? actor.Moderators : default) .Modify(a => a.AlsoKnownAs, actor.AlsoKnownAs) .Modify(a => a.Published, actor.Published) .Modify(a => a.Emojis, actor.Emojis) diff --git a/PrivaPub/Federation/Inbox/Communities.cs b/PrivaPub/Federation/Inbox/Communities.cs index e04fb27..f912e7a 100644 --- a/PrivaPub/Federation/Inbox/Communities.cs +++ b/PrivaPub/Federation/Inbox/Communities.cs @@ -1,7 +1,14 @@ using MongoDB.Entities; +using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Objects; +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.Federation.Inbox @@ -20,5 +27,23 @@ namespace PrivaPub.Federation.Inbox } return default; } + + // whether an account may act for a community's moderators: anyone on the community's own server (its admins and + // moderators, as Lemmy's verify_mod_or_admin_action allows), or an account its moderators collection lists + public static async Task Moderates(ForeignAvatar community, string actorUri, IRemoteActorService remoteActors, CancellationToken token) + { + if (actorUri == default || community == default) + return false; + if (Origin.Same(actorUri, community.ActorURI)) + return true; + if (string.IsNullOrEmpty(community.ModeratorsURL)) + return false; + using var fetched = await remoteActors.FetchObject(community.ModeratorsURL, token); + if (fetched == default) + return false; + var collection = JsonNode.Parse(fetched.Root.GetRawText()); + var items = collection?["orderedItems"] ?? collection?["items"] ?? collection?["first"]?["orderedItems"] ?? collection?["first"]?["items"]; + return items is JsonArray listed && listed.Any(item => Id(item) == actorUri); + } } } diff --git a/PrivaPub/Federation/Inbox/Handlers/ResolveHandler.cs b/PrivaPub/Federation/Inbox/Handlers/ResolveHandler.cs new file mode 100644 index 0000000..b8fde16 --- /dev/null +++ b/PrivaPub/Federation/Inbox/Handlers/ResolveHandler.cs @@ -0,0 +1,66 @@ +using MongoDB.Entities; + +using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Objects; +using PrivaPub.Models.Social; +using PrivaPub.Models.User; +using PrivaPub.StaticServices; + +using System.Text.Json.Nodes; + +using static PrivaPub.Federation.Objects.ActivityJson; + +namespace PrivaPub.Federation.Inbox.Handlers +{ + // Lemmy 1.0 tells a report's sender that it was dealt with: Resolve{Flag}, the Flag being one we sent + // (`/grunts/flag-`). It is believed from the server holding what was reported, or the community it was + // reported to, and kept on the report beside our own moderators' resolution, which it never replaces. + public class ResolveHandler : IActivityHandler + { + const string FlagMarker = "/grunts/flag-"; + + readonly DbEntities _dbEntities; + readonly ILocalActorService _localActors; + + public ResolveHandler(DbEntities dbEntities, ILocalActorService localActors) + { + _dbEntities = dbEntities; + _localActors = localActors; + } + + public string Type => "Resolve"; + + public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) + { + var flag = activity["object"]; + var flagId = Id(flag); + var marker = flagId?.IndexOf(FlagMarker, StringComparison.Ordinal) ?? -1; + if (flag is JsonObject && Value(flag, "type") != "Flag" || marker < 0 + || !flagId.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase)) + { + Arrival.Drop("not-ours"); + return; + } + var reportId = flagId[(marker + FlagMarker.Length)..].Split('-')[0]; + var report = await DB.Default.Find().MatchID(reportId).ExecuteFirstAsync(token); + if (report == default) + { + Arrival.Drop("unknown-object"); + return; + } + var concerned = new List(report.ObjectURIs) { report.TargetActorURI }; + foreach (var post in report.PostIds.Count == 0 ? new List() : await _dbEntities.Posts.Match(p => report.PostIds.Contains(p.ID)).ExecuteAsync(token)) + concerned.Add(await Communities.Of(post, _dbEntities, token)); + if (!concerned.Any(uri => Origin.Same(uri, actor.ActorURI))) + { + Arrival.Drop("misattributed"); + return; + } + await DB.Default.Update().MatchID(report.ID) + .Modify(r => r.RemoteResolvedAt, DateTime.UtcNow) + .Modify(r => r.RemoteResolvedBy, actor.ActorURI) + .ExecuteAsync(token); + Arrival.Accept("resolved"); + } + } +} diff --git a/PrivaPub/Federation/Inbox/Handlers/WarnHandler.cs b/PrivaPub/Federation/Inbox/Handlers/WarnHandler.cs new file mode 100644 index 0000000..10878df --- /dev/null +++ b/PrivaPub/Federation/Inbox/Handlers/WarnHandler.cs @@ -0,0 +1,86 @@ +using MongoDB.Entities; + +using PrivaPub.Domain.Social; +using PrivaPub.Federation.Actors; +using PrivaPub.Models.Federation; +using PrivaPub.Models.Social; +using PrivaPub.Models.User; +using PrivaPub.StaticServices; + +using System.Text.Json.Nodes; + +using static PrivaPub.Federation.Objects.ActivityJson; + +namespace PrivaPub.Federation.Inbox.Handlers +{ + // Lemmy 1.0's warning: a community's moderator tells one of our personas, about one of its posts there, why (Warn{to: + // [persona], object: post, summary: reason, audience: community}), sent to the persona's inbox alone. It becomes the + // persona's moderation_warning notification, once the sender is shown to moderate that community and the post is the + // persona's own, in it. + public class WarnHandler : IActivityHandler + { + const int MaxReason = 1000; + + readonly DbEntities _dbEntities; + readonly ILocalActorService _localActors; + readonly IRemoteActorService _remoteActors; + + public WarnHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors) + { + _dbEntities = dbEntities; + _localActors = localActors; + _remoteActors = remoteActors; + } + + public string Type => "Warn"; + + public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) + { + LocalActor persona = default; + foreach (var uri in Recipients(activity["to"])) + if (await _localActors.FindByUri(uri, token) is { Kind: LocalActorKind.Person } found) + { + persona = found; + break; + } + if (persona == default) + { + Arrival.Drop("not-ours"); + return; + } + var communityUri = Id(activity["audience"]); + var community = communityUri == default ? default : await _remoteActors.GetActor(communityUri, refresh: false, token); + if (community is not { AvatarType: AvatarType.Group }) + { + Arrival.Drop("unknown-community"); + return; + } + var objectUri = Id(activity["object"]); + var post = objectUri == default + ? default + : await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.GroupUserId == persona.Id && !p.IsFederatedCopy).ExecuteFirstAsync(token); + if (post == default || await Communities.Of(post, _dbEntities, token) != community.ActorURI) + { + Arrival.Drop("unknown-object"); + return; + } + Arrival.About(visibility: post.Visibility, created: post.CreationDate, local: persona); + if (!await Communities.Moderates(community, actor.ActorURI, _remoteActors, token)) + { + Arrival.Drop("not-moderator"); + return; + } + var reason = Value(activity, "summary") ?? Value(activity, "content"); + await Notifications.Add(persona.Id, NotificationType.ModerationWarning, community.ID, community.ActorURI, post.ID, token, + text: string.IsNullOrWhiteSpace(reason) ? default : reason.Trim().Length <= MaxReason ? reason.Trim() : reason.Trim()[..MaxReason]); + Arrival.Accept("warned"); + } + + static IEnumerable Recipients(JsonNode node) => node switch + { + JsonArray array => array.Select(Id).Where(id => id != default), + JsonNode single when Id(single) is { } id => new[] { id }, + _ => Enumerable.Empty() + }; + } +} diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index 958f245..cdb298d 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -87,6 +87,8 @@ namespace PrivaPub.Middleware .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton(services => services.GetRequiredService()) diff --git a/PrivaPub/Models/Social/Notification.cs b/PrivaPub/Models/Social/Notification.cs index 5600402..2f4b7cf 100644 --- a/PrivaPub/Models/Social/Notification.cs +++ b/PrivaPub/Models/Social/Notification.cs @@ -12,6 +12,7 @@ namespace PrivaPub.Models.Social public string DedupeKey { get; set; } public string Emoji { get; set; }//a reaction's emoji public string EmojiURL { get; set; } + public string Text { get; set; }//a moderation warning's reason public bool IsRead { get; set; } public bool Filtered { get; set; }//held back by the persona's notification policy, in a NotificationRequest public DateTime CreatedAt { get; set; } = DateTime.UtcNow; @@ -27,6 +28,7 @@ namespace PrivaPub.Models.Social Update, Poll, Reaction, - Quote + Quote, + ModerationWarning } } diff --git a/PrivaPub/Models/Social/Relationships.cs b/PrivaPub/Models/Social/Relationships.cs index e21b167..5e24e49 100644 --- a/PrivaPub/Models/Social/Relationships.cs +++ b/PrivaPub/Models/Social/Relationships.cs @@ -78,5 +78,7 @@ namespace PrivaPub.Models.Social public string ResolvedBy { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? ResolvedAt { get; set; } + public DateTime? RemoteResolvedAt { get; set; }//the server holding the reported content says it dealt with it (Lemmy's Resolve{Flag}) + public string RemoteResolvedBy { get; set; } } } diff --git a/PrivaPub/Models/User/Avatar.cs b/PrivaPub/Models/User/Avatar.cs index cd2f826..50ceeba 100644 --- a/PrivaPub/Models/User/Avatar.cs +++ b/PrivaPub/Models/User/Avatar.cs @@ -77,6 +77,7 @@ namespace PrivaPub.Models.User public string InboxURL { get; set; } public string OutboxURL { get; set; } public string FeaturedURL { get; set; }//featured: the posts it pins + public string ModeratorsURL { get; set; }//a community's attributedTo (Lemmy, PieFed, Mbin): the collection of its moderators public List AssertionKeys { get; set; } = new();//its Ed25519 keys (FEP-521a), which prove what it sends (FEP-8b32) public string WallURL { get; set; }//sm:wall (FEP-400e): where others write to it, Smithereen's walls public string MovedToURL { get; set; } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 71c7935..996dcbd 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -688,7 +688,9 @@ it, raw where it doesn't. public and unlisted replies only; Mastodon finds a thread's replies through them, checked live). - **Lemmy, PieFed and Mbin:** - 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`; + ban of a persona refuses its posts there and shows as `blocked_by`), featured; `Warn` and `Resolve` (**done + 2026-10-07**: a warning becomes the persona's `moderation_warning` notification, believed from the community's + server or its moderators collection, kept as `ForeignAvatar.ModeratorsURL`; a resolution is kept on our report); - 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: **done 2026-10-06** (its server's shared inbox,