mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 23:18:56 -07:00
Radicade Public Code
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
using RadicadeServer.RecNet.Enums.Moderation;
|
||||
|
||||
namespace RadicadeServer.RecNet.Moderation
|
||||
{
|
||||
public class BlockDetails
|
||||
{
|
||||
public int Duration { get; set; }
|
||||
|
||||
public long GameSessionId { get; set; }
|
||||
public long PlayerIdReporter { get; set; }
|
||||
|
||||
public bool IsBan { get; set; }
|
||||
public bool IsHostKick { get; set; }
|
||||
|
||||
public bool IsWarning { get; set; }
|
||||
|
||||
public DateTime? TimeoutStartedAt { get; set; }
|
||||
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
public ReportCategory ReportCategory { get; set; }
|
||||
|
||||
public string VoteKickReason { get; set; } = string.Empty;
|
||||
|
||||
public int? BannedUntil { get; set; }
|
||||
|
||||
|
||||
public string DisplayReason { get; set; } = string.Empty;
|
||||
|
||||
|
||||
public string BanType { get; set; } = string.Empty;
|
||||
|
||||
|
||||
public bool IsVoiceModAutoban { get; set; }
|
||||
|
||||
public string? ModeratorNote { get; set; }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public string BanAppealCode { get; set; } = string.Empty;
|
||||
|
||||
public string BanAppealText { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.Admin;
|
||||
using RadicadeServer.Radicade.Discord;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Enums.Moderation;
|
||||
using RadicadeServer.RecNet.Moderation;
|
||||
using RadicadeServer.Discord.Webhooks;
|
||||
|
||||
namespace RadicadeServer.API.Moderation;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public class ContentReportAPI : Controller
|
||||
{
|
||||
private readonly RecDBContext db;
|
||||
|
||||
public ContentReportAPI(RecDBContext dbContext) {
|
||||
db = dbContext;
|
||||
}
|
||||
|
||||
[HttpPost("api/inventions/v1/report"), Authorize]
|
||||
[HttpPost("api/inventions/v2/report"), Authorize]
|
||||
public Task<IActionResult> ReportInvention() => HandleReport("Invention",
|
||||
new[] { "InventionId", "inventionId", "Id", "id", "TargetId", "targetId", "ReporteeId", "reporteeId" },
|
||||
async targetId =>
|
||||
{
|
||||
var invention = await db.Inventions.AsNoTracking().FirstOrDefaultAsync(x => x.InventionId == targetId);
|
||||
return invention == null ? null : (OwnerId: invention.CreatorPlayerId, TargetName: invention.Name);
|
||||
});
|
||||
|
||||
[HttpPost("api/rooms/v1/report"), Authorize]
|
||||
[HttpPost("api/rooms/v2/report"), Authorize]
|
||||
public Task<IActionResult> ReportRoom() => HandleReport("Room",
|
||||
new[] { "RoomId", "roomId", "Id", "id", "TargetId", "targetId", "ReporteeId", "reporteeId" },
|
||||
async targetId =>
|
||||
{
|
||||
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(x => x.RoomId == targetId);
|
||||
return room == null ? null : (OwnerId: room.CreatorAccountId, TargetName: room.Name);
|
||||
});
|
||||
|
||||
[HttpPost("api/clubs/v1/report"), Authorize]
|
||||
[HttpPost("api/clubs/v2/report"), Authorize]
|
||||
[HttpPost("api/clubreporting/v1/report"), Authorize]
|
||||
public Task<IActionResult> ReportClub() => HandleReport("Club",
|
||||
new[] { "ClubId", "clubId", "Id", "id", "TargetId", "targetId", "ReporteeId", "reporteeId" },
|
||||
_ => Task.FromResult<(long OwnerId, string TargetName)?>(null));
|
||||
|
||||
[HttpPost("api/board/v1/report"), Authorize]
|
||||
[HttpPost("api/board/v2/report"), Authorize]
|
||||
[HttpPost("api/communityboard/v1/report"), Authorize]
|
||||
[HttpPost("api/communityboard/v2/report"), Authorize]
|
||||
public Task<IActionResult> ReportBoard() => HandleReport("Board",
|
||||
new[] { "BoardId", "boardId", "PostId", "postId", "CommentId", "commentId", "Id", "id", "TargetId", "targetId" },
|
||||
_ => Task.FromResult<(long OwnerId, string TargetName)?>(null));
|
||||
|
||||
[HttpPost("api/playerevents/v2/report"), Authorize]
|
||||
[HttpPost("api/events/v1/report"), Authorize]
|
||||
[HttpPost("api/events/v2/report"), Authorize]
|
||||
public Task<IActionResult> ReportEvent() => HandleReport("Event",
|
||||
new[] { "EventId", "eventId", "PlayerEventId", "playerEventId", "Id", "id", "TargetId", "targetId", "ReporteeId", "reporteeId" },
|
||||
async targetId =>
|
||||
{
|
||||
var ev = await db.PlayerEvents.AsNoTracking().FirstOrDefaultAsync(x => x.PlayerEventId == targetId);
|
||||
return ev == null ? null : (OwnerId: ev.CreatorPlayerId, TargetName: ev.Name);
|
||||
});
|
||||
|
||||
private async Task<IActionResult> HandleReport(
|
||||
string reportType,
|
||||
string[] idFieldNames,
|
||||
Func<long, Task<(long OwnerId, string TargetName)?>> resolveTarget)
|
||||
{
|
||||
var reporterId = User.GetUserId();
|
||||
if (reporterId == null) return Unauthorized();
|
||||
|
||||
var fields = await ReadFieldsAsync();
|
||||
|
||||
var targetId = GetLong(fields, idFieldNames);
|
||||
|
||||
long reportedId = 0;
|
||||
string targetName = "";
|
||||
if (targetId.HasValue)
|
||||
{
|
||||
var resolved = await resolveTarget(targetId.Value);
|
||||
if (resolved.HasValue)
|
||||
{
|
||||
reportedId = resolved.Value.OwnerId;
|
||||
targetName = resolved.Value.TargetName;
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
|
||||
|
||||
targetName = $"{reportType} {targetId.Value}";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return await SubmitReport(reportType, targetId, targetName, reportedId, reporterId.Value, fields);
|
||||
}
|
||||
|
||||
[HttpPost("api/customAvatarItems/v1/{customAvatarItemId:guid}/report"), Authorize]
|
||||
public async Task<IActionResult> ReportCustomAvatarItem(Guid customAvatarItemId)
|
||||
{
|
||||
var reporterId = User.GetUserId();
|
||||
if (reporterId == null) return Unauthorized();
|
||||
|
||||
var fields = await ReadFieldsAsync();
|
||||
|
||||
var item = await db.CustomAvatarItems.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.CustomAvatarItemId == customAvatarItemId);
|
||||
|
||||
return await SubmitReport(
|
||||
"CustomAvatarItem",
|
||||
null,
|
||||
item?.Name ?? $"Custom Avatar Item {customAvatarItemId}",
|
||||
item?.CreatorAccountId ?? 0,
|
||||
reporterId.Value,
|
||||
fields);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> SubmitReport(
|
||||
string reportType,
|
||||
long? targetId,
|
||||
string targetName,
|
||||
long reportedId,
|
||||
long reporterId,
|
||||
Dictionary<string, string> fields)
|
||||
{
|
||||
var reason = GetField(fields, "Reason", "reason", "ReportReason", "reportReason") ?? "";
|
||||
var details = GetField(fields, "Details", "details", "Description", "description", "Message", "message", "Text", "text") ?? "";
|
||||
var category = ParseCategory(fields);
|
||||
var roomId = GetLong(fields, "RoomId", "roomId") ?? 0;
|
||||
|
||||
if (reportedId > 0 && reportedId != reporterId)
|
||||
{
|
||||
var reputation = await db.Reputations.FirstOrDefaultAsync(x => x.AccountId == reportedId);
|
||||
if (reputation == null)
|
||||
{
|
||||
reputation = new Reputation { AccountId = reportedId };
|
||||
await db.Reputations.AddAsync(reputation);
|
||||
}
|
||||
reputation.ReportCount += 1;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
AdminPanelAPI.AddModerationReport(reporterId, reportedId, reason, details, category, roomId,
|
||||
reportType, targetId, targetName);
|
||||
|
||||
ReportDiscord.SendAsync(WebhookConfig.ReportsUrl, $"🚩 {reportType} Report",
|
||||
new DiscordPayload
|
||||
{
|
||||
Title = $"{reportType} Report",
|
||||
ThumbnailUrl = WebhookImages.ForReport(reportType) ?? "",
|
||||
Color = 0xF0B232,
|
||||
Fields = new List<DiscordField>
|
||||
{
|
||||
new() { Name = "Report Type", Value = reportType, Inline = true },
|
||||
new() { Name = "Reporter", Value = reporterId.ToString(), Inline = true },
|
||||
new() { Name = "Reported Player", Value = reportedId > 0 ? reportedId.ToString() : "Unknown", Inline = true },
|
||||
new() { Name = "Target", Value = targetId.HasValue ? $"{targetName} ({targetId})" : targetName, Inline = true },
|
||||
new() { Name = "Category", Value = category.ToString(), Inline = true },
|
||||
new() { Name = "Room", Value = roomId.ToString(), Inline = true },
|
||||
new() { Name = "Reason", Value = reason, Inline = false },
|
||||
new() { Name = "Details", Value = details, Inline = false }
|
||||
},
|
||||
TimestampUtc = DateTime.UtcNow.ToString("O")
|
||||
});
|
||||
|
||||
return Ok(new OkResponse { Success = true });
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private async Task<Dictionary<string, string>> ReadFieldsAsync()
|
||||
{
|
||||
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var kv in Request.Query)
|
||||
fields[kv.Key] = kv.Value.ToString();
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var kv in form)
|
||||
fields[kv.Key] = kv.Value.ToString();
|
||||
return fields;
|
||||
}
|
||||
|
||||
if (Request.ContentLength is not > 0) return fields;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
if (string.IsNullOrWhiteSpace(body)) return fields;
|
||||
|
||||
var trimmed = body.TrimStart();
|
||||
if (trimmed.StartsWith("{", StringComparison.Ordinal))
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
fields[prop.Name] = prop.Value.ValueKind == JsonValueKind.String
|
||||
? prop.Value.GetString() ?? ""
|
||||
: prop.Value.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (trimmed.Contains('='))
|
||||
{
|
||||
|
||||
foreach (var pair in body.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var eq = pair.IndexOf('=');
|
||||
if (eq < 0)
|
||||
{
|
||||
fields[Uri.UnescapeDataString(pair)] = "";
|
||||
continue;
|
||||
}
|
||||
var key = Uri.UnescapeDataString(pair.Substring(0, eq));
|
||||
var value = Uri.UnescapeDataString(pair.Substring(eq + 1));
|
||||
fields[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static string? GetField(Dictionary<string, string> fields, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static long? GetLong(Dictionary<string, string> fields, params string[] names)
|
||||
{
|
||||
var raw = GetField(fields, names);
|
||||
if (raw == null) return null;
|
||||
if (long.TryParse(raw, out var value)) return value;
|
||||
|
||||
|
||||
var digits = new string(raw.Where(char.IsDigit).ToArray());
|
||||
return long.TryParse(digits, out value) ? value : null;
|
||||
}
|
||||
|
||||
private static ReportCategory ParseCategory(Dictionary<string, string> fields)
|
||||
{
|
||||
var raw = GetField(fields, "ReportCategory", "reportCategory", "Category", "category", "Reason", "reason", "ReportReason", "reportReason");
|
||||
if (raw == null) return ReportCategory.Misc;
|
||||
|
||||
if (int.TryParse(raw, out var intValue) && Enum.IsDefined(typeof(ReportCategory), intValue))
|
||||
return (ReportCategory)intValue;
|
||||
|
||||
if (Enum.TryParse<ReportCategory>(raw, ignoreCase: true, out var parsed))
|
||||
return parsed;
|
||||
|
||||
return ReportCategory.Misc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace RadicadeServer.RecNet.Moderation
|
||||
{
|
||||
public enum HileType
|
||||
{
|
||||
Obcured = 0,
|
||||
Time = 1,
|
||||
Inject = 2,
|
||||
GiftCount = 3,
|
||||
Engine = 4
|
||||
}
|
||||
|
||||
public class Hile
|
||||
{
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public HileType Type { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using RadicadeServer.RecNet.Moderation;
|
||||
|
||||
namespace RadicadeServer.API.Moderation
|
||||
{
|
||||
[Route("api/PlayerReporting/v1/")]
|
||||
public class HileAPI : Controller
|
||||
{
|
||||
|
||||
public HileAPI() {
|
||||
}
|
||||
|
||||
[HttpPost("hile")]
|
||||
public IActionResult PostHile([FromForm] Hile hile)
|
||||
{
|
||||
|
||||
var body = Environment.GetEnvironmentVariable("RADICADE_HILE_BODY") ?? "ok-true";
|
||||
switch (body)
|
||||
{
|
||||
case "ok-false":
|
||||
return Ok(false);
|
||||
case "nocontent":
|
||||
return NoContent();
|
||||
case "servererror":
|
||||
return StatusCode(500);
|
||||
default:
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace RadicadeServer.RecNet.Moderation
|
||||
{
|
||||
public class IsPureResponse
|
||||
{
|
||||
public bool IsPure { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,1010 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace RadicadeServer.RecNet.Moderation
|
||||
{
|
||||
public class IsPureRequest
|
||||
{
|
||||
public string Value { get; set; } = null!;
|
||||
}
|
||||
public class SanitizeStringRequest
|
||||
{
|
||||
public int ReplacementChar { get; set; }
|
||||
public string Value { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user