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
@@ -2,50 +2,90 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Localization;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.ClientModels.Admin;
|
||||
using PrivaPub.ClientModels.User;
|
||||
using PrivaPub.Domain.Moderation;
|
||||
using PrivaPub.Domain.Timelines;
|
||||
using PrivaPub.Extensions;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Resources;
|
||||
using PrivaPub.Services;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||
|
||||
namespace PrivaPub.Controllers.ClientToServer
|
||||
{
|
||||
// What only administrators do to logins (completion program R6): read them with their personas (the one place a login
|
||||
// is shown next to its personas), change their role, ban and unban them, delete them, a persona or a group, and turn a
|
||||
// login's two-factor sign-in off. Every act is kept in the staff's log; an entry about a login names none of its
|
||||
// personas.
|
||||
[ApiController,
|
||||
Route("clientapi/admin")]
|
||||
Route("clientapi/admin"),
|
||||
Authorize(Policy = Policies.IsAdmin)]
|
||||
public class AdminController : ControllerBase
|
||||
{
|
||||
const int MaxRootsPage = 100;
|
||||
|
||||
readonly IRootUsersService RootUsersService;
|
||||
readonly ILogger<RootUserController> Logger;
|
||||
readonly IStringLocalizer Localizer;
|
||||
readonly IModerationLog _log;
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
|
||||
public AdminController(ILogger<RootUserController> logger,
|
||||
IStringLocalizer<GenericRes> localizer,
|
||||
IRootUsersService rootUsersService)
|
||||
IRootUsersService rootUsersService,
|
||||
IModerationLog log,
|
||||
DbEntities dbEntities,
|
||||
ILocalActorService localActors)
|
||||
{
|
||||
Logger = logger;
|
||||
Localizer = localizer;
|
||||
RootUsersService = rootUsersService;
|
||||
_log = log;
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
}
|
||||
|
||||
// an administrator never removes, bans or unbans itself; the request's own list is left as it came
|
||||
UsersIds Others(UsersIds usersIds)
|
||||
{
|
||||
var selfId = User.GetUserId();
|
||||
return new UsersIds { UserIdList = usersIds.UserIdList.Where(id => id != selfId).ToList() };
|
||||
return new UsersIds { UserIdList = usersIds.UserIdList.Where(id => id != selfId).Distinct().ToList() };
|
||||
}
|
||||
|
||||
[HttpDelete, Route("/clientapi/admin/remove/users"), Authorize(Policy = Policies.IsAdmin)]
|
||||
public async Task<IActionResult> RemoveUsers([Required] UsersIds usersIds)
|
||||
// the logins a request names that exist and are not deleted, by id: what the log names them
|
||||
static async Task<Dictionary<string, string>> Named(UsersIds usersIds, CancellationToken token)
|
||||
{
|
||||
var ids = usersIds.UserIdList;
|
||||
return ids.Count == 0
|
||||
? new Dictionary<string, string>()
|
||||
: (await DB.Default.Find<RootUser>().Match(u => ids.Contains(u.ID) && u.DeletedAt == null).ExecuteAsync(token)).ToDictionary(u => u.ID, u => u.UserName);
|
||||
}
|
||||
|
||||
[HttpDelete, Route("/clientapi/admin/remove/users")]
|
||||
public async Task<IActionResult> RemoveUsers([Required] UsersIds usersIds, CancellationToken token)
|
||||
{
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
result = await RootUsersService.RemoveUserAsync(Others(usersIds));
|
||||
var others = Others(usersIds);
|
||||
var named = await Named(others, token);
|
||||
result = await RootUsersService.RemoveUserAsync(others);
|
||||
if (!result.IsValid)
|
||||
return StatusCode(result.StatusCode, result);
|
||||
|
||||
foreach (var (id, name) in named)
|
||||
await _log.Write(User.GetUserId(), AdminActions.RootDelete, AdminTargets.Root, id, name, token: CancellationToken.None);
|
||||
return Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -55,19 +95,31 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost, Authorize(Policy = Policies.IsAdmin), Route("/clientapi/admin/ban/users")]
|
||||
public async Task<IActionResult> BanUsers([Required] UsersIds usersIds)
|
||||
// A ban ends the login's sessions and holds its personas back as suspended ones (Restrictions), without writing a word
|
||||
// on any of them; unbanning gives them back as they were.
|
||||
[HttpPost, Route("/clientapi/admin/ban/users")]
|
||||
public async Task<IActionResult> BanUsers([Required] UsersIds usersIds, [FromServices] IRootSessions sessions, CancellationToken token)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(usersIds);
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
result = await RootUsersService.BanUserAsync(Others(usersIds));
|
||||
var others = Others(usersIds);
|
||||
var named = await Named(others, token);
|
||||
var banned = (await DB.Default.Find<RootUser>().Match(u => others.UserIdList.Contains(u.ID) && u.IsBanned).ExecuteAsync(token)).Select(u => u.ID).ToHashSet();
|
||||
result = await RootUsersService.BanUserAsync(others);
|
||||
|
||||
if (!result.IsValid)
|
||||
return StatusCode(result.StatusCode, result);
|
||||
|
||||
await Restrictions.Reload(CancellationToken.None);
|
||||
foreach (var (id, name) in named.Where(n => !banned.Contains(n.Key)))
|
||||
{
|
||||
await sessions.Revoke(id, CancellationToken.None);
|
||||
foreach (var link in await _dbEntities.RootToAvatars.Match(ra => ra.RootId == id).ExecuteAsync(token))
|
||||
Streams.CloseFor(link.AvatarId);
|
||||
await _log.Write(User.GetUserId(), AdminActions.RootBan, AdminTargets.Root, id, name, token: CancellationToken.None);
|
||||
}
|
||||
return Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -77,18 +129,23 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost, Authorize(Policy = Policies.IsAdmin), Route("/clientapi/admin/unban/users")]
|
||||
public async Task<IActionResult> UnbanUsers([Required] UsersIds usersIds)
|
||||
[HttpPost, Route("/clientapi/admin/unban/users")]
|
||||
public async Task<IActionResult> UnbanUsers([Required] UsersIds usersIds, CancellationToken token)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(usersIds);
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
result = await RootUsersService.UnbanUserAsync(Others(usersIds));
|
||||
var others = Others(usersIds);
|
||||
var named = await Named(others, token);
|
||||
var banned = (await DB.Default.Find<RootUser>().Match(u => others.UserIdList.Contains(u.ID) && u.IsBanned).ExecuteAsync(token)).Select(u => u.ID).ToHashSet();
|
||||
result = await RootUsersService.UnbanUserAsync(others);
|
||||
if (!result.IsValid)
|
||||
return StatusCode(result.StatusCode, result);
|
||||
|
||||
await Restrictions.Reload(CancellationToken.None);
|
||||
foreach (var (id, name) in named.Where(n => banned.Contains(n.Key)))
|
||||
await _log.Write(User.GetUserId(), AdminActions.RootUnban, AdminTargets.Root, id, name, token: CancellationToken.None);
|
||||
return Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -97,5 +154,187 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
// The logins, newest first, each with its personas and the groups they own; q finds a login by its name, or by the
|
||||
// name of one of its personas.
|
||||
[HttpGet, Route("/clientapi/admin/roots")]
|
||||
public async Task<IActionResult> Roots([FromQuery] string q, [FromQuery] string before, [FromQuery] int limit = 50, CancellationToken token = default)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, MaxRootsPage);
|
||||
var query = DB.Default.Find<RootUser>().Match(u => u.DeletedAt == null);
|
||||
var name = q?.Trim().TrimStart('@').Split('@')[0].ToLowerInvariant();
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
var personaIds = (await DB.Default.Find<Avatar>().Match(a => a.UserName.StartsWith(name) && !a.DeletionAt.HasValue)
|
||||
.Project(a => a.Include(x => x.ID)).Limit(500).ExecuteAsync(token)).Select(a => a.ID).ToList();
|
||||
var owners = personaIds.Count == 0
|
||||
? new List<string>()
|
||||
: (await DB.Default.Find<RootToAvatar>().Match(ra => personaIds.Contains(ra.AvatarId)).ExecuteAsync(token)).Select(ra => ra.RootId).Distinct().ToList();
|
||||
query.Match(u => u.UserName.StartsWith(name) || owners.Contains(u.ID));
|
||||
}
|
||||
if (!string.IsNullOrEmpty(before))
|
||||
{
|
||||
if (!MongoDB.Bson.ObjectId.TryParse(before, out _))
|
||||
return BadRequest();
|
||||
query.Match(f => f.Lt(u => u.ID, before));
|
||||
}
|
||||
var roots = await query.Sort(u => u.ID, Order.Descending).Limit(limit).ExecuteAsync(token);
|
||||
return Ok(new ViewAdminRootsPage
|
||||
{
|
||||
Items = await Views(roots, token),
|
||||
Next = roots.Count == limit ? roots[^1].ID : default
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet, Route("/clientapi/admin/roots/{id}")]
|
||||
public async Task<IActionResult> Root(string id, CancellationToken token)
|
||||
{
|
||||
var root = await DB.Default.Find<RootUser>().MatchID(id).Match(u => u.DeletedAt == null).ExecuteFirstAsync(token);
|
||||
return root == default ? NotFound() : Ok((await Views(new List<RootUser> { root }, token))[0]);
|
||||
}
|
||||
|
||||
// A login's role: user, moderator or administrator. Never one's own, and never the last administrator's.
|
||||
[HttpPost, Route("/clientapi/admin/roots/{id}/role")]
|
||||
public async Task<IActionResult> Role(string id, RootRoleForm form, CancellationToken token)
|
||||
{
|
||||
if (!ModelState.IsValid || !RootUser.TryParseRole(form.Role, out var role))
|
||||
return UnprocessableEntity(new WebResult().Invalidate(Localizer["Unknown role."], StatusCodes.Status422UnprocessableEntity));
|
||||
if (id == User.GetUserId())
|
||||
return Conflict(new WebResult().Invalidate(Localizer["You can't change your own role."], StatusCodes.Status409Conflict));
|
||||
var root = await DB.Default.Find<RootUser>().MatchID(id).Match(u => u.DeletedAt == null).ExecuteFirstAsync(token);
|
||||
if (root == default)
|
||||
return NotFound();
|
||||
var was = root.Role;
|
||||
if (was == role)
|
||||
return Ok((await Views(new List<RootUser> { root }, token))[0]);
|
||||
if (was == RootRole.Admin && !await OtherAdministrators(root.ID, token))
|
||||
return Conflict(new WebResult().Invalidate(Localizer["The last administrator can't be demoted."], StatusCodes.Status409Conflict));
|
||||
root.SetRole(role);
|
||||
await DB.Default.Update<RootUser>().MatchID(root.ID).Modify(u => u.Policies, root.Policies).Modify(u => u.UpdatedAt, root.UpdatedAt)
|
||||
.ExecuteAsync(CancellationToken.None);
|
||||
// two administrators demoting each other at once: this one's act is undone, so one always remains
|
||||
if (was == RootRole.Admin && !await OtherAdministrators(root.ID, CancellationToken.None))
|
||||
{
|
||||
root.SetRole(was);
|
||||
await DB.Default.Update<RootUser>().MatchID(root.ID).Modify(u => u.Policies, root.Policies).ExecuteAsync(CancellationToken.None);
|
||||
return Conflict(new WebResult().Invalidate(Localizer["The last administrator can't be demoted."], StatusCodes.Status409Conflict));
|
||||
}
|
||||
if (role == RootRole.Admin)
|
||||
await ServerSetup.Seal(root.ID, default);//an administrator exists: the client's setup is closed
|
||||
await _log.Write(User.GetUserId(), AdminActions.RootRole, AdminTargets.Root, root.ID, root.UserName,
|
||||
note: $"{RoleName(was)} → {RoleName(role)}", token: CancellationToken.None);
|
||||
return Ok((await Views(new List<RootUser> { root }, token))[0]);
|
||||
}
|
||||
|
||||
// an administrator other than this login that can still act
|
||||
static Task<bool> OtherAdministrators(string rootId, CancellationToken token) =>
|
||||
DB.Default.Find<RootUser>().Match(u => u.ID != rootId && u.DeletedAt == null && !u.IsBanned && u.Policies.Contains(Policies.IsAdmin)).ExecuteAnyAsync(token);
|
||||
|
||||
// A login that lost its authenticator and its recovery codes, as `PrivaPub admin reset-totp` does it: its two-factor
|
||||
// sign-in goes, its password and sessions stay. Never one's own (that is /clientapi/user/totp/disable, with a code).
|
||||
[HttpPost, Route("/clientapi/admin/roots/{id}/totp/reset")]
|
||||
public async Task<IActionResult> ResetTotp(string id, CancellationToken token)
|
||||
{
|
||||
if (id == User.GetUserId())
|
||||
return Conflict(new WebResult().Invalidate(Localizer["Turn your own two-factor sign-in off from your account."], StatusCodes.Status409Conflict));
|
||||
var root = await DB.Default.Find<RootUser>().MatchID(id).Match(u => u.DeletedAt == null).ExecuteFirstAsync(token);
|
||||
if (root == default)
|
||||
return NotFound();
|
||||
await DB.Default.Update<RootUser>().MatchID(root.ID).Modify(u => u.Totp, null).ExecuteAsync(CancellationToken.None);
|
||||
await _log.Write(User.GetUserId(), AdminActions.RootResetTotp, AdminTargets.Root, root.ID, root.UserName, token: CancellationToken.None);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// A persona deleted by an administrator, who types their own password again: as its login would delete it
|
||||
// (PersonaRemoval), the groups it owns with it. The log keeps it without who acted when it is the administrator's own.
|
||||
[HttpPost, Route("/clientapi/admin/personas/{id}/delete")]
|
||||
public async Task<IActionResult> DeletePersona(string id, AdminPasswordForm form, [FromServices] IPersonaRemoval personas, CancellationToken token)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."]));
|
||||
if (!await RootUsersService.PasswordMatches(User.GetUserId(), form.Password))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new WebResult().Invalidate(Localizer[Services.RootUsersService.NoMatch], StatusCodes.Status403Forbidden));
|
||||
var avatar = await _dbEntities.Avatars.MatchID(id).Match(a => !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (avatar == default)
|
||||
return NotFound();
|
||||
var label = "@" + _localActors.FromAvatar(avatar).Handle;
|
||||
if (!await personas.Remove(avatar.ID, CancellationToken.None))
|
||||
return NotFound();
|
||||
await _log.Write(User.GetUserId(), AdminActions.PersonaDelete, AdminTargets.Persona, avatar.ID, label, note: form.Note, token: CancellationToken.None);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPost, Route("/clientapi/admin/groups/{id}/delete")]
|
||||
public async Task<IActionResult> DeleteGroup(string id, AdminPasswordForm form, [FromServices] IGroupRemoval groups, CancellationToken token)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."]));
|
||||
if (!await RootUsersService.PasswordMatches(User.GetUserId(), form.Password))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new WebResult().Invalidate(Localizer[Services.RootUsersService.NoMatch], StatusCodes.Status403Forbidden));
|
||||
var group = await _dbEntities.Groups.MatchID(id).Match(g => !g.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (group == default)
|
||||
return NotFound();
|
||||
var label = "@" + _localActors.FromGroup(group).Handle;
|
||||
if (!await groups.Remove(group.ID, CancellationToken.None))
|
||||
return NotFound();
|
||||
await _log.Write(User.GetUserId(), AdminActions.GroupDelete, AdminTargets.Group, group.ID, label, note: form.Note, token: CancellationToken.None);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
async Task<List<ViewAdminRoot>> Views(List<RootUser> roots, CancellationToken token)
|
||||
{
|
||||
var ids = roots.Select(r => r.ID).ToList();
|
||||
var links = ids.Count == 0 ? new List<RootToAvatar>() : await _dbEntities.RootToAvatars.Match(ra => ids.Contains(ra.RootId)).ExecuteAsync(token);
|
||||
var avatarIds = links.Select(l => l.AvatarId).Distinct().ToList();
|
||||
var avatars = avatarIds.Count == 0
|
||||
? new Dictionary<string, Avatar>()
|
||||
: (await _dbEntities.Avatars.Match(a => avatarIds.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token)).ToDictionary(a => a.ID);
|
||||
var owned = avatarIds.Count == 0
|
||||
? new List<GroupEntity>()
|
||||
: await _dbEntities.Groups.Match(g => avatarIds.Contains(g.OwnerAvatarId) && !g.DeletionAt.HasValue).ExecuteAsync(token);
|
||||
var me = User.GetUserId();
|
||||
return roots.Select(root => new ViewAdminRoot
|
||||
{
|
||||
Id = root.ID,
|
||||
UserName = root.UserName,
|
||||
Role = RoleName(root.Role),
|
||||
IsBanned = root.IsBanned,
|
||||
HasEmail = !string.IsNullOrEmpty(root.Email),
|
||||
IsEmailValidated = root.IsEmailValidated,
|
||||
HasTotp = root.Totp?.EnabledAt != default,
|
||||
IsYou = root.ID == me,
|
||||
CreatedAt = root.CreatedAt,
|
||||
Personas = links.Where(l => l.RootId == root.ID && avatars.ContainsKey(l.AvatarId)).Select(l => avatars[l.AvatarId])
|
||||
.OrderBy(a => a.UserName, StringComparer.Ordinal)
|
||||
.Select(a =>
|
||||
{
|
||||
var persona = _localActors.FromAvatar(a);
|
||||
return new ViewAdminPersona
|
||||
{
|
||||
Id = a.ID,
|
||||
UserName = a.UserName,
|
||||
DisplayName = persona.Name,
|
||||
Handle = persona.Handle,
|
||||
SilencedAt = a.SilencedAt,
|
||||
SuspendedAt = a.SuspendedAt,
|
||||
Groups = owned.Where(g => g.OwnerAvatarId == a.ID).Select(g => new ViewAdminGroup
|
||||
{
|
||||
Id = g.ID,
|
||||
UserName = g.UserName,
|
||||
Name = g.Name,
|
||||
IsCommunity = g.Kind == GroupKind.Community,
|
||||
MembersCount = g.Members.Count
|
||||
}).ToList()
|
||||
};
|
||||
}).ToList()
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
static string RoleName(RootRole role) => role switch
|
||||
{
|
||||
RootRole.Admin => "admin",
|
||||
RootRole.Moderator => "moderator",
|
||||
_ => "user"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,12 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
readonly IStringLocalizer _localizer;
|
||||
readonly Domain.Media.IMediaProxy _proxy;
|
||||
readonly IModerationLog _log;
|
||||
readonly IAccountModeration _moderation;
|
||||
|
||||
public DomainBlockController(IDomainBlocks domainBlocks, IStringLocalizer<GenericRes> localizer, Domain.Media.IMediaProxy proxy, IModerationLog log)
|
||||
public DomainBlockController(IDomainBlocks domainBlocks, IStringLocalizer<GenericRes> localizer, Domain.Media.IMediaProxy proxy, IModerationLog log,
|
||||
IAccountModeration moderation)
|
||||
{
|
||||
_moderation = moderation;
|
||||
_domainBlocks = domainBlocks;
|
||||
_localizer = localizer;
|
||||
_proxy = proxy;
|
||||
@@ -57,6 +60,8 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
.Option(o => o.IsUpsert = true)
|
||||
.ExecuteAsync(token);
|
||||
await _domainBlocks.Reload(token);
|
||||
// a silenced server's posts are limited from now on, and no longer once it is not (R6)
|
||||
await _moderation.DomainChanged(domain, token);
|
||||
// what the media proxy holds of it goes with the block
|
||||
if (block.Severity == DomainBlockSeverity.Suspend || block.RejectMedia)
|
||||
_proxy.Purge(domain);
|
||||
@@ -72,6 +77,8 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
domain = DomainBlocks.Normalise(domain);
|
||||
var deleted = await DB.Default.DeleteAsync<DomainBlock>(b => b.Domain == domain);
|
||||
await _domainBlocks.Reload(token);
|
||||
if (deleted.DeletedCount > 0)
|
||||
await _moderation.DomainChanged(domain, token);
|
||||
if (deleted.DeletedCount > 0)
|
||||
await _log.Write(User.GetUserId(), AdminActions.DomainBlockDelete, AdminTargets.Domain, domain, domain, token: token);
|
||||
return Ok();
|
||||
|
||||
@@ -60,6 +60,27 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
public async Task<IActionResult> Remove(GroupMembershipForm form, CancellationToken token) =>
|
||||
!ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.RemoveMember(User.GetUserId(), form, token));
|
||||
|
||||
// the group's owner deletes it, with the login's password (R6): its members and followers are told, its name stays
|
||||
// taken (GroupRemoval)
|
||||
[HttpPost, Route("/clientapi/group/delete"), Microsoft.AspNetCore.RateLimiting.EnableRateLimiting(Infrastructure.RateLimiting.Accounts)]
|
||||
public async Task<IActionResult> Delete(DeleteGroupForm form, [FromServices] IRootUsersService users, [FromServices] IGroupRemoval groups,
|
||||
[FromServices] StaticServices.DbEntities dbEntities, CancellationToken token)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return Invalid();
|
||||
var rootId = User.GetUserId();
|
||||
if (!await users.PasswordMatches(rootId, form.Password))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new WebResult().Invalidate(_localizer[RootUsersService.NoMatch], StatusCodes.Status403Forbidden));
|
||||
var owns = await dbEntities.RootToAvatars.Match(ra => ra.RootId == rootId && ra.AvatarId == form.AvatarId).ExecuteAnyAsync(token);
|
||||
var group = owns ? await dbEntities.Groups.MatchID(form.GroupId).Match(g => !g.DeletionAt.HasValue).ExecuteFirstAsync(token) : default;
|
||||
if (group == default)
|
||||
return NotFound(new WebResult().Invalidate(_localizer["Group not found."], StatusCodes.Status404NotFound));
|
||||
if (group.OwnerAvatarId != form.AvatarId)
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new WebResult().Invalidate(_localizer["Only the group's owner can delete it."], StatusCodes.Status403Forbidden));
|
||||
await groups.Remove(group.ID, CancellationToken.None);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
IActionResult Invalid() => BadRequest(new WebResult().Invalidate(_localizer["Invalid model."]));
|
||||
|
||||
IActionResult Answer(WebResult result) => result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
|
||||
|
||||
@@ -230,7 +230,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
// a local persona whose login may act: the contact is shown to everyone as the one who runs the server
|
||||
static async Task<bool> IsContactable(string avatarId, CancellationToken token) =>
|
||||
await DB.Default.Find<Avatar>().Match(a => a.ID == avatarId && !a.DeletionAt.HasValue).ExecuteAnyAsync(token)
|
||||
&& !(await Counted.BarredPersonas(token)).Contains(avatarId);
|
||||
&& !(await Counted.HeldBackPersonas(token)).Contains(avatarId);
|
||||
|
||||
static string Clean(string text) => string.IsNullOrWhiteSpace(text) ? default : text.Trim();
|
||||
|
||||
|
||||
@@ -21,13 +21,22 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
{
|
||||
const int MaxLogPage = 100;
|
||||
|
||||
const int MaxAccountsPage = 100;
|
||||
|
||||
readonly IInstanceSettings _settings;
|
||||
readonly IModerationLog _log;
|
||||
readonly IAccountModeration _moderation;
|
||||
readonly Federation.Actors.ILocalActorService _localActors;
|
||||
readonly Microsoft.Extensions.Localization.IStringLocalizer<Resources.GenericRes> _localizer;
|
||||
|
||||
public ModeratorController(IInstanceSettings settings, IModerationLog log)
|
||||
public ModeratorController(IInstanceSettings settings, IModerationLog log, IAccountModeration moderation,
|
||||
Federation.Actors.ILocalActorService localActors, Microsoft.Extensions.Localization.IStringLocalizer<Resources.GenericRes> localizer)
|
||||
{
|
||||
_settings = settings;
|
||||
_log = log;
|
||||
_moderation = moderation;
|
||||
_localActors = localActors;
|
||||
_localizer = localizer;
|
||||
}
|
||||
|
||||
[HttpGet, Route("/clientapi/moderator/reports")]
|
||||
@@ -49,6 +58,8 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
Comment = r.Comment,
|
||||
Forwarded = r.Forwarded,
|
||||
IsResolved = r.IsResolved,
|
||||
Resolution = r.Resolution,
|
||||
ResolutionNote = r.ResolutionNote,
|
||||
CreatedAt = r.CreatedAt,
|
||||
ResolvedAt = r.ResolvedAt,
|
||||
RemoteResolvedAt = r.RemoteResolvedAt,
|
||||
@@ -56,22 +67,136 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
}));
|
||||
}
|
||||
|
||||
// A report resolved, and with it what the staff does about it (R6): nothing more, the reported account silenced or
|
||||
// suspended, or the reported posts removed. The act comes first: one that fails leaves the report open. The body is
|
||||
// optional (a report only marked resolved, as before R6).
|
||||
[HttpPost, Route("/clientapi/moderator/reports/{id}/resolve")]
|
||||
public async Task<IActionResult> Resolve(string id, CancellationToken token)
|
||||
public async Task<IActionResult> Resolve(string id, [FromBody(EmptyBodyBehavior = Microsoft.AspNetCore.Mvc.ModelBinding.EmptyBodyBehavior.Allow)] ResolveReportForm form,
|
||||
CancellationToken token)
|
||||
{
|
||||
form ??= new ResolveReportForm();
|
||||
var action = string.IsNullOrWhiteSpace(form.Action) ? ModerationActions.None : form.Action.Trim().ToLowerInvariant();
|
||||
if (!ModerationActions.IsResolution(action))
|
||||
return UnprocessableEntity(new WebResult().Invalidate(_localizer["Unknown moderation action."], StatusCodes.Status422UnprocessableEntity));
|
||||
var by = User.GetUserId();
|
||||
var result = await DB.Default.Update<Report>().MatchID(id)
|
||||
var report = await DB.Default.Find<Report>().MatchID(id).ExecuteFirstAsync(token);
|
||||
if (report == default)
|
||||
return NotFound();
|
||||
switch (action)
|
||||
{
|
||||
case ModerationActions.Silence or ModerationActions.Suspend:
|
||||
var moderated = await _moderation.Moderate(by, report.TargetAccountId, action, form.Note, form.Warning, report.ID, token);
|
||||
if (!moderated.IsValid)
|
||||
return StatusCode(moderated.StatusCode, moderated);
|
||||
break;
|
||||
case ModerationActions.RemovePosts:
|
||||
if (report.PostIds.Count == 0)
|
||||
return UnprocessableEntity(new WebResult().Invalidate(_localizer["The report names no posts."], StatusCodes.Status422UnprocessableEntity));
|
||||
foreach (var postId in report.PostIds)
|
||||
{
|
||||
var removed = await _moderation.RemovePost(by, postId, form.Note, form.Warning, report.ID, token);
|
||||
if (!removed.IsValid && removed.StatusCode != StatusCodes.Status404NotFound)//one already gone is removed
|
||||
return StatusCode(removed.StatusCode, removed);
|
||||
}
|
||||
break;
|
||||
}
|
||||
await DB.Default.Update<Report>().MatchID(id)
|
||||
.Modify(r => r.IsResolved, true)
|
||||
.Modify(r => r.ResolvedAt, DateTime.UtcNow)
|
||||
.Modify(r => r.ResolvedBy, by)
|
||||
.ExecuteAsync(token);
|
||||
if (result.MatchedCount == 0)
|
||||
return NotFound();
|
||||
var report = await DB.Default.Find<Report>().MatchID(id).ExecuteFirstAsync(token);
|
||||
await _log.Write(by, AdminActions.ReportResolve, AdminTargets.Report, id, report?.Category, reportId: id, token: token);
|
||||
.Modify(r => r.Resolution, action)
|
||||
.Modify(r => r.ResolutionNote, string.IsNullOrWhiteSpace(form.Note) ? null : form.Note.Trim())
|
||||
.ExecuteAsync(CancellationToken.None);
|
||||
await _log.Write(by, AdminActions.ReportResolve, AdminTargets.Report, id, report.Category, reportId: id,
|
||||
note: action == ModerationActions.None ? form.Note : string.IsNullOrWhiteSpace(form.Note) ? action : $"{action}: {form.Note.Trim()}",
|
||||
token: CancellationToken.None);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// Accounts to moderate, found by a name (a persona's, or user@host for an account elsewhere) or an actor's address:
|
||||
// personas here, never their logins, and accounts elsewhere PrivaPub holds
|
||||
[HttpGet, Route("/clientapi/moderator/accounts")]
|
||||
public async Task<IActionResult> Accounts([FromQuery] string q, [FromQuery] int limit = 20, CancellationToken token = default)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, 40);
|
||||
q = q?.Trim().TrimStart('@');
|
||||
var items = new List<ViewModeratedAccount>();
|
||||
if (string.IsNullOrEmpty(q) || q.Length > 300)
|
||||
return Ok(new ViewModeratedAccountsPage { Items = items });
|
||||
if (Uri.TryCreate(q, UriKind.Absolute, out var address) && address.Scheme is "https" or "http")
|
||||
{
|
||||
if (await _localActors.FindByUri(q, token) is { Kind: Models.Federation.LocalActorKind.Person } local
|
||||
&& await DB.Default.Find<Avatar>().MatchID(local.Id).ExecuteFirstAsync(token) is { } persona)
|
||||
items.Add(_moderation.View(persona));
|
||||
items.AddRange((await DB.Default.Find<ForeignAvatar>().Match(f => f.ActorURI == q || f.Url == q).Limit(limit).ExecuteAsync(token)).Select(_moderation.View));
|
||||
return Ok(new ViewModeratedAccountsPage { Items = items.Take(limit).ToList() });
|
||||
}
|
||||
var parts = q.Split('@', 2);
|
||||
var name = parts[0];
|
||||
var domain = parts.Length > 1 ? parts[1].ToLowerInvariant() : default;
|
||||
var here = new Uri(_localActors.BaseAddress).Authority;
|
||||
if (domain == default || domain == here)
|
||||
{
|
||||
var lowered = name.ToLowerInvariant();
|
||||
items.AddRange((await DB.Default.Find<Avatar>().Match(a => a.UserName.StartsWith(lowered) && !a.DeletionAt.HasValue)
|
||||
.Sort(a => a.UserName, Order.Ascending).Limit(limit).ExecuteAsync(token)).Select(_moderation.View));
|
||||
}
|
||||
if (domain != here && items.Count < limit && name.Length > 0)
|
||||
{
|
||||
var userName = new MongoDB.Bson.BsonRegularExpression("^" + System.Text.RegularExpressions.Regex.Escape(name), "i");
|
||||
var remote = DB.Default.Find<ForeignAvatar>().Match(f => f.Regex(a => a.UserName, userName));
|
||||
if (domain != default)
|
||||
remote.Match(f => f.Regex(a => a.Domain, new MongoDB.Bson.BsonRegularExpression("^" + System.Text.RegularExpressions.Regex.Escape(domain), "i")));
|
||||
items.AddRange((await remote.Match(f => !f.DeletionAt.HasValue).Sort(f => f.UserName, Order.Ascending).Limit(limit - items.Count).ExecuteAsync(token))
|
||||
.Select(_moderation.View));
|
||||
}
|
||||
return Ok(new ViewModeratedAccountsPage { Items = items });
|
||||
}
|
||||
|
||||
// what the staff silenced or suspended, here and elsewhere, most recently moderated first (a banned login's personas
|
||||
// are never among them: the ban is the login's, and only administrators see logins)
|
||||
[HttpGet, Route("/clientapi/moderator/accounts/moderated")]
|
||||
public async Task<IActionResult> Moderated([FromQuery] int offset = 0, [FromQuery] int limit = 50, CancellationToken token = default)
|
||||
{
|
||||
offset = Math.Max(0, offset);
|
||||
limit = Math.Clamp(limit, 1, MaxAccountsPage);
|
||||
var locals = await DB.Default.Find<Avatar>().Match(a => (a.SilencedAt != null || a.SuspendedAt != null) && a.DeletionAt == null).ExecuteAsync(token);
|
||||
var remotes = await DB.Default.Find<ForeignAvatar>().Match(f => f.SilencedAt != null || f.SuspendedAt != null).ExecuteAsync(token);
|
||||
var all = locals.Select(_moderation.View).Concat(remotes.Select(_moderation.View))
|
||||
.OrderByDescending(a => Latest(a.SilencedAt, a.SuspendedAt)).ThenBy(a => a.Id, StringComparer.Ordinal).ToList();
|
||||
return Ok(new ViewModeratedAccountsPage
|
||||
{
|
||||
Items = all.Skip(offset).Take(limit).ToList(),
|
||||
Next = all.Count > offset + limit ? offset + limit : default(int?)
|
||||
});
|
||||
}
|
||||
|
||||
static DateTime Latest(DateTime? a, DateTime? b) => (a ?? DateTime.MinValue) > (b ?? DateTime.MinValue) ? a ?? DateTime.MinValue : b ?? DateTime.MinValue;
|
||||
|
||||
[HttpGet, Route("/clientapi/moderator/accounts/{id}")]
|
||||
public async Task<IActionResult> Account(string id, CancellationToken token) =>
|
||||
await _moderation.View(id, token) is { } account ? Ok(account) : NotFound();
|
||||
|
||||
// silence, unsilence, suspend or unsuspend a persona here or an account elsewhere
|
||||
[HttpPost, Route("/clientapi/moderator/accounts/moderate")]
|
||||
public async Task<IActionResult> Moderate(ModerateAccountForm form, CancellationToken token)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(new WebResult().Invalidate(_localizer["Invalid model."]));
|
||||
var result = await _moderation.Moderate(User.GetUserId(), form.AccountId, form.Action.Trim().ToLowerInvariant(), form.Note, form.Warning, default, token);
|
||||
return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
|
||||
}
|
||||
|
||||
// a post taken down: deleted when it is ours (the deletion federates), hidden here when it is a copy
|
||||
[HttpPost, Route("/clientapi/moderator/posts/remove")]
|
||||
public async Task<IActionResult> RemovePost(RemovePostForm form, CancellationToken token)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(new WebResult().Invalidate(_localizer["Invalid model."]));
|
||||
var result = await _moderation.RemovePost(User.GetUserId(), form.PostId, form.Note, form.Warning, default, token);
|
||||
return result.IsValid ? Ok() : StatusCode(result.StatusCode, result);
|
||||
}
|
||||
|
||||
// The staff's log, newest first, a page at a time (before: the last id of the page before). Administrators read all
|
||||
// of it; a moderator who isn't one reads what isn't about a login, and who acted only when it was the command line or
|
||||
// themselves.
|
||||
|
||||
@@ -110,6 +110,9 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
{
|
||||
if (!await Owns(avatarId, token))
|
||||
return NotFound();
|
||||
// a suspended persona may take its archive away, never bring one in (R6): an import follows, blocks and posts
|
||||
if (await DB.Default.Find<Avatar>().MatchID(avatarId).Match(a => a.SuspendedAt != null).ExecuteAnyAsync(token))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new WebResult().Invalidate("This persona is suspended.", StatusCodes.Status403Forbidden));
|
||||
var state = await PersonaArchives.Of(avatarId, token);
|
||||
if (state.ImportState is "queued" or "running")
|
||||
return Conflict(new WebResult().Invalidate("An import is running."));
|
||||
|
||||
@@ -5,6 +5,7 @@ using PrivaPub.ClientModels;
|
||||
using PrivaPub.Extensions;
|
||||
using PrivaPub.Resources;
|
||||
using PrivaPub.Services.ClientToServer.Private;
|
||||
using PrivaPub.ClientModels.User;
|
||||
using PrivaPub.ClientModels.User.Avatar;
|
||||
|
||||
namespace PrivaPub.Controllers.ClientToServer
|
||||
@@ -56,6 +57,23 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
}
|
||||
}
|
||||
|
||||
// A login deletes one of its personas, with its password again (R6): everything public it had goes, the groups it owns
|
||||
// with it, as when the whole login is deleted (PersonaRemoval). A suspended persona may be deleted too.
|
||||
[HttpPost, Route("/clientapi/avatar/private/delete"), Microsoft.AspNetCore.RateLimiting.EnableRateLimiting(Infrastructure.RateLimiting.Accounts)]
|
||||
public async Task<IActionResult> DeleteAvatar(DeletePersonaForm form, [FromServices] Services.IRootUsersService users,
|
||||
[FromServices] Services.IPersonaRemoval personas, [FromServices] StaticServices.DbEntities dbEntities, CancellationToken token)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(new WebResult().Invalidate(_localizer["Invalid model."]));
|
||||
var rootId = User.GetUserId();
|
||||
if (!await users.PasswordMatches(rootId, form.Password))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new WebResult().Invalidate(_localizer[Services.RootUsersService.NoMatch], StatusCodes.Status403Forbidden));
|
||||
if (!await dbEntities.RootToAvatars.Match(ra => ra.RootId == rootId && ra.AvatarId == form.AvatarId).ExecuteAnyAsync(token)
|
||||
|| !await personas.Remove(form.AvatarId, CancellationToken.None))
|
||||
return NotFound(new WebResult().Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound));
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPost, Route("/clientapi/avatar/private/update")]
|
||||
public async Task<IActionResult> UpdateAvatar(UpdateAvatarForm model)
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user