using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.Radicade.Discord; using RadicadeServer.API.Notifications; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.RecNet.Enums.Messages; using RadicadeServer.RecNet.Moderation; using RadicadeServer.RecNet.Notifications; using RadicadeServer.Utils; using RadicadeServer.Discord.Webhooks; namespace RadicadeServer.API.Moderation { public class SubmitAppealRequest { public string Code { get; set; } = string.Empty; public string Text { get; set; } = string.Empty; } [Route("api/Appeal/")] public class AppealAPI(RecDBContext db) : Controller { [HttpPost("submit")] public async Task> Submit([FromBody] SubmitAppealRequest request) { var code = request?.Code?.Trim().ToUpperInvariant() ?? string.Empty; if (code.Length == 0) return BadRequest(new OkResponse { Success = false, Message = "Missing appeal code." }); var text = request?.Text?.Trim() ?? string.Empty; if (text.Length < 10) return BadRequest(new OkResponse { Success = false, Message = "Please describe your appeal in at least 10 characters." }); var ban = await db.Moderation.FirstOrDefaultAsync(x => x.AppealCode == code); if (ban == null) return NotFound(new OkResponse { Success = false, Message = "Unknown appeal code." }); if (!ban.IsBan || ban.ExpireDuration < DateTime.UtcNow) return BadRequest(new OkResponse { Success = false, Message = "Player is not banned!" }); if (AccountLock.IsLockBan(ban)) return BadRequest(new OkResponse { Success = false, Message = AccountLock.NotAppealableMessage }); if (!ModerationPolicy.IssuesAppealCode(ban.BanMethod) || ModerationPolicy.RefusesAppeal(ban.BanMethod)) return BadRequest(new OkResponse { Success = false, Message = AccountLock.NotAppealableMessage }); if (ban.AppealText != null && ban.AppealResolution == null) return BadRequest(new OkResponse { Success = false, Message = "Your appeal is already pending review." }); if (ban.AppealResolution == "Accepted") return BadRequest(new OkResponse { Success = false, Message = "Player is not banned!" }); var cooldownError = await PlayerUtils.GetAppealCooldownErrorAsync(db, ban.PlayerId); if (cooldownError != null) { return BadRequest(new OkResponse { Success = false, Message = cooldownError }); } ban.AppealText = text; ban.AppealSubmittedAt = DateTime.UtcNow; ban.AppealResolution = null; ban.AppealResolvedAt = null; await db.SaveChangesAsync(); var profile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == ban.PlayerId); if (profile != null) { await Notify.SendNotify(ban.PlayerId, new Notification { Id = NotificationType.RefreshLogin, Msg = profile }); } ReportDiscord.SendAsync(WebhookConfig.BanAppealsUrl, "🎟️ Ban Appeal", new DiscordPayload { Title = "New Ban Appeal", ThumbnailUrl = WebhookImages.BanAppeal, Color = 0x5865F2, Fields = new List { new() { Name = "Player", Value = profile?.username ?? ban.PlayerId.ToString(), Inline = true }, new() { Name = "Player ID", Value = ban.PlayerId.ToString(), Inline = true }, new() { Name = "Appeal Code", Value = code, Inline = true }, new() { Name = "Ban Reason", Value = ban.Message ?? "—", Inline = false }, new() { Name = "Appeal", Value = text, Inline = false } }, TimestampUtc = DateTime.UtcNow.ToString("O") }); return Ok(new OkResponse { Success = true, Message = "Appeal submitted. A moderator will review it shortly." }); } [HttpGet("status")] public async Task> Status([FromQuery] string code) { var normalized = code?.Trim().ToUpperInvariant() ?? string.Empty; if (normalized.Length == 0) return Ok(new { found = false }); var ban = await db.Moderation.AsNoTracking().FirstOrDefaultAsync(x => x.AppealCode == normalized); if (ban == null) return Ok(new { found = false }); var profile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == ban.PlayerId); bool banned = ban.IsBan && ban.ExpireDuration > DateTime.UtcNow; string? cooldownText = null; DateTime? cooldownUntilUtc = null; if (banned && (ban.AppealResolution == "Rejected" || ban.AppealResolution == "Reduced" || ban.AppealResolution == "Extended" || ban.AppealResolution == "Reviewed") && ban.AppealResolvedAt.HasValue) { var cooldownEnds = ban.AppealResolvedAt.Value + PlayerUtils.AppealCooldownDuration; if (cooldownEnds > DateTime.UtcNow) { cooldownUntilUtc = cooldownEnds; cooldownText = $"{PlayerUtils.AppealCooldownPrefix}{PlayerUtils.FormatAppealCooldown(cooldownEnds - DateTime.UtcNow)}"; } } return Ok(new { found = true, player = profile?.username ?? "unknown", reason = ban.Message ?? string.Empty, category = ban.Category.ToString(), permanent = ban.ExpireDuration >= DateTime.MaxValue.AddYears(-10), expiresAtUtc = ban.ExpireDuration, banned, hasAppeal = ban.AppealText != null, submittedAt = ban.AppealSubmittedAt, resolution = ban.AppealResolution, resolvedAtUtc = ban.AppealResolvedAt, cooldownUntilUtc, cooldownText }); } } [Route("api/banappeal/")] public class BanAppealAPI(RecDBContext db) : Controller { private static object AppealResponse(bool success, string? error, object? value) => new { success, error, value }; [HttpGet("generateCode"), Authorize] [HttpPost("generateCode"), Authorize] public async Task> GenerateCode() { var userId = User.GetUserId(); if (userId == null) return Unauthorized(new { success = false, error = "Not signed in.", value = (object?)null }); var ban = await db.Moderation.FirstOrDefaultAsync(x => x.PlayerId == userId.Value && x.IsBan && x.ExpireDuration > DateTime.UtcNow); if (ban == null) return Ok(AppealResponse(false, "Account is not banned.", null)); if (AccountLock.IsLockBan(ban)) return Ok(AppealResponse(false, AccountLock.NotAppealableMessage, null)); if (!ModerationPolicy.IssuesAppealCode(ban.BanMethod) || ModerationPolicy.RefusesAppeal(ban.BanMethod)) return Ok(AppealResponse(false, AccountLock.NotAppealableMessage, null)); var cooldownError = await PlayerUtils.GetAppealCooldownErrorAsync(db, userId.Value); if (cooldownError != null) return Ok(AppealResponse(false, cooldownError, null)); if (ban.AppealText != null && ban.AppealResolution == null) return Ok(AppealResponse(false, "Your appeal is already pending review.", null)); ban.AppealCode = PlayerUtils.GenerateAppealCode(); await db.SaveChangesAsync(); var url = $"{PlayerUtils.AppealUrl()}?code={ban.AppealCode}"; return Ok(AppealResponse(true, null, url)); } private async Task<(ModerationDB? Ban, ActionResult? Stop)> LookUpAppeal(string? code, string? key) { if (!key.IsSupportKey()) return (null, Unauthorized(new { success = false, error = "Invalid secret.", value = (object?)null })); var normalized = code?.Trim().ToUpperInvariant() ?? string.Empty; if (normalized.Length == 0) return (null, Ok(AppealResponse(false, "Missing appeal code.", null))); var ban = await db.Moderation.FirstOrDefaultAsync(x => x.AppealCode == normalized); if (ban == null) return (null, Ok(AppealResponse(false, "Unknown appeal code.", null))); if (!ModerationPolicy.IssuesAppealCode(ban.BanMethod) || ModerationPolicy.RefusesAppeal(ban.BanMethod)) return (null, Ok(AppealResponse(false, AccountLock.NotAppealableMessage, null))); return (ban, null); } private string SupportKeyFrom(string? queryKey) { return !string.IsNullOrWhiteSpace(queryKey) ? queryKey : Request.Headers["X-Support-Key"].ToString().Trim(); } [HttpPost("pending")] public async Task> Pending([FromQuery] string code, [FromQuery] string key) { var (ban, stop) = await LookUpAppeal(code, SupportKeyFrom(key)); if (stop != null) return stop; if (ban == null) return Ok(AppealResponse(false, "Unknown appeal code.", null)); if (ban.AppealResolution != null) return Ok(AppealResponse(false, "This appeal has already been reviewed.", ban.AppealResolution)); if (ban.AppealText == null) { ban.AppealText = PlayerUtils.SupportSiteAppealText; ban.AppealSubmittedAt = DateTime.UtcNow; await db.SaveChangesAsync(); } return Ok(AppealResponse(true, null, "pending")); } [HttpPost("resolve")] public async Task> Resolve([FromQuery] string code, [FromQuery] string key, [FromQuery] string? decision = null) { var (ban, stop) = await LookUpAppeal(code, SupportKeyFrom(key)); if (stop != null) return stop; if (ban == null) return Ok(AppealResponse(false, "Unknown appeal code.", null)); if (ban.AppealResolution != null) return Ok(AppealResponse(true, null, ban.AppealResolution)); var verdict = (decision ?? string.Empty).Trim().ToLowerInvariant(); if (verdict == "accept") { ban.IsBan = false; ban.AppealResolution = "Accepted"; } else if (verdict == "deny") { ban.AppealResolution = "Rejected"; } else if (verdict == "reviewed" || verdict == "review") { ban.AppealResolution = "Reviewed"; } else { return Ok(AppealResponse(false, "Unknown decision.", null)); } ban.AppealResolvedAt = DateTime.UtcNow; await db.SaveChangesAsync(); if (!ban.IsBan) { var profile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == ban.PlayerId); if (profile != null) await Notify.SendNotify(ban.PlayerId, new Notification { Id = NotificationType.RefreshLogin, Msg = profile }); } return Ok(AppealResponse(true, null, ban.AppealResolution)); } } }