Files
2026-10-16 23:33:13 -07:00

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());
}
}
}