using Microsoft.AspNetCore.Authorization; 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"), Authorize(Policy = Policies.IsAdmin)] public class AdminController : ControllerBase { const int MaxRootsPage = 100; readonly IRootUsersService RootUsersService; readonly ILogger Logger; readonly IStringLocalizer Localizer; readonly IModerationLog _log; readonly DbEntities _dbEntities; readonly ILocalActorService _localActors; public AdminController(ILogger logger, IStringLocalizer localizer, 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).Distinct().ToList() }; } // the logins a request names that exist and are not deleted, by id: what the log names them static async Task> Named(UsersIds usersIds, CancellationToken token) { var ids = usersIds.UserIdList; return ids.Count == 0 ? new Dictionary() : (await DB.Default.Find().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 RemoveUsers([Required] UsersIds usersIds, CancellationToken token) { var result = new WebResult(); try { 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) { Logger.LogError(ex, $"{nameof(User)}.{nameof(RemoveUsers)}()"); return BadRequest(result.Invalidate(Localizer["Something went wrong."])); } } // 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 BanUsers([Required] UsersIds usersIds, [FromServices] IRootSessions sessions, CancellationToken token) { if (!ModelState.IsValid) return BadRequest(usersIds); var result = new WebResult(); try { var others = Others(usersIds); var named = await Named(others, token); var banned = (await DB.Default.Find().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) { Logger.LogError(ex, $"{nameof(User)}.{nameof(BanUsers)}()"); return BadRequest(result.Invalidate(Localizer["Something went wrong."])); } } [HttpPost, Route("/clientapi/admin/unban/users")] public async Task UnbanUsers([Required] UsersIds usersIds, CancellationToken token) { if (!ModelState.IsValid) return BadRequest(usersIds); var result = new WebResult(); try { var others = Others(usersIds); var named = await Named(others, token); var banned = (await DB.Default.Find().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) { Logger.LogError(ex, $"{nameof(User)}.{nameof(UnbanUsers)}()"); 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 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().Match(u => u.DeletedAt == null); var name = q?.Trim().TrimStart('@').Split('@')[0].ToLowerInvariant(); if (!string.IsNullOrEmpty(name)) { var personaIds = (await DB.Default.Find().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() : (await DB.Default.Find().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 Root(string id, CancellationToken token) { var root = await DB.Default.Find().MatchID(id).Match(u => u.DeletedAt == null).ExecuteFirstAsync(token); return root == default ? NotFound() : Ok((await Views(new List { 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 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().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 { 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().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().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 { root }, token))[0]); } // an administrator other than this login that can still act static Task OtherAdministrators(string rootId, CancellationToken token) => DB.Default.Find().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 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().MatchID(id).Match(u => u.DeletedAt == null).ExecuteFirstAsync(token); if (root == default) return NotFound(); await DB.Default.Update().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 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 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> Views(List roots, CancellationToken token) { var ids = roots.Select(r => r.ID).ToList(); var links = ids.Count == 0 ? new List() : 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() : (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() : 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" }; } }