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,