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 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(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 ReadKickBodyAsync(string endpoint) where T : class, new() { var query = Request?.Query; var route = RouteData?.Values; if (Request!.HasFormContentType) { var form = await Request.ReadFormAsync(); return BindKickBody(form, null, query, route); } using var reader = new StreamReader(Request.Body); var json = await reader.ReadToEndAsync(); if (string.IsNullOrWhiteSpace(json)) return BindKickBody(null, null, query, route); try { return BindKickBody(null, JsonNode.Parse(json), query, route); } catch { return BindKickBody(null, null, query, route); } } private static readonly string[] WarningStaffRoles = { "moderator", "developer" }; private static Dictionary WarningEnvelope(bool success, string error) => new() { ["Success"] = success, ["success"] = success, ["Error"] = error, ["error"] = error, ["Message"] = error, }; [HttpPost("/api/playerwarnings"), Authorize] public async Task CreateWarning() { var request = await ReadKickBodyAsync("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 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 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 GetBlockDetailsPost() => GetBlockDetails(); private async Task EmptyOrWarningBlockDetailsAsync(long playerId) { var pending = await db.GetPendingWarningAsync(playerId); return pending == null ? CreateEmptyBlockDetails() : PlayerUtils.BuildWarningBlockDetails(pending); } [HttpGet("v1/moderationBlockDetails"), Authorize] public async Task 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 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> 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 { 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> VoteToKick() { var request = await ReadKickBodyAsync("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> KickFromEvent() { var request = await ReadKickBodyAsync("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> InstantKick() { var request = await ReadKickBodyAsync("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> GetVoteKickReasons() { return Ok(Defaults.GetVoteKickReasons()); } } }