CatStation/Content.Server/Corvax/Api/AHelp/AHelpBotApiSystem.cs
Dmitry fffb304de9 upstream codefix
secapartment update
2026-09-18 01:54:40 +07:00

301 lines
9.4 KiB
C#

using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Content.Server.Administration.Systems;
using Content.Shared.Corvax.CCCVars;
using Robust.Shared;
using Robust.Shared.Asynchronous;
using Robust.Shared.Configuration;
using Robust.Server.ServerStatus;
using Content.Shared.GameTicking;
namespace Content.Server.Corvax.Api.AHelp;
public sealed partial class AHelpBotApiSystem : EntitySystem
{
[Dependency] private IConfigurationManager _cfg = default!;
[Dependency] private ILogManager _logManager = default!;
[Dependency] private ITaskManager _taskManager = default!;
[Dependency] private BwoinkSystem _bwoinkSystem = default!;
[Dependency] private GameTicker _gameTicker = default!;
[Dependency] private AHelpBotCommandSystem _commands = default!;
private readonly HttpClient _httpClient = new();
private ISawmill _sawmill = default!;
private string _apiUrl = string.Empty;
private string _apiToken = string.Empty;
private int _apiTimeout;
private bool _enabled;
private bool _pushInProgress;
private bool _pushQueued;
private readonly object _pushLock = new();
internal const string CommandPath = "/admin/ahelp/command";
private const string AHelpTokenScheme = "AHelpToken";
internal static void RegisterStatusHostHandler(IStatusHost statusHost, IEntitySystemManager entitySystemManager)
{
statusHost.AddHandler(context =>
{
if (context.RequestMethod != HttpMethod.Post || context.Url.AbsolutePath != CommandPath)
return Task.FromResult(false);
return entitySystemManager.GetEntitySystem<AHelpBotApiSystem>().HandleCommandApiRequest(context);
});
}
public override void Initialize()
{
base.Initialize();
_sawmill = _logManager.GetSawmill("ahelp.api");
_cfg.OnValueChanged(CCCVars.AHelpApiTimeout, OnApiTimeoutChanged, true);
_cfg.OnValueChanged(CCCVars.AHelpApiEnabled, OnEnabledChanged, true);
_cfg.OnValueChanged(CCCVars.AHelpApiUrl, OnApiUrlChanged, true);
_cfg.OnValueChanged(CCCVars.AHelpApiToken, OnApiTokenChanged, true);
SubscribeLocalEvent<CorvaxAHelpRelayChangedEvent>(OnRelayChanged);
}
public override void Shutdown()
{
_cfg.UnsubValueChanged(CCCVars.AHelpApiEnabled, OnEnabledChanged);
_cfg.UnsubValueChanged(CCCVars.AHelpApiUrl, OnApiUrlChanged);
_cfg.UnsubValueChanged(CCCVars.AHelpApiToken, OnApiTokenChanged);
_cfg.UnsubValueChanged(CCCVars.AHelpApiTimeout, OnApiTimeoutChanged);
_httpClient.Dispose();
base.Shutdown();
}
private void OnRelayChanged(CorvaxAHelpRelayChangedEvent ev)
{
PushCurrentState();
}
private AHelpApiEventRequest BuildEventRequest()
{
var conversations = _bwoinkSystem.CorvaxGetAHelpRelaySnapshots()
.Select(snapshot => new AHelpApiConversation(
snapshot.UserId.ToString(),
snapshot.UserId.ToString(),
snapshot.Username,
snapshot.CharacterName,
snapshot.RootMessageId,
snapshot.WebhookChannelId,
_cfg.GetCVar(CVars.GameHostName),
_gameTicker.RoundId,
snapshot.LastRunLevel.ToString(),
string.IsNullOrWhiteSpace(snapshot.Description) ? null : snapshot.Description))
.ToArray();
return new AHelpApiEventRequest(
"state",
_cfg.GetCVar(CVars.GameHostName),
_gameTicker.RoundId,
_gameTicker.RunLevel.ToString(),
conversations);
}
private void PushCurrentState()
{
if (!_enabled || string.IsNullOrWhiteSpace(_apiUrl) || string.IsNullOrWhiteSpace(_apiToken))
return;
lock (_pushLock)
{
if (_pushInProgress)
{
_pushQueued = true;
return;
}
_pushInProgress = true;
}
AHelpApiEventRequest request;
try
{
request = BuildEventRequest();
}
catch (Exception e)
{
_sawmill.Warning($"Unable to build AHelp API event: {e.Message}");
FinishPush();
return;
}
_ = RunPushAsync(request);
}
private async Task RunPushAsync(AHelpApiEventRequest request)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(_apiTimeout));
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, _apiUrl)
{
Content = JsonContent.Create(request),
};
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("AHelpToken", _apiToken);
using var response = await _httpClient.SendAsync(httpRequest, cts.Token);
if (!response.IsSuccessStatusCode)
{
_sawmill.Warning($"AHelp API event push returned bad status code: {response.StatusCode}");
}
}
catch (TaskCanceledException)
{
_sawmill.Warning("AHelp API event push timed out");
}
catch (Exception e)
{
_sawmill.Warning($"AHelp API event push failed: {e.Message}");
}
finally
{
FinishPush();
}
}
private void FinishPush()
{
var runQueued = false;
lock (_pushLock)
{
_pushInProgress = false;
if (_pushQueued)
{
_pushQueued = false;
runQueued = true;
}
}
if (runQueued)
_taskManager.RunOnMainThread(PushCurrentState);
}
private void OnEnabledChanged(bool value)
{
_enabled = value;
PushCurrentState();
}
private void OnApiUrlChanged(string value)
{
_apiUrl = value;
PushCurrentState();
}
private void OnApiTokenChanged(string value)
{
_apiToken = value;
PushCurrentState();
}
private void OnApiTimeoutChanged(int value)
{
_apiTimeout = Math.Max(1, value);
}
private Task<AHelpApiCommandResponse> ExecuteCommandOnMainThread(AHelpApiCommand command, CancellationToken cancellationToken)
{
var completion = new TaskCompletionSource<AHelpApiCommandResponse>();
_taskManager.RunOnMainThread(() =>
{
if (cancellationToken.IsCancellationRequested)
{
completion.TrySetCanceled(cancellationToken);
return;
}
completion.TrySetResult(_commands.ExecuteCommand(command));
});
return completion.Task;
}
internal async Task<bool> HandleCommandApiRequest(IStatusHandlerContext context)
{
if (!await CheckCommandApiAccess(context))
return true;
if (!_enabled)
{
await context.RespondAsync("AHelp API is disabled", HttpStatusCode.ServiceUnavailable);
return true;
}
var command = await ReadCommandApiRequest(context);
if (command == null)
return true;
var response = await ExecuteCommandOnMainThread(command, CancellationToken.None);
await context.RespondJsonAsync(response);
return true;
}
private async Task<bool> CheckCommandApiAccess(IStatusHandlerContext context)
{
if (!context.RequestHeaders.TryGetValue("Authorization", out var authToken))
{
await context.RespondAsync("Authorization is required", HttpStatusCode.Unauthorized);
return false;
}
var authHeaderValue = authToken.ToString();
var spaceIndex = authHeaderValue.IndexOf(' ');
if (spaceIndex == -1)
{
await context.RespondAsync("Invalid Authorization header value", HttpStatusCode.BadRequest);
return false;
}
var authScheme = authHeaderValue[..spaceIndex];
var authValue = authHeaderValue[spaceIndex..].Trim();
if (authScheme != AHelpTokenScheme)
{
await context.RespondAsync("Invalid Authorization scheme", HttpStatusCode.BadRequest);
return false;
}
if (!string.IsNullOrWhiteSpace(_apiToken) &&
CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(authValue),
Encoding.UTF8.GetBytes(_apiToken)))
{
return true;
}
await context.RespondAsync("Authorization is invalid", HttpStatusCode.Unauthorized);
_sawmill.Info($"Unauthorized access attempt to AHelp command API from {context.RemoteEndPoint}");
return false;
}
private static async Task<AHelpApiCommand?> ReadCommandApiRequest(IStatusHandlerContext context)
{
try
{
var command = await context.RequestBodyJsonAsync<AHelpApiCommand>();
if (command == null)
await context.RespondAsync("Request body is null", HttpStatusCode.BadRequest);
return command;
}
catch (Exception e)
{
await context.RespondAsync($"Unable to parse request body: {e.Message}", HttpStatusCode.BadRequest);
return null;
}
}
}