P7: Lemmy's Warn and Resolve{Flag}
Build / Build (push) Successful in 8m37s

- 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-<report id>`) 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-07 21:10:58 +02:00
1 parent 6fc2d001b2
commit 587997dd67
19 files changed
+344 -8

No files matched your search

@@ -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
@@ -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<string> StatusIds { get; set; } = new();
public Account TargetAccount { get; set; }
public object Appeal { get; set; }
public string CreatedAt { get; set; }
}
public class Context
@@ -49,6 +49,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
? new List<PostEntity>()
: 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<string>() : new List<string> { n.PostId },
TargetAccount = warned,
CreatedAt = MastodonJson.Time(n.CreatedAt)
}
})
.ToList();
}
@@ -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")]
+2 -1
View File
@@ -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
};
@@ -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<string> 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<CustomEmoji> 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
@@ -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)
+25
View File
@@ -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<bool> 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);
}
}
}
@@ -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-<report id>`). 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<Report>().MatchID(reportId).ExecuteFirstAsync(token);
if (report == default)
{
Arrival.Drop("unknown-object");
return;
}
var concerned = new List<string>(report.ObjectURIs) { report.TargetActorURI };
foreach (var post in report.PostIds.Count == 0 ? new List<Models.Post.Post>() : 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<Report>().MatchID(report.ID)
.Modify(r => r.RemoteResolvedAt, DateTime.UtcNow)
.Modify(r => r.RemoteResolvedBy, actor.ActorURI)
.ExecuteAsync(token);
Arrival.Accept("resolved");
}
}
}
@@ -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<string> 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<string>()
};
}
}
@@ -87,6 +87,8 @@ namespace PrivaPub.Middleware
.AddSingleton<IActivityHandler, DislikeHandler>()
.AddSingleton<IActivityHandler, EmojiReactHandler>()
.AddSingleton<IActivityHandler, QuoteRequestHandler>()
.AddSingleton<IActivityHandler, WarnHandler>()
.AddSingleton<IActivityHandler, ResolveHandler>()
.AddSingleton<IReactions, Reactions>()
.AddSingleton<LinkPreviews>()
.AddSingleton<ILinkPreviews>(services => services.GetRequiredService<LinkPreviews>())
+3 -1
View File
@@ -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
}
}
+2
View File
@@ -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; }
}
}
+1
View File
@@ -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<AssertionKey> 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; }