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().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(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 ExecuteCommandOnMainThread(AHelpApiCommand command, CancellationToken cancellationToken) { var completion = new TaskCompletionSource(); _taskManager.RunOnMainThread(() => { if (cancellationToken.IsCancellationRequested) { completion.TrySetCanceled(cancellationToken); return; } completion.TrySetResult(_commands.ExecuteCommand(command)); }); return completion.Task; } internal async Task 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 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 ReadCommandApiRequest(IStatusHandlerContext context) { try { var command = await context.RequestBodyJsonAsync(); 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; } } }