mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-26 07:29:00 -07:00
1011 lines
35 KiB
C#
1011 lines
35 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Cors;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using RadicadeServer.Admin;
|
|
using RadicadeServer.Radicade.Discord;
|
|
using RadicadeServer.API.Notifications;
|
|
using RadicadeServer.DB;
|
|
using RadicadeServer.Extensions;
|
|
using RadicadeServer.RecNet.Enums.Messages;
|
|
using RadicadeServer.RecNet.Enums.Moderation;
|
|
using RadicadeServer.RecNet.Messages;
|
|
using RadicadeServer.RecNet.Moderation;
|
|
using RadicadeServer.RecNet.Notifications;
|
|
using RadicadeServer.Utils;
|
|
using RadicadeServer.Discord.Webhooks;
|
|
|
|
namespace RadicadeServer.API.Moderation
|
|
{
|
|
public class CreateReportRequest
|
|
{
|
|
public long PlayerIdReported { get; set; }
|
|
public ReportCategory ReportCategory { get; set; }
|
|
public string Details { get; set; } = string.Empty;
|
|
public string HeightReporter { get; set; } = string.Empty;
|
|
public string? HeightReported { get; set; }
|
|
public long RoomId { get; set; }
|
|
}
|
|
|
|
public class VoteKickRequest
|
|
{
|
|
public long PlayerId { get; set; }
|
|
public bool Response { get; set; }
|
|
public long GameSessionId { get; set; }
|
|
|
|
|
|
|
|
public string Reason { get; set; } = string.Empty;
|
|
|
|
|
|
|
|
public KickReason? ReportCategory { get; set; }
|
|
}
|
|
|
|
public class KickFromEventRequest
|
|
{
|
|
public long PlayerId { get; set; }
|
|
public long GameSessionId { get; set; }
|
|
public string Response { get; set; } = string.Empty;
|
|
}
|
|
|
|
public class InstantKickRequest
|
|
{
|
|
public long PlayerId { get; set; }
|
|
public long GameSessionId { get; set; }
|
|
|
|
|
|
|
|
public string Reason { get; set; } = string.Empty;
|
|
}
|
|
|
|
public class CreateWarningRequest
|
|
{
|
|
public long WarnedPlayerId { get; set; }
|
|
public ReportCategory ReportCategory { get; set; }
|
|
public string? DisplayReason { get; set; }
|
|
public string? ModeratorNote { get; set; }
|
|
}
|
|
|
|
[Route("api/PlayerReporting/")]
|
|
public class PlayerReportingAPI : Controller
|
|
{
|
|
private readonly RecDBContext db;
|
|
|
|
private const int VoteKickThreshold = 3;
|
|
private const int AutomaticTimeoutReportThreshold = 5;
|
|
|
|
|
|
private static readonly TimeSpan KickDuration = TimeSpan.FromMinutes(15);
|
|
|
|
|
|
|
|
|
|
private sealed class VoteKickState
|
|
{
|
|
public HashSet<long> Voters { get; } = new();
|
|
public string Reason { get; set; } = string.Empty;
|
|
}
|
|
|
|
private static readonly ConcurrentDictionary<(long GameSessionId, long PlayerId), VoteKickState> _voteKicks = new();
|
|
|
|
public PlayerReportingAPI(RecDBContext dbContext) {
|
|
db = dbContext;
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private static readonly string[] PlayerIdAliases =
|
|
{ "PlayerId", "playerId", "PlayerID", "playerID", "Player_Id", "player_id",
|
|
"PlayerIds", "playerIds",
|
|
"PlayerIdReported", "playerIdReported", "ReportedPlayerId", "reportedPlayerId",
|
|
"TargetPlayerId", "targetPlayerId", "TargetPlayer", "targetPlayer",
|
|
"TargetId", "targetId", "VictimId", "victimId", "KickedPlayerId", "kickedPlayerId",
|
|
"OffendingPlayerId", "offendingPlayerId", "UserId", "userId", "Id", "id" };
|
|
|
|
private static readonly string[] ReasonAliases =
|
|
{ "Reason", "reason", "Details", "details", "Message", "message" };
|
|
|
|
|
|
|
|
|
|
private static string? LookupKickField(IFormCollection? form, JsonNode? node, string key,
|
|
IQueryCollection? query = null, RouteValueDictionary? route = null)
|
|
{
|
|
if (route != null && route.TryGetValue(key, out var routeValue) && routeValue != null)
|
|
return routeValue.ToString();
|
|
|
|
if (query != null && query.TryGetValue(key, out var queryValue))
|
|
return queryValue.FirstOrDefault();
|
|
|
|
if (form != null)
|
|
{
|
|
|
|
|
|
foreach (var formKey in form.Keys)
|
|
{
|
|
if (string.Equals(formKey, key, StringComparison.OrdinalIgnoreCase))
|
|
return form[formKey].ToString();
|
|
}
|
|
}
|
|
return LookupJsonRecursive(node, key);
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string? LookupJsonRecursive(JsonNode? node, string key)
|
|
{
|
|
if (node is JsonObject obj)
|
|
{
|
|
foreach (var kvp in obj)
|
|
{
|
|
if (string.Equals(kvp.Key, key, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (kvp.Value is JsonArray array)
|
|
return array.Count > 0 ? array[0]?.ToString() : null;
|
|
return kvp.Value?.ToString();
|
|
}
|
|
}
|
|
foreach (var kvp in obj)
|
|
{
|
|
var found = LookupJsonRecursive(kvp.Value, key);
|
|
if (found != null) return found;
|
|
}
|
|
}
|
|
else if (node is JsonArray arr)
|
|
{
|
|
foreach (var item in arr)
|
|
{
|
|
var found = LookupJsonRecursive(item, key);
|
|
if (found != null) return found;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static long ScanForPlayerId(JsonNode? node)
|
|
{
|
|
if (node is JsonValue bare && long.TryParse(bare.ToString(), out var bareId))
|
|
return bareId;
|
|
|
|
if (node is JsonArray array)
|
|
foreach (var entry in array)
|
|
if (entry is JsonValue value && long.TryParse(value.ToString(), out var arrayId) && arrayId > 0)
|
|
return arrayId;
|
|
|
|
if (node is not JsonObject obj) return 0;
|
|
|
|
foreach (var pair in obj)
|
|
{
|
|
if (pair.Key.Contains("player", StringComparison.OrdinalIgnoreCase) &&
|
|
long.TryParse(pair.Value?.ToString(), out var named) && named > 0)
|
|
return named;
|
|
}
|
|
|
|
foreach (var pair in obj)
|
|
{
|
|
var nested = ScanForPlayerId(pair.Value);
|
|
if (nested > 0) return nested;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
private static T BindKickBody<T>(IFormCollection? form, JsonNode? node,
|
|
IQueryCollection? query = null, RouteValueDictionary? route = null) where T : class, new()
|
|
{
|
|
var result = new T();
|
|
var idProperty = typeof(T).GetProperty("PlayerId");
|
|
if (idProperty != null)
|
|
{
|
|
long playerId = 0;
|
|
foreach (var alias in PlayerIdAliases)
|
|
{
|
|
var raw = LookupKickField(form, node, alias, query, route);
|
|
if (!string.IsNullOrWhiteSpace(raw) && long.TryParse(raw, out playerId))
|
|
break;
|
|
playerId = 0;
|
|
}
|
|
|
|
if (playerId <= 0) playerId = ScanForPlayerId(node);
|
|
|
|
if (playerId <= 0)
|
|
{
|
|
var text = LookupKickField(form, node, "Reason")
|
|
?? LookupKickField(form, node, "reason")
|
|
?? LookupKickField(form, node, "Details")
|
|
?? LookupKickField(form, node, "Message");
|
|
if (!string.IsNullOrWhiteSpace(text))
|
|
{
|
|
var m = System.Text.RegularExpressions.Regex.Match(text, @"\((\d+)\)\s*$");
|
|
if (m.Success && long.TryParse(m.Groups[1].Value, out var embedded))
|
|
playerId = embedded;
|
|
}
|
|
}
|
|
|
|
if (playerId > 0) idProperty.SetValue(result, playerId);
|
|
}
|
|
|
|
var reasonProperty = typeof(T).GetProperty("Reason");
|
|
if (reasonProperty != null)
|
|
{
|
|
foreach (var alias in ReasonAliases)
|
|
{
|
|
var raw = LookupKickField(form, node, alias, query, route);
|
|
if (!string.IsNullOrWhiteSpace(raw))
|
|
{
|
|
reasonProperty.SetValue(result, raw);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (var property in typeof(T).GetProperties())
|
|
{
|
|
if (property == idProperty || property == reasonProperty || property.GetSetMethod() == null)
|
|
continue;
|
|
var raw = LookupKickField(form, node, property.Name, query, route);
|
|
if (string.IsNullOrWhiteSpace(raw)) continue;
|
|
try
|
|
{
|
|
var converted = System.ComponentModel.TypeDescriptor
|
|
.GetConverter(property.PropertyType)
|
|
.ConvertFromInvariantString(raw);
|
|
if (converted != null) property.SetValue(result, converted);
|
|
}
|
|
catch { }
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private async Task<T> ReadKickBodyAsync<T>(string endpoint) where T : class, new()
|
|
{
|
|
var query = Request?.Query;
|
|
var route = RouteData?.Values;
|
|
|
|
if (Request!.HasFormContentType)
|
|
{
|
|
var form = await Request.ReadFormAsync();
|
|
return BindKickBody<T>(form, null, query, route);
|
|
}
|
|
|
|
using var reader = new StreamReader(Request.Body);
|
|
var json = await reader.ReadToEndAsync();
|
|
|
|
if (string.IsNullOrWhiteSpace(json)) return BindKickBody<T>(null, null, query, route);
|
|
try
|
|
{
|
|
return BindKickBody<T>(null, JsonNode.Parse(json), query, route);
|
|
}
|
|
catch
|
|
{
|
|
return BindKickBody<T>(null, null, query, route);
|
|
}
|
|
}
|
|
|
|
private static readonly string[] WarningStaffRoles = { "moderator", "developer" };
|
|
|
|
private static Dictionary<string, object> WarningEnvelope(bool success, string error) => new()
|
|
{
|
|
["Success"] = success,
|
|
["success"] = success,
|
|
["Error"] = error,
|
|
["error"] = error,
|
|
["Message"] = error,
|
|
};
|
|
|
|
[HttpPost("/api/playerwarnings"), Authorize]
|
|
public async Task<IActionResult> CreateWarning()
|
|
{
|
|
var request = await ReadKickBodyAsync<CreateWarningRequest>("createWarning");
|
|
var moderatorId = User.GetUserId();
|
|
if (moderatorId == null)
|
|
return Unauthorized(WarningEnvelope(false, "Unauthorized"));
|
|
|
|
var moderator = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == moderatorId.Value);
|
|
if (moderator == null || !WarningStaffRoles.Any(role => moderator.HasRole(role)))
|
|
{
|
|
return StatusCode(StatusCodes.Status403Forbidden, WarningEnvelope(false, "Forbidden"));
|
|
}
|
|
|
|
if (request.WarnedPlayerId <= 0)
|
|
return BadRequest(WarningEnvelope(false, "WarnedPlayerId is required"));
|
|
|
|
var displayReason = string.IsNullOrWhiteSpace(request.DisplayReason) ? null : request.DisplayReason.Trim();
|
|
var moderatorNote = string.IsNullOrWhiteSpace(request.ModeratorNote) ? null : request.ModeratorNote.Trim();
|
|
|
|
var warning = await IssueWarningAsync(db, request.WarnedPlayerId, request.ReportCategory,
|
|
displayReason, moderatorNote, moderatorId.Value);
|
|
|
|
if (warning == null)
|
|
{
|
|
AdminActionDiscord.LogAction($"Warn Player (awaiting decision): {request.WarnedPlayerId}",
|
|
moderator.username ?? moderatorId.Value.ToString(),
|
|
$"@{moderator.username} warned developer {request.WarnedPlayerId} ({request.ReportCategory}) - parked until they accept or deny" +
|
|
(displayReason == null ? string.Empty : $": {displayReason}"));
|
|
|
|
return Ok(WarningEnvelope(true, string.Empty));
|
|
}
|
|
|
|
AdminActionDiscord.LogAction($"Warn Player: {warning.WarnedPlayerId}",
|
|
moderator.username ?? moderatorId.Value.ToString(),
|
|
$"@{moderator.username} warned player {warning.WarnedPlayerId} ({warning.ReportCategory})" +
|
|
(displayReason == null ? string.Empty : $": {displayReason}") +
|
|
(moderatorNote == null ? string.Empty : $" | note: {moderatorNote}"));
|
|
|
|
return Ok(WarningEnvelope(true, string.Empty));
|
|
}
|
|
|
|
public static async Task<PlayerWarning?> IssueWarningAsync(
|
|
RecDBContext db,
|
|
long warnedPlayerId,
|
|
ReportCategory reportCategory,
|
|
string? displayReason,
|
|
string? moderatorNote,
|
|
long moderatorPlayerId,
|
|
bool devDecisionResolved = false)
|
|
{
|
|
var warning = new PlayerWarning
|
|
{
|
|
ModeratorPlayerId = moderatorPlayerId,
|
|
WarnedPlayerId = warnedPlayerId,
|
|
ReportCategory = reportCategory,
|
|
DisplayReason = displayReason,
|
|
ModeratorNote = moderatorNote,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
var hold = (int)PlayerUtils.WarningScreenHold.TotalSeconds;
|
|
var prompt = PlayerUtils.BuildWarningBlockDetails(warning, hold);
|
|
prompt.ModeratorNote = moderatorNote;
|
|
|
|
if (!devDecisionResolved &&
|
|
await PlayerUtils.DeferForDeveloperAsync(db, warnedPlayerId, PlayerUtils.DeveloperDecisionKind.Warning, prompt))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
await db.PlayerWarnings.AddAsync(warning);
|
|
await db.SaveChangesAsync();
|
|
|
|
await PlayerUtils.EjectToDormAsync(db, warnedPlayerId);
|
|
|
|
await PlayerUtils.DeliverWarningAsync(warnedPlayerId, PlayerUtils.BuildWarningBlockDetails(warning, hold));
|
|
|
|
return warning;
|
|
}
|
|
|
|
[HttpPost("/api/playerwarnings/acknowledge"), Authorize]
|
|
public async Task<IActionResult> AcknowledgeWarning()
|
|
{
|
|
var playerId = User.GetUserId();
|
|
if (playerId == null)
|
|
return Unauthorized(WarningEnvelope(false, "Unauthorized"));
|
|
|
|
var pending = await db.PlayerWarnings
|
|
.Where(x => x.WarnedPlayerId == playerId.Value && x.AcknowledgedAt == null)
|
|
.ToListAsync();
|
|
|
|
if (pending.Count == 0)
|
|
{
|
|
return Ok(WarningEnvelope(true, string.Empty));
|
|
}
|
|
|
|
var acknowledgedAt = DateTime.UtcNow;
|
|
foreach (var warning in pending)
|
|
warning.AcknowledgedAt = acknowledgedAt;
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
await PlayerUtils.ReleaseModerationHoldAsync(playerId.Value, await db.IsDeveloper(playerId.Value));
|
|
return Ok(WarningEnvelope(true, string.Empty));
|
|
}
|
|
|
|
[HttpPost("v1/moderationBlockDetails"), Authorize]
|
|
public Task<IActionResult> GetBlockDetailsPost() => GetBlockDetails();
|
|
|
|
private async Task<object> EmptyOrWarningBlockDetailsAsync(long playerId)
|
|
{
|
|
var pending = await db.GetPendingWarningAsync(playerId);
|
|
|
|
return pending == null ? CreateEmptyBlockDetails() : PlayerUtils.BuildWarningBlockDetails(pending);
|
|
}
|
|
|
|
[HttpGet("v1/moderationBlockDetails"), Authorize]
|
|
public async Task<IActionResult> GetBlockDetails()
|
|
{
|
|
var userId = User.GetUserId();
|
|
|
|
|
|
|
|
|
|
var ban = await db.ResolveBanAsync(userId!.Value, Request.GetClientIp());
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (ban == null)
|
|
return Ok(await EmptyOrWarningBlockDetailsAsync(userId!.Value));
|
|
|
|
var remaining = ban.ExpiryUtc - DateTime.UtcNow;
|
|
|
|
if (remaining <= TimeSpan.Zero)
|
|
return Ok(await EmptyOrWarningBlockDetailsAsync(userId!.Value));
|
|
|
|
bool isPermanent = ban.IsPermanent;
|
|
int duration = isPermanent ? int.MaxValue : (int)remaining.TotalSeconds;
|
|
int? bannedUntil = null;
|
|
if (isPermanent)
|
|
bannedUntil = int.MaxValue;
|
|
else
|
|
{
|
|
var unix = new DateTimeOffset(DateTime.SpecifyKind(ban.ExpiryUtc, DateTimeKind.Utc), TimeSpan.Zero)
|
|
.ToUnixTimeSeconds();
|
|
if (unix <= int.MaxValue)
|
|
bannedUntil = (int)unix;
|
|
}
|
|
var message = ban.Reason ?? "";
|
|
|
|
if (ModerationPolicy.IsTimeout(ban.BanMethod))
|
|
message = GetTimeoutDisplayReason(ban.BanMethod, message, ban.Category);
|
|
|
|
|
|
var relatedUsername = await PlayerUtils.RelatedAccountUsernameAsync(db, ban.BannedAccountId, userId!.Value);
|
|
if (relatedUsername != null)
|
|
message = message + $"\nRelated account: @{relatedUsername}";
|
|
|
|
var appealCode = ban.AppealCode ?? string.Empty;
|
|
string appealText = appealCode.Length > 0 ? PlayerUtils.BuildBanAppealText(appealCode) : string.Empty;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
var appealPending = false;
|
|
if (ban.Kind == "Moderation")
|
|
{
|
|
appealPending = await db.Moderation.AsNoTracking()
|
|
.AnyAsync(x => x.PlayerId == userId!.Value && x.IsBan && x.AppealText != null && x.AppealResolution == null);
|
|
}
|
|
|
|
if (appealPending)
|
|
{
|
|
appealCode = string.Empty;
|
|
appealText = PlayerUtils.PendingAppealText;
|
|
}
|
|
else
|
|
{
|
|
var cooldownError = await PlayerUtils.GetAppealCooldownErrorAsync(db, userId!.Value);
|
|
if (cooldownError != null)
|
|
{
|
|
appealCode = string.Empty;
|
|
appealText = cooldownError;
|
|
}
|
|
else if (appealCode.Length == 0 && ban.Kind == "Moderation"
|
|
&& ModerationPolicy.IssuesAppealCode(ban.BanMethod))
|
|
{
|
|
appealCode = PlayerUtils.GenerateAppealCode();
|
|
var moderation = await db.Moderation.FirstOrDefaultAsync(x => x.PlayerId == userId.Value && x.IsBan);
|
|
if (moderation != null)
|
|
{
|
|
moderation.AppealCode = appealCode;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
appealText = PlayerUtils.BuildBanAppealText(appealCode);
|
|
}
|
|
}
|
|
|
|
|
|
if (!ModerationPolicy.IssuesAppealCode(ban.BanMethod))
|
|
{
|
|
appealCode = string.Empty;
|
|
appealText = string.Empty;
|
|
}
|
|
|
|
return Ok(PlayerUtils.ApplyClientBanKind(new BlockDetails
|
|
{
|
|
Duration = duration,
|
|
IsBan = true,
|
|
IsHostKick = false,
|
|
Message = message,
|
|
PlayerIdReporter = 1,
|
|
ReportCategory = ban.Category,
|
|
VoteKickReason = "",
|
|
BannedUntil = bannedUntil,
|
|
DisplayReason = message,
|
|
TimeoutStartedAt = ban.StartedAtUtc,
|
|
BanAppealCode = appealCode,
|
|
BanAppealText = appealText
|
|
}, ban.BanMethod));
|
|
}
|
|
|
|
public static bool ShouldApplyAutomaticTimeout(int reportCount, bool reporterIsModerator) =>
|
|
!reporterIsModerator && reportCount > AutomaticTimeoutReportThreshold;
|
|
|
|
public static string GetTimeoutDisplayReason(string? banMethod, string? storedReason, ReportCategory category)
|
|
{
|
|
var reason = (storedReason ?? string.Empty).Trim();
|
|
if (string.Equals(banMethod, "Moderator Report", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var moderatorReason = string.IsNullOrWhiteSpace(reason)
|
|
|| reason.Equals("You were reported by a moderator.", StringComparison.OrdinalIgnoreCase)
|
|
? "a moderator report"
|
|
: reason.TrimEnd('.');
|
|
return $"You have received a timeout. {moderatorReason}.";
|
|
}
|
|
|
|
const string prefix = "You have received a timeout due to a high number of reports for ";
|
|
if (reason.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
|
reason = reason[prefix.Length..].Trim().TrimEnd('.');
|
|
else if (string.IsNullOrWhiteSpace(reason) || reason.Length > 80
|
|
|| reason.Contains('.') || reason.Contains('!') || reason.Contains('?'))
|
|
reason = GetAutomaticTimeoutReason(category);
|
|
|
|
return $"You have received a timeout due to a high number of reports for {reason}.";
|
|
}
|
|
|
|
public static string GetAutomaticTimeoutReason(ReportCategory category) => category switch
|
|
{
|
|
ReportCategory.CoC_Underage => "Underage behavior",
|
|
ReportCategory.CoC_Sexual => "Sexual behavior",
|
|
ReportCategory.CoC_Discrimination => "Discrimination",
|
|
ReportCategory.CoC_Trolling => "Trolling",
|
|
ReportCategory.CoC_NameOrProfile => "Inappropriate name or profile",
|
|
ReportCategory.InappropriateClothing => "Inappropriate clothing",
|
|
ReportCategory.Unknown => "Reported by players",
|
|
_ => category.ToString()
|
|
};
|
|
|
|
private static object CreateEmptyBlockDetails() => new
|
|
{
|
|
ReportCategory = 0,
|
|
Duration = 0,
|
|
GameSessionId = 0,
|
|
Message = ""
|
|
};
|
|
|
|
[HttpPost("v1/deviceId"), AllowAnonymous]
|
|
public async Task<ActionResult> PostDeviceId([FromForm] DeviceId deviceId)
|
|
{
|
|
var userId = User.GetUserId();
|
|
if (!userId.HasValue)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(Request.Headers.Authorization.ToString())) return Ok();
|
|
return Unauthorized();
|
|
}
|
|
|
|
var player = await db.Players.FirstOrDefaultAsync(x => x.AccountId == userId.Value);
|
|
if (player == null) return NotFound();
|
|
|
|
player.DeviceId = deviceId.newDeviceId;
|
|
await db.SaveChangesAsync();
|
|
|
|
return Ok();
|
|
}
|
|
|
|
[HttpPost("v3/create"), Authorize]
|
|
public async Task<ActionResult<OkResponse>> CreateReport([FromForm] CreateReportRequest request)
|
|
{
|
|
if (request.PlayerIdReported <= 0 || !Enum.IsDefined(typeof(ReportCategory), request.ReportCategory))
|
|
return BadRequest(new OkResponse { Success = false, Message = "A valid player and report category are required." });
|
|
|
|
var reporterId = User.GetUserId();
|
|
|
|
|
|
|
|
|
|
|
|
var reputation = await db.Reputations.FirstOrDefaultAsync(x => x.AccountId == request.PlayerIdReported);
|
|
if (reputation == null)
|
|
{
|
|
reputation = new Reputation { AccountId = request.PlayerIdReported };
|
|
await db.Reputations.AddAsync(reputation);
|
|
}
|
|
|
|
var reporter = reporterId.HasValue && reporterId!.Value != request.PlayerIdReported
|
|
? await db.Profiles.FirstOrDefaultAsync(x => x.accountId == reporterId!.Value)
|
|
: null;
|
|
var isValidPlayerReport = reporter != null;
|
|
var reporterIsModerator = reporter?.HasRole("moderator") == true;
|
|
if (isValidPlayerReport && !reporterIsModerator)
|
|
reputation.ReportCount += 1;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (reporterIsModerator)
|
|
{
|
|
var timeoutStartedAt = DateTime.UtcNow;
|
|
var timeoutExpiry = ModerationPolicy.TimeoutExpiry(timeoutStartedAt);
|
|
var reportMsg = string.IsNullOrWhiteSpace(request.Details)
|
|
? "You were reported by a moderator."
|
|
: request.Details;
|
|
|
|
var existingModeration = await db.Moderation.FirstOrDefaultAsync(x => x.PlayerId == request.PlayerIdReported && x.IsBan);
|
|
if (existingModeration == null)
|
|
{
|
|
|
|
await db.Moderation.AddAsync(new ModerationDB
|
|
{
|
|
PlayerId = request.PlayerIdReported,
|
|
IsBan = true,
|
|
Message = reportMsg,
|
|
Category = request.ReportCategory,
|
|
ExpireDuration = timeoutExpiry,
|
|
CreatedAt = timeoutStartedAt,
|
|
BanMethod = "Moderator Report",
|
|
AppealCode = null
|
|
});
|
|
|
|
reputation.ApplyBanPenalty(permanent: false, cheating: false);
|
|
|
|
var blockDetails = PlayerUtils.BuildBlockDetails(true, request.ReportCategory, GetTimeoutDisplayReason("Moderator Report", reportMsg, request.ReportCategory), timeoutExpiry, reporterId: reporterId!.Value);
|
|
blockDetails.TimeoutStartedAt = timeoutStartedAt;
|
|
blockDetails.BanType = "Timeout";
|
|
await PlayerUtils.KickOrPromptAsync(db, request.PlayerIdReported, blockDetails);
|
|
|
|
await AdminActionDiscord.LogBanAsync(db, request.PlayerIdReported,
|
|
bannedBy: reporter?.username ?? $"Moderator {reporterId}",
|
|
causedByReports: true,
|
|
reason: reportMsg,
|
|
category: request.ReportCategory,
|
|
expiry: timeoutExpiry,
|
|
isModerator: true);
|
|
}
|
|
else if (existingModeration.ExpireDuration < timeoutExpiry)
|
|
{
|
|
existingModeration.IsBan = true;
|
|
existingModeration.Message = reportMsg;
|
|
existingModeration.Category = request.ReportCategory;
|
|
|
|
existingModeration.ExpireDuration = ModerationPolicy.ExtendedTimeoutExpiry(
|
|
existingModeration.CreatedAt, existingModeration.ExpireDuration, timeoutExpiry - timeoutStartedAt);
|
|
existingModeration.BanMethod = "Moderator Report";
|
|
existingModeration.AppealCode = null;
|
|
reputation.ApplyBanPenalty(permanent: false, cheating: false);
|
|
|
|
var blockDetails = PlayerUtils.BuildBlockDetails(true, request.ReportCategory, GetTimeoutDisplayReason("Moderator Report", reportMsg, request.ReportCategory), timeoutExpiry, reporterId: reporterId!.Value);
|
|
blockDetails.TimeoutStartedAt = timeoutStartedAt;
|
|
blockDetails.BanType = "Timeout";
|
|
await PlayerUtils.KickOrPromptAsync(db, request.PlayerIdReported, blockDetails);
|
|
|
|
await AdminActionDiscord.LogBanAsync(db, request.PlayerIdReported,
|
|
bannedBy: reporter?.username ?? $"Moderator {reporterId}",
|
|
causedByReports: true,
|
|
reason: reportMsg,
|
|
category: request.ReportCategory,
|
|
expiry: timeoutExpiry,
|
|
isModerator: true);
|
|
}
|
|
|
|
AdminPanelAPI.AddModerationReport(
|
|
reporterId!.Value,
|
|
request.PlayerIdReported,
|
|
reportMsg,
|
|
request.Details,
|
|
request.ReportCategory,
|
|
request.RoomId);
|
|
}
|
|
|
|
if (isValidPlayerReport && ShouldApplyAutomaticTimeout(reputation.ReportCount, reporterIsModerator))
|
|
{
|
|
var autoBanStartedAt = DateTime.UtcNow;
|
|
var autoBanExpiry = ModerationPolicy.TimeoutExpiry(autoBanStartedAt);
|
|
var autoBanReason = GetAutomaticTimeoutReason(request.ReportCategory);
|
|
var autoBanMessage = $"You have received a timeout due to a high number of reports for {autoBanReason}.";
|
|
|
|
var existingModeration = await db.Moderation.FirstOrDefaultAsync(x => x.PlayerId == request.PlayerIdReported && x.IsBan);
|
|
if (existingModeration == null)
|
|
{
|
|
|
|
|
|
|
|
|
|
await db.Moderation.AddAsync(new ModerationDB
|
|
{
|
|
PlayerId = request.PlayerIdReported,
|
|
IsBan = true,
|
|
Message = autoBanMessage,
|
|
Category = request.ReportCategory,
|
|
ExpireDuration = autoBanExpiry,
|
|
CreatedAt = autoBanStartedAt,
|
|
BanMethod = "Automatic Timeout",
|
|
AppealCode = null
|
|
});
|
|
|
|
reputation.ApplyBanPenalty(permanent: false, cheating: false);
|
|
var blockDetails = PlayerUtils.BuildBlockDetails(true, request.ReportCategory, autoBanMessage, autoBanExpiry);
|
|
blockDetails.TimeoutStartedAt = autoBanStartedAt;
|
|
|
|
blockDetails.BanType = "Timeout";
|
|
await PlayerUtils.KickOrPromptAsync(db, request.PlayerIdReported, blockDetails);
|
|
|
|
|
|
|
|
var autoBanReporter = reporter;
|
|
await AdminActionDiscord.LogBanAsync(db, request.PlayerIdReported,
|
|
bannedBy: $"Reports ({autoBanReporter?.username ?? reporterId.ToString()})",
|
|
causedByReports: true,
|
|
reason: autoBanMessage,
|
|
category: request.ReportCategory,
|
|
expiry: autoBanExpiry,
|
|
isModerator: false);
|
|
}
|
|
else if (existingModeration.ExpireDuration < autoBanExpiry)
|
|
{
|
|
|
|
existingModeration.IsBan = true;
|
|
existingModeration.Message = autoBanMessage;
|
|
existingModeration.Category = request.ReportCategory;
|
|
|
|
existingModeration.ExpireDuration = ModerationPolicy.ExtendedTimeoutExpiry(
|
|
existingModeration.CreatedAt, existingModeration.ExpireDuration, autoBanExpiry - autoBanStartedAt);
|
|
existingModeration.BanMethod = "Automatic Timeout";
|
|
existingModeration.AppealCode = null;
|
|
reputation.ApplyBanPenalty(permanent: false, cheating: false);
|
|
|
|
var blockDetails = PlayerUtils.BuildBlockDetails(true, request.ReportCategory, autoBanMessage, autoBanExpiry);
|
|
blockDetails.TimeoutStartedAt = autoBanStartedAt;
|
|
blockDetails.BanType = "Timeout";
|
|
await PlayerUtils.KickOrPromptAsync(db, request.PlayerIdReported, blockDetails);
|
|
|
|
|
|
var autoBanReporter2 = reporter;
|
|
await AdminActionDiscord.LogBanAsync(db, request.PlayerIdReported,
|
|
bannedBy: $"Reports ({autoBanReporter2?.username ?? reporterId.ToString()})",
|
|
causedByReports: true,
|
|
reason: autoBanMessage,
|
|
category: request.ReportCategory,
|
|
expiry: autoBanExpiry,
|
|
isModerator: false);
|
|
}
|
|
}
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
|
|
ReportDiscord.SendAsync(WebhookConfig.ReportsUrl, "🚩 Player Report",
|
|
new DiscordPayload
|
|
{
|
|
Title = "Player Report",
|
|
ThumbnailUrl = WebhookImages.PlayerReport,
|
|
Color = 0xF0B232,
|
|
Fields = new List<DiscordField>
|
|
{
|
|
new() { Name = "Reporter", Value = reporterId?.ToString() ?? "Unknown", Inline = true },
|
|
new() { Name = "Reported Player", Value = request.PlayerIdReported.ToString(), Inline = true },
|
|
new() { Name = "Category", Value = request.ReportCategory.ToString(), Inline = true },
|
|
new() { Name = "Room", Value = request.RoomId.ToString(), Inline = true },
|
|
new() { Name = "Details", Value = request.Details, Inline = false }
|
|
},
|
|
TimestampUtc = DateTime.UtcNow.ToString("O")
|
|
});
|
|
|
|
return Ok(new OkResponse { Success = true });
|
|
}
|
|
|
|
[HttpPost("v2/voteToKick"), Authorize]
|
|
public async Task<ActionResult<OkResponse>> VoteToKick()
|
|
{
|
|
var request = await ReadKickBodyAsync<VoteKickRequest>("voteToKick");
|
|
var voterId = User.GetUserId();
|
|
if (voterId == null) return Unauthorized();
|
|
|
|
if (request.PlayerId <= 0)
|
|
return BadRequest(new OkResponse { Success = false, Message = "Missing player id." });
|
|
|
|
var key = (request.GameSessionId, request.PlayerId);
|
|
|
|
if (request.PlayerId == voterId.Value)
|
|
{
|
|
await ResolveDeveloperDecisionAsync(request.PlayerId, request.Response);
|
|
return Ok(new OkResponse { Success = true });
|
|
}
|
|
|
|
var state = _voteKicks.GetOrAdd(key, _ => new VoteKickState());
|
|
|
|
bool thresholdReached;
|
|
lock (state.Voters)
|
|
{
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(request.Reason) && string.IsNullOrWhiteSpace(state.Reason))
|
|
state.Reason = request.Reason.Trim();
|
|
|
|
|
|
|
|
var before = state.Voters.Count;
|
|
if (request.Response)
|
|
state.Voters.Add(voterId.Value);
|
|
else
|
|
state.Voters.Remove(voterId.Value);
|
|
|
|
thresholdReached = before < VoteKickThreshold && state.Voters.Count >= VoteKickThreshold;
|
|
}
|
|
|
|
if (thresholdReached)
|
|
{
|
|
var reason = string.IsNullOrWhiteSpace(state.Reason) ? "Kicked by player vote" : state.Reason;
|
|
|
|
|
|
|
|
|
|
var category = ReportCategory.VoteKick;
|
|
if (request.ReportCategory.HasValue && Enum.IsDefined(typeof(KickReason), request.ReportCategory.Value))
|
|
category = (ReportCategory)(int)request.ReportCategory.Value;
|
|
else
|
|
{
|
|
var known = Defaults.GetVoteKickReasons()
|
|
.FirstOrDefault(r => string.Equals(r.Reason, state.Reason, StringComparison.OrdinalIgnoreCase));
|
|
if (known != null) category = (ReportCategory)(int)known.ReportCategory;
|
|
}
|
|
|
|
await ApplyKick(request.PlayerId, category, reason);
|
|
_voteKicks.TryRemove(key, out _);
|
|
}
|
|
|
|
return Ok(new OkResponse { Success = true });
|
|
}
|
|
|
|
[HttpPost("v1/kickFromEvent"), Authorize]
|
|
public async Task<ActionResult<OkResponse>> KickFromEvent()
|
|
{
|
|
var request = await ReadKickBodyAsync<KickFromEventRequest>("kickFromEvent");
|
|
var requesterId = User.GetUserId();
|
|
|
|
if (request.PlayerId <= 0)
|
|
return BadRequest(new OkResponse { Success = false, Message = "Missing player id." });
|
|
|
|
|
|
|
|
await ApplyKick(request.PlayerId, ReportCategory.Misc, string.IsNullOrWhiteSpace(request.Response) ? "Kicked from event" : request.Response);
|
|
|
|
return Ok(new OkResponse { Success = true });
|
|
}
|
|
|
|
[HttpPost("v1/instantKick"), Authorize]
|
|
public async Task<ActionResult<OkResponse>> InstantKick()
|
|
{
|
|
var request = await ReadKickBodyAsync<InstantKickRequest>("instantKick");
|
|
var requesterId = User.GetUserId();
|
|
|
|
|
|
|
|
|
|
if (request.PlayerId <= 0)
|
|
return BadRequest(new OkResponse { Success = false, Message = "Missing player id." });
|
|
|
|
var reason = string.IsNullOrWhiteSpace(request.Reason) ? "Kicked instantly" : request.Reason.Trim();
|
|
await ApplyKick(request.PlayerId, ReportCategory.VoteKick, reason, force: true);
|
|
|
|
return Ok(new OkResponse { Success = true });
|
|
}
|
|
|
|
private async Task ResolveDeveloperDecisionAsync(long playerId, bool accepted)
|
|
{
|
|
var decision = PlayerUtils.TakeDeveloperDecision(playerId);
|
|
if (decision == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!accepted)
|
|
{
|
|
AdminActionDiscord.LogAction($"Developer denied {decision.Kind}: {playerId}",
|
|
playerId.ToString(),
|
|
$"Developer {playerId} denied the parked {decision.Kind} ({decision.Reason}); nothing applied.");
|
|
return;
|
|
}
|
|
|
|
if (decision.Kind == PlayerUtils.DeveloperDecisionKind.Warning)
|
|
{
|
|
var warning = await IssueWarningAsync(db, playerId, decision.Category, decision.DisplayReason,
|
|
decision.ModeratorNote, decision.IssuedBy, devDecisionResolved: true);
|
|
|
|
AdminActionDiscord.LogAction($"Warn Player (accepted): {playerId}",
|
|
decision.IssuedBy.ToString(),
|
|
$"Developer {playerId} accepted the warning ({decision.Category})" +
|
|
(decision.DisplayReason == null ? string.Empty : $": {decision.DisplayReason}"));
|
|
|
|
return;
|
|
}
|
|
|
|
await ApplyKick(playerId, decision.Category, decision.Reason, devDecisionResolved: true);
|
|
}
|
|
|
|
private async Task ApplyKick(long playerId, ReportCategory category, string message, bool force = false, bool devDecisionResolved = false)
|
|
{
|
|
if (!devDecisionResolved &&
|
|
await PlayerUtils.DeferForDeveloperAsync(db, playerId, PlayerUtils.DeveloperDecisionKind.Kick,
|
|
PlayerUtils.BuildBlockDetails(true, category, message, DateTime.UtcNow.Add(KickDuration))))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var moderation = await db.Moderation.FirstOrDefaultAsync(x => x.PlayerId == playerId);
|
|
var expires = DateTime.UtcNow.Add(KickDuration);
|
|
|
|
if (moderation == null)
|
|
{
|
|
await db.Moderation.AddAsync(new ModerationDB
|
|
{
|
|
PlayerId = playerId,
|
|
IsBan = true,
|
|
Message = message,
|
|
Category = category,
|
|
ExpireDuration = expires
|
|
});
|
|
}
|
|
else if (moderation.ExpireDuration < expires)
|
|
{
|
|
|
|
moderation.IsBan = true;
|
|
moderation.Message = message;
|
|
moderation.Category = category;
|
|
moderation.ExpireDuration = expires;
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (category == ReportCategory.VoteKick)
|
|
{
|
|
await db.VoteKicks.AddAsync(new VoteKickLog
|
|
{
|
|
PlayerId = playerId,
|
|
Reason = message,
|
|
Category = category,
|
|
IsForce = force,
|
|
CreatedAt = DateTime.UtcNow,
|
|
ExpiresAt = expires
|
|
});
|
|
}
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
await PlayerUtils.KickOrPromptAsync(db, playerId, PlayerUtils.BuildBlockDetails(true, category, message, expires), devDecisionResolved: true);
|
|
}
|
|
|
|
[HttpGet("v1/voteToKickReasons")]
|
|
[EnableCors("AdminPanel")]
|
|
public ActionResult<List<VoteKickReason>> GetVoteKickReasons()
|
|
{
|
|
return Ok(Defaults.GetVoteKickReasons());
|
|
}
|
|
}
|
|
}
|