:3
This commit is contained in:
@@ -0,0 +1,616 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using Mocha2023.Classes.DBs;
|
||||
|
||||
namespace Mocha2023.Classes
|
||||
{
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true, AllowMultiple = false)]
|
||||
public sealed class ApiProtectionAttribute : Attribute, IAsyncResourceFilter, IOrderedFilter
|
||||
{
|
||||
private sealed class TokenBucket
|
||||
{
|
||||
public readonly object Sync = new();
|
||||
public double Tokens;
|
||||
public DateTime LastRefillUtc;
|
||||
public DateTime LastSeenUtc;
|
||||
|
||||
public TokenBucket(double capacity, DateTime now)
|
||||
{
|
||||
Tokens = capacity;
|
||||
LastRefillUtc = now;
|
||||
LastSeenUtc = now;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct Policy(
|
||||
string Name,
|
||||
int BurstCapacity,
|
||||
double BurstRefillPerSecond,
|
||||
int SustainedCapacity,
|
||||
double SustainedRefillPerSecond,
|
||||
int MaxConcurrent,
|
||||
long MaxBodyBytes);
|
||||
|
||||
private static readonly ConcurrentDictionary<string, TokenBucket> Buckets = new();
|
||||
private static readonly ConcurrentDictionary<string, int> ActiveRequests = new();
|
||||
private static readonly ConcurrentDictionary<string, int> GlobalActiveRequests = new();
|
||||
private const string EacChallengeResponse = "AQAAAGd9O3h2ynQW6Y/1MhdZC8VoHygxyTzmiRvAfpiRtJEBQ+NVaXMStRTsYQk42H1hbB7NGKhIpgfShk+ADtRW9EU/YF5320eGmINJZAqkm3pyX9QF/w1QT4IB2EQfOqTfpryQ3QFchXYqDgg/SbX+/X9mgzssbflIw3OAK+c=";
|
||||
private static long _requestCounter;
|
||||
|
||||
public int Order => int.MinValue + 100;
|
||||
|
||||
public async Task OnResourceExecutionAsync(
|
||||
ResourceExecutingContext context,
|
||||
ResourceExecutionDelegate next)
|
||||
{
|
||||
HttpRequest request = context.HttpContext.Request;
|
||||
ApplySecurityHeaders(context.HttpContext.Response);
|
||||
Policy policy = ResolvePolicy(request);
|
||||
string requestPath = request.Path.Value?.ToLowerInvariant() ?? string.Empty;
|
||||
IPAddress? clientAddress = ClientNetwork.GetClientIp(request);
|
||||
|
||||
if (clientAddress != null &&
|
||||
!ShouldSkipIpBan(requestPath) &&
|
||||
RecNetDB.TryGetIpBan(clientAddress, out RecNetDB.IpBan? ipBan))
|
||||
{
|
||||
context.HttpContext.Response.Headers["X-Mocha-Blocked"] = "ip-ban";
|
||||
context.Result = Error(
|
||||
StatusCodes.Status403Forbidden,
|
||||
"ip_banned",
|
||||
string.IsNullOrWhiteSpace(ipBan?.Reason)
|
||||
? "This network is blocked from Mocha."
|
||||
: ipBan.Reason);
|
||||
return;
|
||||
}
|
||||
|
||||
if (clientAddress != null &&
|
||||
RecNetDB.IsVpnBlockingEnabled() &&
|
||||
ShouldCheckVpn(requestPath) &&
|
||||
await VpnDetectionService.IsVpnOrProxyAsync(
|
||||
clientAddress,
|
||||
context.HttpContext.RequestAborted))
|
||||
{
|
||||
context.HttpContext.Response.Headers["X-Mocha-Blocked"] = "anonymous-network";
|
||||
context.Result = Error(
|
||||
StatusCodes.Status403Forbidden,
|
||||
"vpn_or_proxy_blocked",
|
||||
"VPN, proxy, Tor, and hosting-provider connections are not allowed on Mocha.");
|
||||
return;
|
||||
}
|
||||
|
||||
if ((request.QueryString.Value?.Length ?? 0) > 8_192)
|
||||
{
|
||||
context.Result = Error(
|
||||
StatusCodes.Status414UriTooLong,
|
||||
"query_too_large",
|
||||
"The query string is too large.");
|
||||
return;
|
||||
}
|
||||
|
||||
var maxBodyFeature = context.HttpContext.Features.Get<
|
||||
Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();
|
||||
if (maxBodyFeature != null &&
|
||||
!maxBodyFeature.IsReadOnly &&
|
||||
(!maxBodyFeature.MaxRequestBodySize.HasValue ||
|
||||
maxBodyFeature.MaxRequestBodySize.Value > policy.MaxBodyBytes))
|
||||
{
|
||||
|
||||
maxBodyFeature.MaxRequestBodySize = policy.MaxBodyBytes;
|
||||
}
|
||||
|
||||
if (request.ContentLength.HasValue &&
|
||||
request.ContentLength.Value > policy.MaxBodyBytes)
|
||||
{
|
||||
context.Result = Error(
|
||||
StatusCodes.Status413PayloadTooLarge,
|
||||
"payload_too_large",
|
||||
"The request body is too large for this endpoint.");
|
||||
return;
|
||||
}
|
||||
|
||||
string clientKey = GetClientKey(request);
|
||||
|
||||
string routeKey = $"{clientKey}:{GetRateLimitRouteKey(request, policy.Name)}";
|
||||
|
||||
bool burstAllowed = TryConsume(
|
||||
routeKey + ":burst",
|
||||
policy.BurstCapacity,
|
||||
policy.BurstRefillPerSecond,
|
||||
out int burstRetryAfter);
|
||||
bool sustainedAllowed = TryConsume(
|
||||
routeKey + ":sustained",
|
||||
policy.SustainedCapacity,
|
||||
policy.SustainedRefillPerSecond,
|
||||
out int sustainedRetryAfter);
|
||||
|
||||
if (!burstAllowed || !sustainedAllowed)
|
||||
{
|
||||
int retryAfter = Math.Max(1, Math.Max(burstRetryAfter, sustainedRetryAfter));
|
||||
context.HttpContext.Response.Headers["Retry-After"] = retryAfter.ToString();
|
||||
context.Result = Error(
|
||||
StatusCodes.Status429TooManyRequests,
|
||||
"rate_limited",
|
||||
"Too many requests. Slow down and try again.",
|
||||
new { retryAfterSeconds = retryAfter });
|
||||
return;
|
||||
}
|
||||
|
||||
int active = ActiveRequests.AddOrUpdate(routeKey, 1, (_, current) => current + 1);
|
||||
if (active > policy.MaxConcurrent)
|
||||
{
|
||||
DecrementActive(routeKey, ActiveRequests);
|
||||
context.HttpContext.Response.Headers["Retry-After"] = "1";
|
||||
context.Result = Error(
|
||||
StatusCodes.Status429TooManyRequests,
|
||||
"too_many_concurrent_requests",
|
||||
"Too many requests are already running for this client.",
|
||||
new { retryAfterSeconds = 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
int globalLimit = GetGlobalConcurrencyLimit(policy.Name);
|
||||
int globalActive = GlobalActiveRequests.AddOrUpdate(
|
||||
policy.Name,
|
||||
1,
|
||||
(_, current) => current + 1);
|
||||
if (globalActive > globalLimit)
|
||||
{
|
||||
DecrementActive(policy.Name, GlobalActiveRequests);
|
||||
DecrementActive(routeKey, ActiveRequests);
|
||||
context.HttpContext.Response.Headers["Retry-After"] = "1";
|
||||
context.Result = Error(
|
||||
StatusCodes.Status503ServiceUnavailable,
|
||||
"server_busy",
|
||||
"The server is busy. Try again in a moment.",
|
||||
new { retryAfterSeconds = 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (requestPath.Equals("/auth/eac/challenge", StringComparison.Ordinal))
|
||||
{
|
||||
context.HttpContext.Response.Headers["Cache-Control"] = "no-store";
|
||||
context.HttpContext.Response.Headers["Pragma"] = "no-cache";
|
||||
context.Result = new JsonResult(EacChallengeResponse)
|
||||
{
|
||||
ContentType = "application/json; charset=utf-8",
|
||||
StatusCode = StatusCodes.Status200OK
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
finally
|
||||
{
|
||||
DecrementActive(policy.Name, GlobalActiveRequests);
|
||||
DecrementActive(routeKey, ActiveRequests);
|
||||
CleanupOccasionally();
|
||||
}
|
||||
}
|
||||
|
||||
private static Policy ResolvePolicy(HttpRequest request)
|
||||
{
|
||||
string path = request.Path.Value?.ToLowerInvariant() ?? string.Empty;
|
||||
bool isWrite = HttpMethods.IsPost(request.Method) ||
|
||||
HttpMethods.IsPut(request.Method) ||
|
||||
HttpMethods.IsPatch(request.Method) ||
|
||||
HttpMethods.IsDelete(request.Method);
|
||||
|
||||
if (path.Equals("/api/images/v4/uploadsaved", StringComparison.Ordinal))
|
||||
{
|
||||
return new Policy(
|
||||
"image-upload",
|
||||
BurstCapacity: 12,
|
||||
BurstRefillPerSecond: 12d / 60d,
|
||||
SustainedCapacity: 90,
|
||||
SustainedRefillPerSecond: 90d / 3600d,
|
||||
MaxConcurrent: 3,
|
||||
MaxBodyBytes: 12L * 1024L * 1024L);
|
||||
}
|
||||
|
||||
if (path.Equals("/upload", StringComparison.Ordinal))
|
||||
{
|
||||
return new Policy(
|
||||
"room-blob-upload",
|
||||
BurstCapacity: 30,
|
||||
BurstRefillPerSecond: 30d / 60d,
|
||||
SustainedCapacity: 360,
|
||||
SustainedRefillPerSecond: 360d / 3600d,
|
||||
MaxConcurrent: 3,
|
||||
MaxBodyBytes: 52L * 1024L * 1024L);
|
||||
}
|
||||
|
||||
if (path.Contains("community-board/media", StringComparison.Ordinal))
|
||||
{
|
||||
return new Policy(
|
||||
"admin-media-upload",
|
||||
BurstCapacity: 6,
|
||||
BurstRefillPerSecond: 6d / 60d,
|
||||
SustainedCapacity: 30,
|
||||
SustainedRefillPerSecond: 30d / 3600d,
|
||||
MaxConcurrent: 1,
|
||||
MaxBodyBytes: 106L * 1024L * 1024L);
|
||||
}
|
||||
|
||||
if (isWrite &&
|
||||
(path.Equals("/recnet/api/admin/rooms/import", StringComparison.Ordinal) ||
|
||||
path.Equals("/recnet/api/admin/rooms", StringComparison.Ordinal)))
|
||||
{
|
||||
return new Policy(
|
||||
"admin-room-import",
|
||||
BurstCapacity: 3,
|
||||
BurstRefillPerSecond: 3d / 300d,
|
||||
SustainedCapacity: 8,
|
||||
SustainedRefillPerSecond: 8d / 3600d,
|
||||
MaxConcurrent: 1,
|
||||
MaxBodyBytes: 512L * 1024L * 1024L);
|
||||
}
|
||||
|
||||
if (path.Equals("/recnet/api/auth/register", StringComparison.Ordinal))
|
||||
{
|
||||
return new Policy(
|
||||
"account-creation",
|
||||
BurstCapacity: 5,
|
||||
BurstRefillPerSecond: 5d / 600d,
|
||||
SustainedCapacity: 12,
|
||||
SustainedRefillPerSecond: 12d / 3600d,
|
||||
MaxConcurrent: 2,
|
||||
MaxBodyBytes: 64L * 1024L);
|
||||
}
|
||||
|
||||
if (path.Equals("/photon/authenticate", StringComparison.Ordinal))
|
||||
{
|
||||
return new Policy(
|
||||
"photon-callback",
|
||||
BurstCapacity: 1_000,
|
||||
BurstRefillPerSecond: 100,
|
||||
SustainedCapacity: 100_000,
|
||||
SustainedRefillPerSecond: 100_000d / 3600d,
|
||||
MaxConcurrent: 64,
|
||||
MaxBodyBytes: 128L * 1024L);
|
||||
}
|
||||
|
||||
if (path.Equals("/auth/connect/token", StringComparison.Ordinal))
|
||||
{
|
||||
return new Policy(
|
||||
"token-exchange",
|
||||
BurstCapacity: 300,
|
||||
BurstRefillPerSecond: 15,
|
||||
SustainedCapacity: 20_000,
|
||||
SustainedRefillPerSecond: 20_000d / 3600d,
|
||||
MaxConcurrent: 24,
|
||||
MaxBodyBytes: 2L * 1024L * 1024L);
|
||||
}
|
||||
|
||||
if (path.StartsWith("/auth/role/", StringComparison.Ordinal) ||
|
||||
path.Equals("/auth/eac/challenge", StringComparison.Ordinal) ||
|
||||
path.Equals("/api/photon/config", StringComparison.Ordinal) ||
|
||||
path.Equals("/photon/config", StringComparison.Ordinal) ||
|
||||
path.Equals("/voice/config", StringComparison.Ordinal))
|
||||
{
|
||||
return new Policy(
|
||||
"game-auth-read",
|
||||
BurstCapacity: 1_500,
|
||||
BurstRefillPerSecond: 75,
|
||||
SustainedCapacity: 100_000,
|
||||
SustainedRefillPerSecond: 100_000d / 3600d,
|
||||
MaxConcurrent: 48,
|
||||
MaxBodyBytes: 1L * 1024L * 1024L);
|
||||
}
|
||||
|
||||
if (path.Equals("/data/heartbeat", StringComparison.Ordinal) ||
|
||||
path.Equals("/match/player/exclusivelogin", StringComparison.Ordinal) ||
|
||||
path.Contains("heartbeat", StringComparison.Ordinal))
|
||||
{
|
||||
return new Policy(
|
||||
"game-session",
|
||||
BurstCapacity: 2_000,
|
||||
BurstRefillPerSecond: 100,
|
||||
SustainedCapacity: 200_000,
|
||||
SustainedRefillPerSecond: 200_000d / 3600d,
|
||||
MaxConcurrent: 64,
|
||||
MaxBodyBytes: 2L * 1024L * 1024L);
|
||||
}
|
||||
|
||||
if (path.StartsWith("/cdn/", StringComparison.Ordinal))
|
||||
{
|
||||
return new Policy(
|
||||
"cdn-read",
|
||||
BurstCapacity: 2_000,
|
||||
BurstRefillPerSecond: 100,
|
||||
SustainedCapacity: 100_000,
|
||||
SustainedRefillPerSecond: 100_000d / 3600d,
|
||||
MaxConcurrent: 48,
|
||||
MaxBodyBytes: 16 * 1024);
|
||||
}
|
||||
|
||||
if (path.StartsWith("/imageserver", StringComparison.Ordinal))
|
||||
{
|
||||
bool transformsImage = request.Query.ContainsKey("width") ||
|
||||
request.Query.ContainsKey("height") ||
|
||||
request.Query.ContainsKey("cropsquare");
|
||||
bool signsImage = string.Equals(
|
||||
request.Query["sig"].FirstOrDefault()?.Trim(),
|
||||
"p1",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
if (transformsImage)
|
||||
{
|
||||
return new Policy(
|
||||
"image-transform",
|
||||
BurstCapacity: 300,
|
||||
BurstRefillPerSecond: 10,
|
||||
SustainedCapacity: 4_000,
|
||||
SustainedRefillPerSecond: 4_000d / 3600d,
|
||||
MaxConcurrent: 8,
|
||||
MaxBodyBytes: 16 * 1024);
|
||||
}
|
||||
|
||||
if (signsImage)
|
||||
{
|
||||
return new Policy(
|
||||
"image-signed-read",
|
||||
BurstCapacity: 1_000,
|
||||
BurstRefillPerSecond: 50,
|
||||
SustainedCapacity: 50_000,
|
||||
SustainedRefillPerSecond: 50_000d / 3600d,
|
||||
MaxConcurrent: 32,
|
||||
MaxBodyBytes: 16 * 1024);
|
||||
}
|
||||
|
||||
return new Policy(
|
||||
"image-read",
|
||||
BurstCapacity: 2_000,
|
||||
BurstRefillPerSecond: 100,
|
||||
SustainedCapacity: 100_000,
|
||||
SustainedRefillPerSecond: 100_000d / 3600d,
|
||||
MaxConcurrent: 48,
|
||||
MaxBodyBytes: 16 * 1024);
|
||||
}
|
||||
|
||||
if (path.Contains("cloudvariables", StringComparison.Ordinal) ||
|
||||
path.Contains("roomvariables", StringComparison.Ordinal) ||
|
||||
path.Contains("/playerdata/", StringComparison.Ordinal))
|
||||
{
|
||||
return new Policy(
|
||||
"cloud-variable",
|
||||
BurstCapacity: 2_000,
|
||||
BurstRefillPerSecond: 100,
|
||||
SustainedCapacity: 200_000,
|
||||
SustainedRefillPerSecond: 200_000d / 3600d,
|
||||
MaxConcurrent: 32,
|
||||
MaxBodyBytes: 256L * 1024L);
|
||||
}
|
||||
|
||||
if (path.Equals("/recnet/api/auth/login", StringComparison.Ordinal))
|
||||
{
|
||||
return new Policy(
|
||||
"recnet-login",
|
||||
BurstCapacity: 20,
|
||||
BurstRefillPerSecond: 1,
|
||||
SustainedCapacity: 300,
|
||||
SustainedRefillPerSecond: 300d / 3600d,
|
||||
MaxConcurrent: 4,
|
||||
MaxBodyBytes: 128L * 1024L);
|
||||
}
|
||||
|
||||
if (isWrite)
|
||||
{
|
||||
return new Policy(
|
||||
"game-write",
|
||||
BurstCapacity: 2_000,
|
||||
BurstRefillPerSecond: 100,
|
||||
SustainedCapacity: 200_000,
|
||||
SustainedRefillPerSecond: 200_000d / 3600d,
|
||||
MaxConcurrent: 48,
|
||||
MaxBodyBytes: 20L * 1024L * 1024L);
|
||||
}
|
||||
|
||||
return new Policy(
|
||||
"game-read",
|
||||
BurstCapacity: 4_000,
|
||||
BurstRefillPerSecond: 200,
|
||||
SustainedCapacity: 400_000,
|
||||
SustainedRefillPerSecond: 400_000d / 3600d,
|
||||
MaxConcurrent: 96,
|
||||
MaxBodyBytes: 1L * 1024L * 1024L);
|
||||
}
|
||||
|
||||
private static string GetRateLimitRouteKey(HttpRequest request, string policyName)
|
||||
{
|
||||
string rawPath = request.Path.Value ?? "/";
|
||||
string[] segments = rawPath.Split(
|
||||
'/',
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
for (int index = 0; index < segments.Length; index++)
|
||||
{
|
||||
string segment = segments[index];
|
||||
if (long.TryParse(segment, out _) ||
|
||||
Guid.TryParse(segment, out _) ||
|
||||
LooksLikeOpaqueIdentifier(segment))
|
||||
{
|
||||
segments[index] = "{id}";
|
||||
}
|
||||
else
|
||||
{
|
||||
segments[index] = segment.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
string normalizedPath = segments.Length == 0
|
||||
? "/"
|
||||
: "/" + string.Join('/', segments);
|
||||
return $"{policyName}:{request.Method.ToUpperInvariant()}:{normalizedPath}";
|
||||
}
|
||||
|
||||
private static bool LooksLikeOpaqueIdentifier(string value)
|
||||
{
|
||||
if (value.Length < 24)
|
||||
return false;
|
||||
|
||||
foreach (char character in value)
|
||||
{
|
||||
if (!char.IsLetterOrDigit(character) &&
|
||||
character != '-' &&
|
||||
character != '_')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryConsume(
|
||||
string key,
|
||||
int capacity,
|
||||
double refillPerSecond,
|
||||
out int retryAfterSeconds)
|
||||
{
|
||||
DateTime now = DateTime.UtcNow;
|
||||
TokenBucket bucket = Buckets.GetOrAdd(key, _ => new TokenBucket(capacity, now));
|
||||
|
||||
lock (bucket.Sync)
|
||||
{
|
||||
double elapsedSeconds = Math.Max(
|
||||
0,
|
||||
(now - bucket.LastRefillUtc).TotalSeconds);
|
||||
bucket.Tokens = Math.Min(
|
||||
capacity,
|
||||
bucket.Tokens + (elapsedSeconds * refillPerSecond));
|
||||
bucket.LastRefillUtc = now;
|
||||
bucket.LastSeenUtc = now;
|
||||
|
||||
if (bucket.Tokens >= 1)
|
||||
{
|
||||
bucket.Tokens -= 1;
|
||||
retryAfterSeconds = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
retryAfterSeconds = refillPerSecond <= 0
|
||||
? 60
|
||||
: Math.Max(1, (int)Math.Ceiling((1 - bucket.Tokens) / refillPerSecond));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetClientKey(HttpRequest request)
|
||||
{
|
||||
IPAddress? address = ClientNetwork.GetClientIp(request);
|
||||
return "ip:" + (address?.ToString() ?? "unknown");
|
||||
}
|
||||
|
||||
private static void ApplySecurityHeaders(HttpResponse response)
|
||||
{
|
||||
response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||
response.Headers["X-Frame-Options"] = "DENY";
|
||||
response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
|
||||
response.Headers["Permissions-Policy"] =
|
||||
"camera=(), microphone=(), geolocation=(), payment=(), usb=()";
|
||||
}
|
||||
|
||||
private static bool ShouldSkipIpBan(string path) =>
|
||||
path is "/recnet" or "/recnet/" or "/recnet/api/session" or
|
||||
"/photon/authenticate" ||
|
||||
(path.StartsWith("/recnet/", StringComparison.Ordinal) &&
|
||||
!path.StartsWith("/recnet/api/", StringComparison.Ordinal)) ||
|
||||
path.StartsWith("/recnet/api/admin/ip-bans", StringComparison.Ordinal);
|
||||
|
||||
private static bool ShouldCheckVpn(string path)
|
||||
{
|
||||
if (path is "/" or "/nameserver" or "/favicon.ico" or
|
||||
"/privacy" or "/tos" or "/recnet" or "/recnet/" or
|
||||
"/photon/authenticate" ||
|
||||
path.StartsWith("/recnet/api/admin/", StringComparison.Ordinal) ||
|
||||
path.StartsWith("/cdn/", StringComparison.Ordinal) ||
|
||||
path.StartsWith("/imageserver", StringComparison.Ordinal) ||
|
||||
(path.StartsWith("/recnet/", StringComparison.Ordinal) &&
|
||||
!path.StartsWith("/recnet/api/", StringComparison.Ordinal)) ||
|
||||
path is "/recnet/api/status" or "/recnet/api/session")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static ObjectResult Error(
|
||||
int statusCode,
|
||||
string error,
|
||||
string message,
|
||||
object? extra = null)
|
||||
{
|
||||
object payload = extra == null
|
||||
? new { success = false, error, message }
|
||||
: new { success = false, error, message, details = extra };
|
||||
return new ObjectResult(payload) { StatusCode = statusCode };
|
||||
}
|
||||
|
||||
private static int GetGlobalConcurrencyLimit(string policyName) =>
|
||||
policyName switch
|
||||
{
|
||||
"admin-media-upload" => 2,
|
||||
"image-upload" => 12,
|
||||
"room-blob-upload" => 12,
|
||||
"image-transform" => 32,
|
||||
"recnet-login" or "account-creation" => 32,
|
||||
"token-exchange" => 256,
|
||||
"photon-callback" => 512,
|
||||
"game-auth-read" or "game-session" => 1_024,
|
||||
"game-write" or "cloud-variable" => 1_024,
|
||||
"image-signed-read" or "image-read" or "cdn-read" => 1_024,
|
||||
_ => 2_048
|
||||
};
|
||||
|
||||
private static void DecrementActive(
|
||||
string key,
|
||||
ConcurrentDictionary<string, int> counters)
|
||||
{
|
||||
while (counters.TryGetValue(key, out int current))
|
||||
{
|
||||
if (current <= 1)
|
||||
{
|
||||
if (counters.TryRemove(key, out _))
|
||||
return;
|
||||
}
|
||||
else if (counters.TryUpdate(key, current - 1, current))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static int GetGlobalActiveRequests()
|
||||
{
|
||||
int total = 0;
|
||||
foreach (var kvp in GlobalActiveRequests)
|
||||
total += kvp.Value;
|
||||
return total;
|
||||
}
|
||||
|
||||
private static void CleanupOccasionally()
|
||||
{
|
||||
long count = Interlocked.Increment(ref _requestCounter);
|
||||
if ((count & 4095) != 0)
|
||||
return;
|
||||
|
||||
DateTime cutoff = DateTime.UtcNow.AddHours(-2);
|
||||
foreach (var pair in Buckets)
|
||||
{
|
||||
if (pair.Value.LastSeenUtc < cutoff)
|
||||
Buckets.TryRemove(pair.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user