Roles (user, moderator, administrator) from the CLI and /clientapi/admin/roots/{id}/role, never one's own nor the last
administrator. Silence keeps an account's posts out of public timelines, hashtags, trends, search and relays while its
followers still get them; suspension refuses and revokes a local persona's tokens, answers 404 for its posts and
collections, holds back every delivery and tells other servers nothing new, its actor unchanged; a suspended remote
account's activities are dropped and nothing goes to its own inbox (a shared one still serves its server). A banned
login's personas are held back in memory alone (Restrictions), so nothing pairs them in stored data. Personas and groups
are deleted one at a time (PersonaRemoval, GroupRemoval), by their login or an administrator with the password. Reports
resolve with an action and a note; posts are taken down, a remote copy hidden. Warnings reach the persona as Mastodon's
moderation_warning. Every staff act goes to the staff's log. Migration _020 limits posts from servers already silenced;
a restore keeps the moderation made since the backup.
Verified: interop.sh moderation gts mastodon with SecureMode, 194 passed; restore.sh, 25 passed, now with a suspension,
a silence and a takedown made after the backup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
106 lines
3.6 KiB
C#
106 lines
3.6 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Domain.Social;
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Models.Social;
|
|
using PrivaPub.Federation.Objects;
|
|
using PrivaPub.Federation.Outbox;
|
|
using PrivaPub.Federation.Rendering;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Group;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
using static PrivaPub.Federation.Inbox.ForeignMembers;
|
|
using static PrivaPub.Federation.Objects.ActivityJson;
|
|
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
|
|
|
namespace PrivaPub.Federation.Inbox.Handlers
|
|
{
|
|
public class FollowHandler : IActivityHandler
|
|
{
|
|
readonly DbEntities _dbEntities;
|
|
readonly ILocalActorService _localActors;
|
|
readonly IRemoteActorService _remoteActors;
|
|
readonly IDeliveryService _delivery;
|
|
|
|
public FollowHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery)
|
|
{
|
|
_dbEntities = dbEntities;
|
|
_localActors = localActors;
|
|
_remoteActors = remoteActors;
|
|
_delivery = delivery;
|
|
}
|
|
|
|
public string Type => "Follow";
|
|
|
|
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
|
|
{
|
|
var follow = activity;
|
|
var follower = actor;
|
|
|
|
var target = await _localActors.FindByAddress(Id(follow["object"]), token);
|
|
if (target is not { IsFederated: true } || target.IsServerActor)
|
|
{
|
|
Arrival.Drop("unknown-recipient");
|
|
return;
|
|
}
|
|
// a suspended persona answers nothing, so it takes no follower it could not answer (R6): the request stays
|
|
// pending on the follower's side, as it would for any persona that has not answered yet
|
|
if (target is { Kind: LocalActorKind.Person, Suspended: true })
|
|
{
|
|
Arrival.Drop("recipient-suspended");
|
|
return;
|
|
}
|
|
if (target.Kind == LocalActorKind.Person
|
|
&& await DB.Default.Find<Models.Social.Block>().Match(b => b.AvatarId == target.Id && b.TargetActorURI == follower.ActorURI).ExecuteAnyAsync(token))
|
|
{
|
|
var reject = new JsonObject
|
|
{
|
|
["@context"] = ActivityPubRenderer.ActivityStreams,
|
|
["id"] = target.ActivityUri($"reject-{Guid.NewGuid():N}"),
|
|
["type"] = "Reject",
|
|
["actor"] = target.Uri,
|
|
["object"] = follow.DeepClone()
|
|
};
|
|
await _delivery.Enqueue(target, new[] { follower.InboxURL }, reject, token);
|
|
Arrival.Reject("blocked");
|
|
return;
|
|
}
|
|
|
|
var existing = await _dbEntities.Followers
|
|
.Match(f => f.LocalActorId == target.Id && f.LocalActorKind == target.Kind && f.ActorURI == follower.ActorURI)
|
|
.ExecuteFirstAsync(token);
|
|
var record = existing ?? new Follower
|
|
{
|
|
LocalActorId = target.Id,
|
|
LocalActorKind = target.Kind,
|
|
ActorURI = follower.ActorURI
|
|
};
|
|
record.InboxURL = follower.InboxURL;
|
|
record.SharedInboxURL = follower.SharedInboxURL;
|
|
record.FollowActivityURI = Id(follow);
|
|
record.IsAccepted = existing?.IsAccepted == true || !target.ManuallyApprovesFollowers;
|
|
await DB.Default.SaveAsync(record, token);
|
|
Arrival.Accept(record.IsAccepted ? "auto-accepted" : "pending");
|
|
|
|
if (target.Kind == LocalActorKind.Person)
|
|
await Notifications.Add(target.Id, record.IsAccepted ? NotificationType.Follow : NotificationType.FollowRequest,
|
|
follower.ID, follower.ActorURI, default, token);
|
|
if (!record.IsAccepted)
|
|
return;
|
|
|
|
if (target.Kind == LocalActorKind.Group)
|
|
await AddForeignMember(target.Id, follower.ActorURI, token);
|
|
|
|
var accept = ActivityPubRenderer.Accept(target, follow, $"accept-{record.ID}-{DateTime.UtcNow.Ticks}");
|
|
await _delivery.Enqueue(target, new[] { follower.InboxURL }, accept, token);
|
|
return;
|
|
}
|
|
}
|
|
}
|