Files
Radicade/Server/BanAppeal/AppealAPI.cs
T
2026-10-16 23:33:13 -07:00

343 lines
12 KiB
C#

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<ActionResult<OkResponse>> 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<DiscordField>
{
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<ActionResult<object>> 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<ActionResult<object>> 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<ActionResult<object>> 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<ActionResult<object>> 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));
}
}
}