- 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:
1 parent
6fc2d001b2
commit
587997dd67
19 files changed
+344
-8
No files matched your search
@@ -185,6 +185,8 @@ Received:
|
|||||||
| `Like` | counted and notified, on posts the liker could see |
|
| `Like` | counted and notified, on posts the liker could see |
|
||||||
| `Dislike` | counted as a downvote (Lemmy, PieFed, Mbin, Friendica); `Undo` takes it back |
|
| `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 |
|
| `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) |
|
| `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 |
|
| `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 |
|
| `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 |
|
||||||
|
|||||||
@@ -16,5 +16,7 @@ namespace PrivaPub.ClientModels.Admin
|
|||||||
public bool IsResolved { get; set; }
|
public bool IsResolved { get; set; }
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public DateTime? ResolvedAt { 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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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<Post>().Match(p => p.GroupUserId == alice.Id).ExecuteFirstAsync(token);
|
||||||
|
await DB.Default.Update<Post>().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<Notification>().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<Notification>().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<Notification>(n => n.AvatarId == alice.Id);
|
||||||
|
await _harness.Deliver(admin, "/human-centipede", adminWarning);
|
||||||
|
Assert.Equal("from the server's admin", (await DB.Default.Find<Notification>().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<Report>().OneAsync(report.ID, token)).RemoteResolvedAt);
|
||||||
|
|
||||||
|
await _harness.Deliver(moderator, "/human-centipede", Resolve(moderator));
|
||||||
|
var resolved = await DB.Default.Find<Report>().OneAsync(report.ID, token);
|
||||||
|
Assert.NotNull(resolved.RemoteResolvedAt);
|
||||||
|
Assert.Equal(moderator.Id, resolved.RemoteResolvedBy);
|
||||||
|
Assert.False(resolved.IsResolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,8 @@ namespace PrivaPub.Tests.Support
|
|||||||
new LikeHandler(Db, Reactions),
|
new LikeHandler(Db, Reactions),
|
||||||
new EmojiReactHandler(Db, Reactions),
|
new EmojiReactHandler(Db, Reactions),
|
||||||
new QuoteRequestHandler(Quotes),
|
new QuoteRequestHandler(Quotes),
|
||||||
|
new WarnHandler(Db, Local, Remote),
|
||||||
|
new ResolveHandler(Db, Local),
|
||||||
new DislikeHandler(Db),
|
new DislikeHandler(Db),
|
||||||
new JoinHandler(Db, Local, Delivery),
|
new JoinHandler(Db, Local, Delivery),
|
||||||
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote, Records, Quotes, relays: Relays),
|
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote, Records, Quotes, relays: Relays),
|
||||||
|
|||||||
@@ -217,7 +217,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
|||||||
[NotificationType.Update] = "update",
|
[NotificationType.Update] = "update",
|
||||||
[NotificationType.Poll] = "poll",
|
[NotificationType.Poll] = "poll",
|
||||||
[NotificationType.Reaction] = "pleroma:emoji_reaction",
|
[NotificationType.Reaction] = "pleroma:emoji_reaction",
|
||||||
[NotificationType.Quote] = "quote"
|
[NotificationType.Quote] = "quote",
|
||||||
|
[NotificationType.ModerationWarning] = "moderation_warning"
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly MastodonMapper _mapper;
|
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(),
|
sample_account_ids = group.Select(n => mapped[n.ID].Account.Id).Distinct().Take(8).ToList(),
|
||||||
status_id = newest.Status?.Id,
|
status_id = newest.Status?.Id,
|
||||||
emoji = newest.Emoji,
|
emoji = newest.Emoji,
|
||||||
emoji_url = newest.EmojiUrl
|
emoji_url = newest.EmojiUrl,
|
||||||
|
moderation_warning = newest.ModerationWarning
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return new
|
return new
|
||||||
|
|||||||
@@ -405,6 +405,18 @@
|
|||||||
public Status Status { get; set; }
|
public Status Status { get; set; }
|
||||||
public string Emoji { get; set; }
|
public string Emoji { get; set; }
|
||||||
public string EmojiUrl { 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
|
public class Context
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
|||||||
? new List<PostEntity>()
|
? new List<PostEntity>()
|
||||||
: await _dbEntities.Posts.Match(p => postIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token);
|
: 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 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
|
return notifications
|
||||||
.Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty) && (n.PostId == default || statuses.ContainsKey(n.PostId)))
|
.Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty) && (n.PostId == default || statuses.ContainsKey(n.PostId)))
|
||||||
.Select(n => new Entities.Notification
|
.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
|
//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),
|
GroupKey = Controllers.NotificationsController.GroupKey(n, Controllers.NotificationsController.DefaultGrouped),
|
||||||
Account = accounts[n.FromAccountId],
|
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}:",
|
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();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ namespace PrivaPub.Controllers.ClientToServer
|
|||||||
Forwarded = r.Forwarded,
|
Forwarded = r.Forwarded,
|
||||||
IsResolved = r.IsResolved,
|
IsResolved = r.IsResolved,
|
||||||
CreatedAt = r.CreatedAt,
|
CreatedAt = r.CreatedAt,
|
||||||
ResolvedAt = r.ResolvedAt
|
ResolvedAt = r.ResolvedAt,
|
||||||
|
RemoteResolvedAt = r.RemoteResolvedAt,
|
||||||
|
RemoteResolvedBy = r.RemoteResolvedBy
|
||||||
}));
|
}));
|
||||||
|
|
||||||
[HttpPost, Route("/clientapi/moderator/reports/{id}/resolve")]
|
[HttpPost, Route("/clientapi/moderator/reports/{id}/resolve")]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace PrivaPub.Domain.Social
|
|||||||
public static class Notifications
|
public static class Notifications
|
||||||
{
|
{
|
||||||
public static async Task Add(string avatarId, NotificationType type, string fromAccountId, string fromActorUri, string postId,
|
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)
|
if (string.IsNullOrEmpty(avatarId) || avatarId == fromAccountId && type != NotificationType.Poll)
|
||||||
return;
|
return;
|
||||||
@@ -28,6 +28,7 @@ namespace PrivaPub.Domain.Social
|
|||||||
PostId = postId,
|
PostId = postId,
|
||||||
Emoji = emoji,
|
Emoji = emoji,
|
||||||
EmojiURL = emojiUrl,
|
EmojiURL = emojiUrl,
|
||||||
|
Text = text,
|
||||||
DedupeKey = emoji == default ? $"{type}|{avatarId}|{fromActorUri}|{postId}" : $"{type}|{avatarId}|{fromActorUri}|{postId}|{emoji}",
|
DedupeKey = emoji == default ? $"{type}|{avatarId}|{fromActorUri}|{postId}" : $"{type}|{avatarId}|{fromActorUri}|{postId}|{emoji}",
|
||||||
Filtered = action == NotificationAction.Filter
|
Filtered = action == NotificationAction.Filter
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ namespace PrivaPub.Federation.Actors
|
|||||||
public string IconDescription { get; init; }
|
public string IconDescription { get; init; }
|
||||||
public string HeaderDescription { get; init; }
|
public string HeaderDescription { get; init; }
|
||||||
public string MovedTo { 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 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 DateTime? Published { get; init; }
|
||||||
public List<CustomEmoji> Emojis { get; init; } = new();
|
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,
|
IconDescription = root.TryGetProperty("icon", out var described) ? Alt(described) : default,
|
||||||
HeaderDescription = root.TryGetProperty("image", out var headerImage) ? Alt(headerImage) : default,
|
HeaderDescription = root.TryGetProperty("image", out var headerImage) ? Alt(headerImage) : default,
|
||||||
MovedTo = RemoteActorService.Text(root, "movedTo"),
|
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(),
|
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 = DateTimeOffset.TryParse(RemoteActorService.Text(root, "published"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var published)
|
||||||
? published.UtcDateTime
|
? published.UtcDateTime
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ namespace PrivaPub.Federation.Actors
|
|||||||
.Modify(a => a.IsLocked, actor.Locked)
|
.Modify(a => a.IsLocked, actor.Locked)
|
||||||
.Modify(a => a.IsMemorial, actor.Memorial)
|
.Modify(a => a.IsMemorial, actor.Memorial)
|
||||||
.Modify(a => a.MovedToURL, Origin.Of(actor.MovedTo) == default ? default : actor.MovedTo)
|
.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.AlsoKnownAs, actor.AlsoKnownAs)
|
||||||
.Modify(a => a.Published, actor.Published)
|
.Modify(a => a.Published, actor.Published)
|
||||||
.Modify(a => a.Emojis, actor.Emojis)
|
.Modify(a => a.Emojis, actor.Emojis)
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
using MongoDB.Entities;
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.Federation.Actors;
|
||||||
|
using PrivaPub.Federation.Objects;
|
||||||
|
using PrivaPub.Models.User;
|
||||||
using PrivaPub.StaticServices;
|
using PrivaPub.StaticServices;
|
||||||
|
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
using static PrivaPub.Federation.Objects.ActivityJson;
|
||||||
|
|
||||||
using PostEntity = PrivaPub.Models.Post.Post;
|
using PostEntity = PrivaPub.Models.Post.Post;
|
||||||
|
|
||||||
namespace PrivaPub.Federation.Inbox
|
namespace PrivaPub.Federation.Inbox
|
||||||
@@ -20,5 +27,23 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
}
|
}
|
||||||
return default;
|
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, DislikeHandler>()
|
||||||
.AddSingleton<IActivityHandler, EmojiReactHandler>()
|
.AddSingleton<IActivityHandler, EmojiReactHandler>()
|
||||||
.AddSingleton<IActivityHandler, QuoteRequestHandler>()
|
.AddSingleton<IActivityHandler, QuoteRequestHandler>()
|
||||||
|
.AddSingleton<IActivityHandler, WarnHandler>()
|
||||||
|
.AddSingleton<IActivityHandler, ResolveHandler>()
|
||||||
.AddSingleton<IReactions, Reactions>()
|
.AddSingleton<IReactions, Reactions>()
|
||||||
.AddSingleton<LinkPreviews>()
|
.AddSingleton<LinkPreviews>()
|
||||||
.AddSingleton<ILinkPreviews>(services => services.GetRequiredService<LinkPreviews>())
|
.AddSingleton<ILinkPreviews>(services => services.GetRequiredService<LinkPreviews>())
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ namespace PrivaPub.Models.Social
|
|||||||
public string DedupeKey { get; set; }
|
public string DedupeKey { get; set; }
|
||||||
public string Emoji { get; set; }//a reaction's emoji
|
public string Emoji { get; set; }//a reaction's emoji
|
||||||
public string EmojiURL { get; set; }
|
public string EmojiURL { get; set; }
|
||||||
|
public string Text { get; set; }//a moderation warning's reason
|
||||||
public bool IsRead { get; set; }
|
public bool IsRead { get; set; }
|
||||||
public bool Filtered { get; set; }//held back by the persona's notification policy, in a NotificationRequest
|
public bool Filtered { get; set; }//held back by the persona's notification policy, in a NotificationRequest
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
@@ -27,6 +28,7 @@ namespace PrivaPub.Models.Social
|
|||||||
Update,
|
Update,
|
||||||
Poll,
|
Poll,
|
||||||
Reaction,
|
Reaction,
|
||||||
Quote
|
Quote,
|
||||||
|
ModerationWarning
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,5 +78,7 @@ namespace PrivaPub.Models.Social
|
|||||||
public string ResolvedBy { get; set; }
|
public string ResolvedBy { get; set; }
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime? ResolvedAt { get; set; }
|
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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,6 +77,7 @@ namespace PrivaPub.Models.User
|
|||||||
public string InboxURL { get; set; }
|
public string InboxURL { get; set; }
|
||||||
public string OutboxURL { get; set; }
|
public string OutboxURL { get; set; }
|
||||||
public string FeaturedURL { get; set; }//featured: the posts it pins
|
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 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 WallURL { get; set; }//sm:wall (FEP-400e): where others write to it, Smithereen's walls
|
||||||
public string MovedToURL { get; set; }
|
public string MovedToURL { get; set; }
|
||||||
|
|||||||
+3
-1
@@ -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).
|
public and unlisted replies only; Mastodon finds a thread's replies through them, checked live).
|
||||||
- **Lemmy, PieFed and Mbin:**
|
- **Lemmy, PieFed and Mbin:**
|
||||||
- the moderation set: removals, locks, bans (**done 2026-10-05**, relayed by the community: a lock refuses replies, a
|
- 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`;
|
- 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: **done 2026-10-06** (its server's shared inbox,
|
- communities we host announce to the author's own instance too: **done 2026-10-06** (its server's shared inbox,
|
||||||
|
|||||||
Reference in new issue
Block a user