R6 moderation: roles, silence and suspension, bans that write nothing on the personas, deletions, report actions
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
This commit is contained in:
1 parent
70d1ffe9a2
commit
9f68ca3ed0
71 files changed
+2229
-219
No files matched your search
@@ -0,0 +1,159 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels.Admin
|
||||
{
|
||||
// The staff's acts on accounts and posts (completion program R6). Moderators silence and suspend personas here and
|
||||
// accounts elsewhere, remove posts and resolve reports with one of those acts; administrators also change logins
|
||||
// (roles, bans, deletions, two-factor sign-in). Every act is kept in the staff's log.
|
||||
public static class ModerationActions
|
||||
{
|
||||
public const string None = "none";
|
||||
public const string Silence = "silence";
|
||||
public const string Unsilence = "unsilence";
|
||||
public const string Suspend = "suspend";
|
||||
public const string Unsuspend = "unsuspend";
|
||||
public const string RemovePosts = "remove_posts";//a report's resolution only: the posts it names
|
||||
|
||||
public static bool IsAccountAction(string action) => action is Silence or Unsilence or Suspend or Unsuspend;
|
||||
|
||||
public static bool IsResolution(string action) => action is None or Silence or Suspend or RemovePosts;
|
||||
}
|
||||
|
||||
// POST /clientapi/moderator/accounts/moderate: a persona here (its id) or an account elsewhere (its id here)
|
||||
public class ModerateAccountForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string AccountId { get; set; }
|
||||
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(16, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Action { get; set; }//ModerationActions: silence, unsilence, suspend, unsuspend
|
||||
|
||||
// the staff's own words: kept in the log and on the account, never shown to it
|
||||
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Note { get; set; }
|
||||
|
||||
// what a persona here is told with its warning (Mastodon's moderation_warning); nothing reaches an account elsewhere
|
||||
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Warning { get; set; }
|
||||
}
|
||||
|
||||
// POST /clientapi/moderator/posts/remove: a post here is deleted as its author would (the deletion federates), a copy of
|
||||
// a post elsewhere is taken down here only
|
||||
public class RemovePostForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string PostId { get; set; }
|
||||
|
||||
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Note { get; set; }
|
||||
|
||||
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Warning { get; set; }
|
||||
}
|
||||
|
||||
// POST /clientapi/moderator/reports/{id}/resolve: the body is optional; without one, or with action none, the report is
|
||||
// only marked resolved
|
||||
public class ResolveReportForm
|
||||
{
|
||||
[StringLength(16, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Action { get; set; } = ModerationActions.None;//none, silence, suspend, remove_posts
|
||||
|
||||
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Note { get; set; }
|
||||
|
||||
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Warning { get; set; }
|
||||
}
|
||||
|
||||
// an account as moderators see it: a persona here (never its login) or an account elsewhere, and what the staff did to it
|
||||
public class ViewModeratedAccount
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public bool IsLocal { get; set; } = false;
|
||||
public string UserName { get; set; }
|
||||
public string DisplayName { get; set; }
|
||||
public string Handle { get; set; }//user@host
|
||||
public string Url { get; set; }//its actor's id
|
||||
public string Domain { get; set; }
|
||||
public DateTime? SilencedAt { get; set; }
|
||||
public DateTime? SuspendedAt { get; set; }
|
||||
public bool DomainSilenced { get; set; } = false;//an account elsewhere whose server is silenced: limited without its own silence
|
||||
public bool DomainSuspended { get; set; } = false;
|
||||
public string Note { get; set; }//the staff's last note on it
|
||||
}
|
||||
|
||||
// GET /clientapi/moderator/accounts?q= and /clientapi/moderator/accounts/moderated?offset=&limit=: Next is the offset of
|
||||
// the next page, null on the last
|
||||
public class ViewModeratedAccountsPage
|
||||
{
|
||||
public List<ViewModeratedAccount> Items { get; set; } = [];
|
||||
public int? Next { get; set; }
|
||||
}
|
||||
|
||||
// a login as administrators see it (GET /clientapi/admin/roots), with its personas: only administrators ever read a login
|
||||
// next to its personas
|
||||
public class ViewAdminRoot
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string UserName { get; set; }
|
||||
public string Role { get; set; }//user, moderator, admin
|
||||
public bool IsBanned { get; set; } = false;
|
||||
public bool HasEmail { get; set; } = false;
|
||||
public bool IsEmailValidated { get; set; } = false;
|
||||
public bool HasTotp { get; set; } = false;
|
||||
public bool IsYou { get; set; } = false;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public List<ViewAdminPersona> Personas { get; set; } = [];
|
||||
}
|
||||
|
||||
public class ViewAdminPersona
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string UserName { get; set; }
|
||||
public string DisplayName { get; set; }
|
||||
public string Handle { get; set; }
|
||||
public DateTime? SilencedAt { get; set; }
|
||||
public DateTime? SuspendedAt { get; set; }
|
||||
public List<ViewAdminGroup> Groups { get; set; } = [];//the groups it owns
|
||||
}
|
||||
|
||||
public class ViewAdminGroup
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string UserName { get; set; }
|
||||
public string Name { get; set; }
|
||||
public bool IsCommunity { get; set; } = false;
|
||||
public int MembersCount { get; set; } = 0;
|
||||
}
|
||||
|
||||
// newest first; Next is the `before` of the next page, null on the last
|
||||
public class ViewAdminRootsPage
|
||||
{
|
||||
public List<ViewAdminRoot> Items { get; set; } = [];
|
||||
public string Next { get; set; }
|
||||
}
|
||||
|
||||
// POST /clientapi/admin/roots/{id}/role
|
||||
public class RootRoleForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(16, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Role { get; set; }//user, moderator, admin
|
||||
}
|
||||
|
||||
// what can't be undone asks for the administrator's own password: deleting a persona or a group
|
||||
public class AdminPasswordForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
|
||||
public string Password { get; set; }
|
||||
|
||||
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Note { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ namespace PrivaPub.ClientModels.Admin
|
||||
public string Comment { get; set; }
|
||||
public bool Forwarded { get; set; }
|
||||
public bool IsResolved { get; set; }
|
||||
public string Resolution { get; set; }//what resolving it did: none, silence, suspend, remove_posts (null before R6)
|
||||
public string ResolutionNote { 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
|
||||
|
||||
@@ -17,6 +17,19 @@ namespace PrivaPub.ClientModels.Group
|
||||
public string InvitationPassword { get; set; }
|
||||
}
|
||||
|
||||
// the group's owner deletes it (POST /clientapi/group/delete), with the login's password: its members are told, and its
|
||||
// name stays taken
|
||||
public class DeleteGroupForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string AvatarId { get; set; }
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string GroupId { get; set; }
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
|
||||
public string Password { get; set; }
|
||||
}
|
||||
|
||||
public class GroupMembershipForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels.User
|
||||
{
|
||||
// A login deletes one of its own personas (POST /clientapi/avatar/private/delete), and with it the groups the persona
|
||||
// owns, with its password again, as it deletes itself.
|
||||
public class DeletePersonaForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = "AvatarId", ResourceType = typeof(FieldsNameResource))]
|
||||
public string AvatarId { get; set; }
|
||||
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
|
||||
public string Password { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user