2026-10-01 12:13:57 +02:00
|
|
|
using Microsoft.AspNetCore.Authorization;
|
|
|
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
|
|
|
|
|
|
using MongoDB.Entities;
|
|
|
|
|
|
|
|
|
|
using PrivaPub.ClientModels;
|
2026-10-05 05:32:32 +02:00
|
|
|
using PrivaPub.ClientModels.Admin;
|
2026-10-17 04:02:47 +02:00
|
|
|
using PrivaPub.Domain.Instance;
|
|
|
|
|
using PrivaPub.Domain.Moderation;
|
2026-10-01 12:13:57 +02:00
|
|
|
using PrivaPub.Extensions;
|
2026-10-17 04:02:47 +02:00
|
|
|
using PrivaPub.Models;
|
2026-10-01 12:13:57 +02:00
|
|
|
using PrivaPub.Models.Social;
|
2026-10-17 04:02:47 +02:00
|
|
|
using PrivaPub.Models.User;
|
2023-02-18 08:52:17 +01:00
|
|
|
|
2023-02-19 00:43:43 +01:00
|
|
|
namespace PrivaPub.Controllers.ClientToServer
|
2023-02-18 08:52:17 +01:00
|
|
|
{
|
|
|
|
|
[ApiController,
|
2026-10-01 12:13:57 +02:00
|
|
|
Route("clientapi/moderator"),
|
|
|
|
|
Authorize(Policy = Policies.IsModerator)]
|
2023-02-18 08:52:17 +01:00
|
|
|
public class ModeratorController : ControllerBase
|
|
|
|
|
{
|
2026-10-17 04:02:47 +02:00
|
|
|
const int MaxLogPage = 100;
|
|
|
|
|
|
2026-10-17 05:23:45 +02:00
|
|
|
const int MaxAccountsPage = 100;
|
|
|
|
|
|
2026-10-17 04:02:47 +02:00
|
|
|
readonly IInstanceSettings _settings;
|
|
|
|
|
readonly IModerationLog _log;
|
2026-10-17 05:23:45 +02:00
|
|
|
readonly IAccountModeration _moderation;
|
|
|
|
|
readonly Federation.Actors.ILocalActorService _localActors;
|
|
|
|
|
readonly Microsoft.Extensions.Localization.IStringLocalizer<Resources.GenericRes> _localizer;
|
2026-10-17 04:02:47 +02:00
|
|
|
|
2026-10-17 05:23:45 +02:00
|
|
|
public ModeratorController(IInstanceSettings settings, IModerationLog log, IAccountModeration moderation,
|
|
|
|
|
Federation.Actors.ILocalActorService localActors, Microsoft.Extensions.Localization.IStringLocalizer<Resources.GenericRes> localizer)
|
2026-10-17 04:02:47 +02:00
|
|
|
{
|
|
|
|
|
_settings = settings;
|
|
|
|
|
_log = log;
|
2026-10-17 05:23:45 +02:00
|
|
|
_moderation = moderation;
|
|
|
|
|
_localActors = localActors;
|
|
|
|
|
_localizer = localizer;
|
2026-10-17 04:02:47 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-01 12:13:57 +02:00
|
|
|
[HttpGet, Route("/clientapi/moderator/reports")]
|
2026-10-17 04:02:47 +02:00
|
|
|
public async Task<IActionResult> 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<Report>().Match(r => r.IsResolved == resolved).Sort(r => r.ID, Order.Descending).Limit(100).ExecuteAsync(token))
|
2026-10-05 05:32:32 +02:00
|
|
|
.Select(r => new ViewReport
|
2026-10-01 12:13:57 +02:00
|
|
|
{
|
2026-10-05 05:32:32 +02:00
|
|
|
ID = r.ID,
|
2026-10-01 12:13:57 +02:00
|
|
|
Reporter = r.ReporterAvatarId != default ? "local" : r.ReporterActorURI,
|
2026-10-05 05:32:32 +02:00
|
|
|
TargetAccountId = r.TargetAccountId,
|
|
|
|
|
TargetActorURI = r.TargetActorURI,
|
|
|
|
|
PostIds = r.PostIds,
|
|
|
|
|
ObjectURIs = r.ObjectURIs,
|
|
|
|
|
Category = r.Category,
|
2026-10-17 04:02:47 +02:00
|
|
|
RuleIds = r.RuleIds ?? new(),
|
|
|
|
|
Rules = (r.RuleIds ?? new()).Select(id => rules.GetValueOrDefault(id, string.Empty)).ToList(),
|
2026-10-05 05:32:32 +02:00
|
|
|
Comment = r.Comment,
|
|
|
|
|
Forwarded = r.Forwarded,
|
|
|
|
|
IsResolved = r.IsResolved,
|
2026-10-17 05:23:45 +02:00
|
|
|
Resolution = r.Resolution,
|
|
|
|
|
ResolutionNote = r.ResolutionNote,
|
2026-10-05 05:32:32 +02:00
|
|
|
CreatedAt = r.CreatedAt,
|
2026-10-07 21:10:58 +02:00
|
|
|
ResolvedAt = r.ResolvedAt,
|
|
|
|
|
RemoteResolvedAt = r.RemoteResolvedAt,
|
|
|
|
|
RemoteResolvedBy = r.RemoteResolvedBy
|
2026-10-01 12:13:57 +02:00
|
|
|
}));
|
2026-10-17 04:02:47 +02:00
|
|
|
}
|
2026-10-01 12:13:57 +02:00
|
|
|
|
2026-10-17 05:23:45 +02:00
|
|
|
// 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).
|
2026-10-01 12:13:57 +02:00
|
|
|
[HttpPost, Route("/clientapi/moderator/reports/{id}/resolve")]
|
2026-10-17 05:23:45 +02:00
|
|
|
public async Task<IActionResult> Resolve(string id, [FromBody(EmptyBodyBehavior = Microsoft.AspNetCore.Mvc.ModelBinding.EmptyBodyBehavior.Allow)] ResolveReportForm form,
|
|
|
|
|
CancellationToken token)
|
2026-10-01 12:13:57 +02:00
|
|
|
{
|
2026-10-17 05:23:45 +02:00
|
|
|
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));
|
2026-10-17 04:02:47 +02:00
|
|
|
var by = User.GetUserId();
|
2026-10-17 05:23:45 +02:00
|
|
|
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)
|
2026-10-01 12:13:57 +02:00
|
|
|
.Modify(r => r.IsResolved, true)
|
|
|
|
|
.Modify(r => r.ResolvedAt, DateTime.UtcNow)
|
2026-10-17 04:02:47 +02:00
|
|
|
.Modify(r => r.ResolvedBy, by)
|
2026-10-17 05:23:45 +02:00
|
|
|
.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);
|
2026-10-17 04:02:47 +02:00
|
|
|
return Ok();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 05:23:45 +02:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 04:02:47 +02:00
|
|
|
// 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<IActionResult> Log([FromQuery] string before, [FromQuery] int limit = 50, CancellationToken token = default)
|
|
|
|
|
{
|
|
|
|
|
var me = await DB.Default.Find<RootUser>().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<AdminAction>();
|
|
|
|
|
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<RootUser>().Match(u => actorIds.Contains(u.ID)).Project(u => u.Include(x => x.UserName)).ExecuteAsync(token))
|
|
|
|
|
.ToDictionary(u => u.ID, u => u.UserName)
|
|
|
|
|
: new Dictionary<string, string>();
|
|
|
|
|
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
|
|
|
|
|
});
|
2026-10-01 12:13:57 +02:00
|
|
|
}
|
2023-02-18 08:52:17 +01:00
|
|
|
}
|
|
|
|
|
}
|