using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MongoDB.Entities; using PrivaPub.ClientModels; using PrivaPub.ClientModels.Admin; using PrivaPub.Domain.Instance; using PrivaPub.Domain.Moderation; using PrivaPub.Extensions; using PrivaPub.Models; using PrivaPub.Models.Social; using PrivaPub.Models.User; namespace PrivaPub.Controllers.ClientToServer { [ApiController, Route("clientapi/moderator"), Authorize(Policy = Policies.IsModerator)] public class ModeratorController : ControllerBase { 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 _localizer; public ModeratorController(IInstanceSettings settings, IModerationLog log, IAccountModeration moderation, Federation.Actors.ILocalActorService localActors, Microsoft.Extensions.Localization.IStringLocalizer localizer) { _settings = settings; _log = log; _moderation = moderation; _localActors = localActors; _localizer = localizer; } [HttpGet, Route("/clientapi/moderator/reports")] public async Task Reports([FromQuery] bool resolved, CancellationToken token) { var rules = _settings.Current.Rules.ToDictionary(r => r.Id, r => r.Text ?? string.Empty); return Ok((await DB.Default.Find().Match(r => r.IsResolved == resolved).Sort(r => r.ID, Order.Descending).Limit(100).ExecuteAsync(token)) .Select(r => new ViewReport { ID = r.ID, Reporter = r.ReporterAvatarId != default ? "local" : r.ReporterActorURI, TargetAccountId = r.TargetAccountId, TargetActorURI = r.TargetActorURI, PostIds = r.PostIds, ObjectURIs = r.ObjectURIs, Category = r.Category, RuleIds = r.RuleIds ?? new(), Rules = (r.RuleIds ?? new()).Select(id => rules.GetValueOrDefault(id, string.Empty)).ToList(), Comment = r.Comment, Forwarded = r.Forwarded, IsResolved = r.IsResolved, Resolution = r.Resolution, ResolutionNote = r.ResolutionNote, CreatedAt = r.CreatedAt, ResolvedAt = r.ResolvedAt, RemoteResolvedAt = r.RemoteResolvedAt, RemoteResolvedBy = r.RemoteResolvedBy })); } // 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 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 report = await DB.Default.Find().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().MatchID(id) .Modify(r => r.IsResolved, true) .Modify(r => r.ResolvedAt, DateTime.UtcNow) .Modify(r => r.ResolvedBy, by) .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 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(); 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().MatchID(local.Id).ExecuteFirstAsync(token) is { } persona) items.Add(_moderation.View(persona)); items.AddRange((await DB.Default.Find().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().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().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 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().Match(a => (a.SilencedAt != null || a.SuspendedAt != null) && a.DeletionAt == null).ExecuteAsync(token); var remotes = await DB.Default.Find().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 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 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 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. [HttpGet, Route("/clientapi/moderator/log")] public async Task Log([FromQuery] string before, [FromQuery] int limit = 50, CancellationToken token = default) { var me = await DB.Default.Find().MatchID(User.GetUserId()).ExecuteFirstAsync(token); if (me is not { IsBanned: false, DeletedAt: null } || !me.Policies.Contains(Policies.IsModerator)) return Forbid(); var admin = me.Policies.Contains(Policies.IsAdmin); limit = Math.Clamp(limit, 1, MaxLogPage); var query = DB.Default.Find(); if (!string.IsNullOrEmpty(before)) { if (!MongoDB.Bson.ObjectId.TryParse(before, out _)) return BadRequest(); query.Match(f => f.Lt(a => a.ID, before)); } if (!admin) query.Match(a => a.TargetKind != AdminTargets.Root); var actions = await query.Sort(a => a.ID, Order.Descending).Limit(limit).ExecuteAsync(token); var actorIds = actions.Select(a => a.ByRootId).Where(id => id != default && id != AdminAction.Cli).Distinct().ToList(); var names = admin && actorIds.Count > 0 ? (await DB.Default.Find().Match(u => actorIds.Contains(u.ID)).Project(u => u.Include(x => x.UserName)).ExecuteAsync(token)) .ToDictionary(u => u.ID, u => u.UserName) : new Dictionary(); return Ok(new ViewAdminActionsPage { Items = actions.Select(a => new ViewAdminAction { Id = a.ID, At = a.At, By = a.ByRootId == AdminAction.Cli ? AdminAction.Cli : a.ByRootId == default ? default : admin ? names.GetValueOrDefault(a.ByRootId, a.ByRootId) : a.ByRootId == me.ID ? me.UserName : default, ByYou = a.ByRootId == me.ID, Action = a.Action, TargetKind = a.TargetKind, TargetId = a.TargetId, TargetLabel = a.TargetLabel, ReportId = a.ReportId, Note = a.Note }).ToList(), Next = actions.Count == limit ? actions[^1].ID : default }); } } }