From 297853929b7b3859760dcdda95e21888672ce8e1 Mon Sep 17 00:00:00 2001 From: Simon <63975668+Simyon264@users.noreply.github.com> Date: Wed, 10 Apr 2024 11:37:16 +0200 Subject: [PATCH] Game server api (#24015) * Revert "Revert "Game server api (#23129)"" * Review pt.1 * Reviews pt.2 * Reviews pt. 3 * Reviews pt. 4 --- Content.Server/Administration/ServerAPI.cs | 1033 +++++++++++++++++ .../Administration/Systems/AdminSystem.cs | 24 +- Content.Server/Entry/EntryPoint.cs | 2 + Content.Server/IoC/ServerContentIoC.cs | 1 + Content.Shared/CCVar/CCVars.cs | 7 + 5 files changed, 1055 insertions(+), 12 deletions(-) create mode 100644 Content.Server/Administration/ServerAPI.cs diff --git a/Content.Server/Administration/ServerAPI.cs b/Content.Server/Administration/ServerAPI.cs new file mode 100644 index 0000000000..d7591fb80c --- /dev/null +++ b/Content.Server/Administration/ServerAPI.cs @@ -0,0 +1,1033 @@ +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Content.Server.Administration.Systems; +using Content.Server.GameTicking; +using Content.Server.GameTicking.Presets; +using Content.Server.GameTicking.Rules.Components; +using Content.Server.Maps; +using Content.Server.RoundEnd; +using Content.Shared.Administration.Events; +using Content.Shared.Administration.Managers; +using Content.Shared.CCVar; +using Content.Shared.Prototypes; +using Robust.Server.ServerStatus; +using Robust.Shared.Asynchronous; +using Robust.Shared.Configuration; +using Robust.Shared.Network; +using Robust.Shared.Player; +using Robust.Shared.Prototypes; +using Robust.Shared.Utility; + +namespace Content.Server.Administration; + +public sealed class ServerApi : IPostInjectInit +{ + [Dependency] private readonly IStatusHost _statusHost = default!; + [Dependency] private readonly IConfigurationManager _config = default!; + [Dependency] private readonly ISharedPlayerManager _playerManager = default!; // Players + [Dependency] private readonly ISharedAdminManager _adminManager = default!; // Admins + [Dependency] private readonly IGameMapManager _gameMapManager = default!; // Map name + [Dependency] private readonly IServerNetManager _netManager = default!; // Kick + [Dependency] private readonly IPrototypeManager _prototypeManager = default!; // Game rules + [Dependency] private readonly IComponentFactory _componentFactory = default!; + [Dependency] private readonly ITaskManager _taskManager = default!; // game explodes when calling stuff from the non-game thread + [Dependency] private readonly EntityManager _entityManager = default!; + + [Dependency] private readonly IEntitySystemManager _entitySystemManager = default!; + + private string _token = string.Empty; + private ISawmill _sawmill = default!; + + public static Dictionary PanicPunkerCvarNames = new() + { + { "Enabled", "game.panic_bunker.enabled" }, + { "DisableWithAdmins", "game.panic_bunker.disable_with_admins" }, + { "EnableWithoutAdmins", "game.panic_bunker.enable_without_admins" }, + { "CountDeadminnedAdmins", "game.panic_bunker.count_deadminned_admins" }, + { "ShowReason", "game.panic_bunker.show_reason" }, + { "MinAccountAgeHours", "game.panic_bunker.min_account_age" }, + { "MinOverallHours", "game.panic_bunker.min_overall_hours" }, + { "CustomReason", "game.panic_bunker.custom_reason" } + }; + + void IPostInjectInit.PostInject() + { + _sawmill = Logger.GetSawmill("serverApi"); + + // Get + _statusHost.AddHandler(InfoHandler); + _statusHost.AddHandler(GetGameRules); + _statusHost.AddHandler(GetForcePresets); + + // Post + _statusHost.AddHandler(ActionRoundStatus); + _statusHost.AddHandler(ActionKick); + _statusHost.AddHandler(ActionAddGameRule); + _statusHost.AddHandler(ActionEndGameRule); + _statusHost.AddHandler(ActionForcePreset); + _statusHost.AddHandler(ActionForceMotd); + _statusHost.AddHandler(ActionPanicPunker); + } + + public void Initialize() + { + _config.OnValueChanged(CCVars.AdminApiToken, UpdateToken, true); + } + + public void Shutdown() + { + _config.UnsubValueChanged(CCVars.AdminApiToken, UpdateToken); + } + + private void UpdateToken(string token) + { + _token = token; + } + + +#region Actions + + /// + /// Changes the panic bunker settings. + /// + private async Task ActionPanicPunker(IStatusHandlerContext context) + { + if (context.RequestMethod != HttpMethod.Patch || context.Url.AbsolutePath != "/admin/actions/panic_bunker") + { + return false; + } + + if (!CheckAccess(context)) + return true; + + var body = await ReadJson>(context); + var (success, actor) = await CheckActor(context); + if (!success) + return true; + + foreach (var panicPunkerActions in body!.Select(x => new { Action = x.Key, Value = x.Value.ToString() })) + { + if (panicPunkerActions.Action == null || panicPunkerActions.Value == null) + { + await context.RespondJsonAsync(new BaseResponse() + { + Message = "Action and value are required to perform this action.", + Exception = new ExceptionData() + { + Message = "Action and value are required to perform this action.", + ErrorType = ErrorTypes.ActionNotSpecified + } + }, HttpStatusCode.BadRequest); + return true; + } + + if (!PanicPunkerCvarNames.TryGetValue(panicPunkerActions.Action, out var cvarName)) + { + await context.RespondJsonAsync(new BaseResponse() + { + Message = $"Cannot set: Action {panicPunkerActions.Action} does not exist.", + Exception = new ExceptionData() + { + Message = $"Cannot set: Action {panicPunkerActions.Action} does not exist.", + ErrorType = ErrorTypes.ActionNotSupported + } + }, HttpStatusCode.BadRequest); + return true; + } + + // Since the CVar can be of different types, we need to parse it to the correct type + // First, I try to parse it as a bool, if it fails, I try to parse it as an int + // And as a last resort, I do nothing and put it as a string + if (bool.TryParse(panicPunkerActions.Value, out var boolValue)) + { + await RunOnMainThread(() => _config.SetCVar(cvarName, boolValue)); + } + else if (int.TryParse(panicPunkerActions.Value, out var intValue)) + { + await RunOnMainThread(() => _config.SetCVar(cvarName, intValue)); + } + else + { + await RunOnMainThread(() => _config.SetCVar(cvarName, panicPunkerActions.Value)); + } + _sawmill.Info($"Panic bunker property {panicPunkerActions} changed to {panicPunkerActions.Value} by {actor!.Name} ({actor.Guid})."); + } + + await context.RespondJsonAsync(new BaseResponse() + { + Message = "OK" + }); + return true; + } + + /// + /// Sets the current MOTD. + /// + private async Task ActionForceMotd(IStatusHandlerContext context) + { + if (context.RequestMethod != HttpMethod.Post || context.Url.AbsolutePath != "/admin/actions/set_motd") + { + return false; + } + + if (!CheckAccess(context)) + return true; + + var motd = await ReadJson(context); + var (success, actor) = await CheckActor(context); + if (!success) + return true; + + + if (motd!.Motd == null) + { + await context.RespondJsonAsync(new BaseResponse() + { + Message = "A motd is required to perform this action.", + Exception = new ExceptionData() + { + Message = "A motd is required to perform this action.", + ErrorType = ErrorTypes.MotdNotSpecified + } + }, HttpStatusCode.BadRequest); + return true; + } + + _sawmill.Info($"MOTD changed to \"{motd.Motd}\" by {actor!.Name} ({actor.Guid})."); + + await RunOnMainThread(() => _config.SetCVar(CCVars.MOTD, motd.Motd)); + // A hook in the MOTD system sends the changes to each client + await context.RespondJsonAsync(new BaseResponse() + { + Message = "OK" + }); + return true; + } + + /// + /// Forces the next preset- + /// + private async Task ActionForcePreset(IStatusHandlerContext context) + { + if (context.RequestMethod != HttpMethod.Post || context.Url.AbsolutePath != "/admin/actions/force_preset") + { + return false; + } + + if (!CheckAccess(context)) + return true; + + var body = await ReadJson(context); + var (success, actor) = await CheckActor(context); + if (!success) + return true; + + var ticker = await RunOnMainThread(() => _entitySystemManager.GetEntitySystem()); + + if (ticker.RunLevel != GameRunLevel.PreRoundLobby) + { + await context.RespondJsonAsync(new BaseResponse() + { + Message = "Round already started", + Exception = new ExceptionData() + { + Message = "Round already started", + ErrorType = ErrorTypes.RoundAlreadyStarted + } + }, HttpStatusCode.Conflict); + return true; + } + + if (body!.PresetId == null) + { + await context.RespondJsonAsync(new BaseResponse() + { + Message = "A preset is required to perform this action.", + Exception = new ExceptionData() + { + Message = "A preset is required to perform this action.", + ErrorType = ErrorTypes.PresetNotSpecified + } + }, HttpStatusCode.BadRequest); + return true; + } + + var result = await RunOnMainThread(() => ticker.FindGamePreset(body.PresetId)); + if (result == null) + { + await context.RespondJsonAsync(new BaseResponse() + { + Message = "Preset not found", + Exception = new ExceptionData() + { + Message = "Preset not found", + ErrorType = ErrorTypes.PresetNotSpecified + } + }, HttpStatusCode.UnprocessableContent); + return true; + } + + await RunOnMainThread(() => + { + ticker.SetGamePreset(result); + }); + _sawmill.Info($"Forced the game to start with preset {body.PresetId} by {actor!.Name}({actor.Guid})."); + await context.RespondJsonAsync(new BaseResponse() + { + Message = "OK" + }); + return true; + } + + /// + /// Ends an active game rule. + /// + private async Task ActionEndGameRule(IStatusHandlerContext context) + { + if (context.RequestMethod != HttpMethod.Post || context.Url.AbsolutePath != "/admin/actions/end_game_rule") + { + return false; + } + + if (!CheckAccess(context)) + return true; + + var body = await ReadJson(context); + var (success, actor) = await CheckActor(context); + if (!success) + return true; + + if (body!.GameRuleId == null) + { + await context.RespondJsonAsync(new BaseResponse() + { + Message = "A game rule is required to perform this action.", + Exception = new ExceptionData() + { + Message = "A game rule is required to perform this action.", + ErrorType = ErrorTypes.GuidNotSpecified + } + }, HttpStatusCode.BadRequest); + return true; + } + var ticker = await RunOnMainThread(() => _entitySystemManager.GetEntitySystem()); + + var gameRuleEntity = await RunOnMainThread(() => ticker + .GetActiveGameRules() + .FirstOrNull(rule => _entityManager.MetaQuery.GetComponent(rule).EntityPrototype?.ID == body.GameRuleId)); + + if (gameRuleEntity == null) // Game rule not found + { + await context.RespondJsonAsync(new BaseResponse() + { + Message = "Game rule not found or not active", + Exception = new ExceptionData() + { + Message = "Game rule not found or not active", + ErrorType = ErrorTypes.GameRuleNotFound + } + }, HttpStatusCode.Conflict); + return true; + } + + _sawmill.Info($"Ended game rule {body.GameRuleId} by {actor!.Name} ({actor.Guid})."); + await RunOnMainThread(() => ticker.EndGameRule((EntityUid) gameRuleEntity)); + await context.RespondJsonAsync(new BaseResponse() + { + Message = "OK" + }); + return true; + } + + /// + /// Adds a game rule to the current round. + /// + private async Task ActionAddGameRule(IStatusHandlerContext context) + { + if (context.RequestMethod != HttpMethod.Post || context.Url.AbsolutePath != "/admin/actions/add_game_rule") + { + return false; + } + + if (!CheckAccess(context)) + return true; + + var body = await ReadJson(context); + var (success, actor) = await CheckActor(context); + if (!success) + return true; + + if (body!.GameRuleId == null) + { + await context.RespondJsonAsync(new BaseResponse() + { + Message = "A game rule is required to perform this action.", + Exception = new ExceptionData() + { + Message = "A game rule is required to perform this action.", + ErrorType = ErrorTypes.GuidNotSpecified + } + }, HttpStatusCode.BadRequest); + return true; + } + + var ruleEntity = await RunOnMainThread(() => + { + var ticker = _entitySystemManager.GetEntitySystem(); + // See if prototype exists + try + { + _prototypeManager.Index(body.GameRuleId); + } + catch (KeyNotFoundException e) + { + return null; + } + + var ruleEntity = ticker.AddGameRule(body.GameRuleId); + _sawmill.Info($"Added game rule {body.GameRuleId} by {actor!.Name} ({actor.Guid})."); + if (ticker.RunLevel == GameRunLevel.InRound) + { + ticker.StartGameRule(ruleEntity); + _sawmill.Info($"Started game rule {body.GameRuleId} by {actor.Name} ({actor.Guid})."); + } + return ruleEntity; + }); + if (ruleEntity == null) + { + await context.RespondJsonAsync(new BaseResponse() + { + Message = "Game rule not found", + Exception = new ExceptionData() + { + Message = "Game rule not found", + ErrorType = ErrorTypes.GameRuleNotFound + } + }, HttpStatusCode.UnprocessableContent); + return true; + } + + await context.RespondJsonAsync(new BaseResponse() + { + Message = "OK" + }); + return true; + } + + /// + /// Kicks a player. + /// + private async Task ActionKick(IStatusHandlerContext context) + { + if (context.RequestMethod != HttpMethod.Post || context.Url.AbsolutePath != "/admin/actions/kick") + { + return false; + } + + if (!CheckAccess(context)) + return true; + + var body = await ReadJson(context); + var (success, actor) = await CheckActor(context); + if (!success) + return true; + + if (body == null) + { + _sawmill.Info($"Attempted to kick player without supplying a body by {actor!.Name}({actor.Guid})."); + await context.RespondJsonAsync(new BaseResponse() + { + Message = "A body is required to perform this action.", + Exception = new ExceptionData() + { + Message = "A body is required to perform this action.", + ErrorType = ErrorTypes.BodyUnableToParse + } + }, HttpStatusCode.BadRequest); + return true; + } + + if (body.Guid == null) + { + _sawmill.Info($"Attempted to kick player without supplying a username by {actor!.Name}({actor.Guid})."); + await context.RespondJsonAsync(new BaseResponse() + { + Message = "A player is required to perform this action.", + Exception = new ExceptionData() + { + Message = "A player is required to perform this action.", + ErrorType = ErrorTypes.GuidNotSpecified + } + }, HttpStatusCode.BadRequest); + return true; + } + + var session = await RunOnMainThread(() => + { + _playerManager.TryGetSessionById(new NetUserId(new Guid(body.Guid)), out var player); + return player; + }); + + if (session == null) + { + _sawmill.Info($"Attempted to kick player {body.Guid} by {actor!.Name} ({actor.Guid}), but they were not found."); + await context.RespondJsonAsync(new BaseResponse() + { + Message = "Player not found", + Exception = new ExceptionData() + { + Message = "Player not found", + ErrorType = ErrorTypes.PlayerNotFound + } + }, HttpStatusCode.UnprocessableContent); + return true; + } + + var reason = body.Reason ?? "No reason supplied"; + reason += " (kicked by admin)"; + + await RunOnMainThread(() => + { + _netManager.DisconnectChannel(session.Channel, reason); + }); + await context.RespondJsonAsync(new BaseResponse() + { + Message = "OK" + }); + _sawmill.Info("Kicked player {0} ({1}) for {2} by {3}({4})", session.Name, session.UserId.UserId.ToString(), reason, actor!.Name, actor.Guid); + return true; + } + + /// + /// Round restart/end + /// + private async Task ActionRoundStatus(IStatusHandlerContext context) + { + if (context.RequestMethod != HttpMethod.Post || !context.Url.AbsolutePath.StartsWith("/admin/actions/round/")) + { + return false; + } + + // Make sure paths like /admin/actions/round/lol/start don't work + if (context.Url.AbsolutePath.Split('/').Length != 5) + { + return false; + } + + if (!CheckAccess(context)) + return true; + var (success, actor) = await CheckActor(context); + if (!success) + return true; + + + var (ticker, roundEndSystem) = await RunOnMainThread(() => + { + var ticker = _entitySystemManager.GetEntitySystem(); + var roundEndSystem = _entitySystemManager.GetEntitySystem(); + return (ticker, roundEndSystem); + }); + + // Action is the last part of the URL path (e.g. /admin/actions/round/start -> start) + var action = context.Url.AbsolutePath.Split('/').Last(); + + switch (action) + { + case "start": + if (ticker.RunLevel != GameRunLevel.PreRoundLobby) + { + await context.RespondJsonAsync(new BaseResponse() + { + Message = "Round already started", + Exception = new ExceptionData() + { + Message = "Round already started", + ErrorType = ErrorTypes.RoundAlreadyStarted + } + }, HttpStatusCode.Conflict); + _sawmill.Debug("Forced round start failed: round already started"); + return true; + } + + await RunOnMainThread(() => + { + ticker.StartRound(); + }); + _sawmill.Info("Forced round start"); + break; + case "end": + if (ticker.RunLevel != GameRunLevel.InRound) + { + await context.RespondJsonAsync(new BaseResponse() + { + Message = "Round already ended", + Exception = new ExceptionData() + { + Message = "Round already ended", + ErrorType = ErrorTypes.RoundAlreadyEnded + } + }, HttpStatusCode.Conflict); + _sawmill.Debug("Forced round end failed: round is not in progress"); + return true; + } + await RunOnMainThread(() => + { + roundEndSystem.EndRound(); + }); + _sawmill.Info("Forced round end"); + break; + case "restart": + if (ticker.RunLevel != GameRunLevel.InRound) + { + await context.RespondJsonAsync(new BaseResponse() + { + Message = "Round not in progress", + Exception = new ExceptionData() + { + Message = "Round not in progress", + ErrorType = ErrorTypes.RoundNotInProgress + } + }, HttpStatusCode.Conflict); + _sawmill.Debug("Forced round restart failed: round is not in progress"); + return true; + } + await RunOnMainThread(() => + { + roundEndSystem.EndRound(); + }); + _sawmill.Info("Forced round restart"); + break; + case "restartnow": // You should restart yourself NOW!!! + await RunOnMainThread(() => + { + ticker.RestartRound(); + }); + _sawmill.Info("Forced instant round restart"); + break; + default: + return false; + } + + _sawmill.Info($"Round {action} by {actor!.Name} ({actor.Guid})."); + await context.RespondJsonAsync(new BaseResponse() + { + Message = "OK" + }); + return true; + } +#endregion + +#region Fetching + + /// + /// Returns an array containing all available presets. + /// + private async Task GetForcePresets(IStatusHandlerContext context) + { + if (context.RequestMethod != HttpMethod.Get || context.Url.AbsolutePath != "/admin/force_presets") + { + return false; + } + + if (!CheckAccess(context)) + return true; + + var presets = new List<(string id, string desc)>(); + foreach (var preset in _prototypeManager.EnumeratePrototypes()) + { + presets.Add((preset.ID, preset.Description)); + } + + await context.RespondJsonAsync(new PresetResponse() + { + Presets = presets + }); + return true; + } + + /// + /// Returns an array containing all game rules. + /// + private async Task GetGameRules(IStatusHandlerContext context) + { + if (context.RequestMethod != HttpMethod.Get || context.Url.AbsolutePath != "/admin/game_rules") + { + return false; + } + + if (!CheckAccess(context)) + return true; + + var gameRules = new List(); + foreach (var gameRule in _prototypeManager.EnumeratePrototypes()) + { + if (gameRule.Abstract) + continue; + + if (gameRule.HasComponent(_componentFactory)) + gameRules.Add(gameRule.ID); + } + + await context.RespondJsonAsync(new GameruleResponse() + { + GameRules = gameRules + }); + return true; + } + + + /// + /// Handles fetching information. + /// + private async Task InfoHandler(IStatusHandlerContext context) + { + if (context.RequestMethod != HttpMethod.Get || context.Url.AbsolutePath != "/admin/info") + { + return false; + } + + if (!CheckAccess(context)) + return true; + + var (success, actor) = await CheckActor(context); + if (!success) + return true; + + /* Information to display + Round number + Connected players + Active admins + Active game rules + Active game preset + Active map + MOTD + Panic bunker status + */ + + var (ticker, adminSystem) = await RunOnMainThread(() => + { + var ticker = _entitySystemManager.GetEntitySystem(); + var adminSystem = _entitySystemManager.GetEntitySystem(); + return (ticker, adminSystem); + }); + + var players = new List(); + await RunOnMainThread(async () => + { + foreach (var player in _playerManager.Sessions) + { + var isAdmin = _adminManager.IsAdmin(player); + var isDeadmined = _adminManager.IsAdmin(player, true) && !isAdmin; + + players.Add(new Actor() + { + Guid = player.UserId.UserId.ToString(), + Name = player.Name, + IsAdmin = isAdmin, + IsDeadmined = isDeadmined + }); + } + }); + var gameRules = await RunOnMainThread(() => + { + var gameRules = new List(); + foreach (var addedGameRule in ticker.GetActiveGameRules()) + { + var meta = _entityManager.MetaQuery.GetComponent(addedGameRule); + gameRules.Add(meta.EntityPrototype?.ID ?? meta.EntityPrototype?.Name ?? "Unknown"); + } + + return gameRules; + }); + + _sawmill.Info($"Info requested by {actor!.Name} ({actor.Guid})."); + await context.RespondJsonAsync(new InfoResponse() + { + Players = players, + RoundId = ticker.RoundId, + Map = await RunOnMainThread(() => _gameMapManager.GetSelectedMap()?.MapName ?? "Unknown"), + PanicBunker = adminSystem.PanicBunker, + GamePreset = ticker.CurrentPreset?.ID, + GameRules = gameRules, + MOTD = _config.GetCVar(CCVars.MOTD) + }); + return true; + } + +#endregion + + private bool CheckAccess(IStatusHandlerContext context) + { + var auth = context.RequestHeaders.TryGetValue("Authorization", out var authToken); + if (!auth) + { + context.RespondJsonAsync(new BaseResponse() + { + Message = "An authorization header is required to perform this action.", + Exception = new ExceptionData() + { + Message = "An authorization header is required to perform this action.", + ErrorType = ErrorTypes.MissingAuthentication + } + }); + return false; + } + + + if (CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(authToken.ToString()), Encoding.UTF8.GetBytes(_token))) + return true; + + context.RespondJsonAsync(new BaseResponse() + { + Message = "Invalid authorization header.", + Exception = new ExceptionData() + { + Message = "Invalid authorization header.", + ErrorType = ErrorTypes.InvalidAuthentication + } + }); + // Invalid auth header, no access + _sawmill.Info("Unauthorized access attempt to admin API."); + return false; + } + + /// + /// Async helper function which runs a task on the main thread and returns the result. + /// + private async Task RunOnMainThread(Func func) + { + var taskCompletionSource = new TaskCompletionSource(); + _taskManager.RunOnMainThread(() => + { + try + { + taskCompletionSource.TrySetResult(func()); + } + catch (Exception e) + { + taskCompletionSource.TrySetException(e); + } + }); + + var result = await taskCompletionSource.Task; + return result; + } + + /// + /// Runs an action on the main thread. This does not return any value and is meant to be used for void functions. Use for functions that return a value. + /// + private async Task RunOnMainThread(Action action) + { + var taskCompletionSource = new TaskCompletionSource(); + _taskManager.RunOnMainThread(() => + { + try + { + action(); + taskCompletionSource.TrySetResult(true); + } + catch (Exception e) + { + taskCompletionSource.TrySetException(e); + } + }); + + await taskCompletionSource.Task; + } + + private async Task<(bool, Actor? actor)> CheckActor(IStatusHandlerContext context) + { + // The actor is JSON encoded in the header + var actor = context.RequestHeaders.TryGetValue("Actor", out var actorHeader) ? actorHeader.ToString() : null; + if (actor != null) + { + var actionData = JsonSerializer.Deserialize(actor); + if (actionData == null) + { + await context.RespondJsonAsync(new BaseResponse() + { + Message = "Unable to parse actor.", + Exception = new ExceptionData() + { + Message = "Unable to parse actor.", + ErrorType = ErrorTypes.BodyUnableToParse + } + }, HttpStatusCode.BadRequest); + return (false, null); + } + // Check if the actor is valid, like if all the required fields are present + if (string.IsNullOrWhiteSpace(actionData.Guid) || string.IsNullOrWhiteSpace(actionData.Name)) + { + await context.RespondJsonAsync(new BaseResponse() + { + Message = "Invalid actor supplied.", + Exception = new ExceptionData() + { + Message = "Invalid actor supplied.", + ErrorType = ErrorTypes.InvalidActor + } + }, HttpStatusCode.BadRequest); + return (false, null); + } + + // See if the parsed GUID is a valid GUID + if (!Guid.TryParse(actionData.Guid, out _)) + { + await context.RespondJsonAsync(new BaseResponse() + { + Message = "Invalid GUID supplied.", + Exception = new ExceptionData() + { + Message = "Invalid GUID supplied.", + ErrorType = ErrorTypes.InvalidActor + } + }, HttpStatusCode.BadRequest); + return (false, null); + } + + return (true, actionData); + } + + await context.RespondJsonAsync(new BaseResponse() + { + Message = "An actor is required to perform this action.", + Exception = new ExceptionData() + { + Message = "An actor is required to perform this action.", + ErrorType = ErrorTypes.MissingActor + } + }, HttpStatusCode.BadRequest); + return (false, null); + } + + /// + /// Helper function to read JSON encoded data from the request body. + /// + private async Task ReadJson(IStatusHandlerContext context) + { + try + { + var json = await context.RequestBodyJsonAsync(); + return json; + } + catch (Exception e) + { + await context.RespondJsonAsync(new BaseResponse() + { + Message = "Unable to parse request body.", + Exception = new ExceptionData() + { + Message = e.Message, + ErrorType = ErrorTypes.BodyUnableToParse, + StackTrace = e.StackTrace + } + }, HttpStatusCode.BadRequest); + return default; + } + } + +#region From Client + + private record Actor + { + public string? Guid { get; init; } + public string? Name { get; init; } + public bool IsAdmin { get; init; } = false; + public bool IsDeadmined { get; init; } = false; + } + + private record KickActionBody + { + public string? Guid { get; init; } + public string? Reason { get; init; } + } + + private record GameRuleActionBody + { + public string? GameRuleId { get; init; } + } + + private record PresetActionBody + { + public string? PresetId { get; init; } + } + + private record MotdActionBody + { + public string? Motd { get; init; } + } + +#endregion + +#region Responses + + private record BaseResponse + { + public string? Message { get; init; } = "OK"; + public ExceptionData? Exception { get; init; } = null; + } + + private record ExceptionData + { + public string Message { get; init; } = string.Empty; + public ErrorTypes ErrorType { get; init; } = ErrorTypes.None; + public string? StackTrace { get; init; } = null; + } + + private enum ErrorTypes + { + BodyUnableToParse = -2, + None = -1, + MissingAuthentication = 0, + InvalidAuthentication = 1, + MissingActor = 2, + InvalidActor = 3, + RoundNotInProgress = 4, + RoundAlreadyStarted = 5, + RoundAlreadyEnded = 6, + ActionNotSpecified = 7, + ActionNotSupported = 8, + GuidNotSpecified = 9, + PlayerNotFound = 10, + GameRuleNotFound = 11, + PresetNotSpecified = 12, + MotdNotSpecified = 13 + } + +#endregion + +#region Misc + + /// + /// Record used to send the response for the info endpoint. + /// + private record InfoResponse + { + public int RoundId { get; init; } = 0; + public List Players { get; init; } = new(); + public List GameRules { get; init; } = new(); + public string? GamePreset { get; init; } = null; + public string? Map { get; init; } = null; + public string? MOTD { get; init; } = null; + public PanicBunkerStatus PanicBunker { get; init; } = new(); + } + + private record PresetResponse : BaseResponse + { + public List<(string id, string desc)> Presets { get; init; } = new(); + } + + private record GameruleResponse : BaseResponse + { + public List GameRules { get; init; } = new(); + } + +#endregion + +} diff --git a/Content.Server/Administration/Systems/AdminSystem.cs b/Content.Server/Administration/Systems/AdminSystem.cs index c3c024174a..b7ca4e915b 100644 --- a/Content.Server/Administration/Systems/AdminSystem.cs +++ b/Content.Server/Administration/Systems/AdminSystem.cs @@ -61,7 +61,7 @@ namespace Content.Server.Administration.Systems public IReadOnlySet RoundActivePlayers => _roundActivePlayers; private readonly HashSet _roundActivePlayers = new(); - private readonly PanicBunkerStatus _panicBunker = new(); + public readonly PanicBunkerStatus PanicBunker = new(); public override void Initialize() { @@ -240,7 +240,7 @@ namespace Content.Server.Administration.Systems private void OnPanicBunkerChanged(bool enabled) { - _panicBunker.Enabled = enabled; + PanicBunker.Enabled = enabled; _chat.SendAdminAlert(Loc.GetString(enabled ? "admin-ui-panic-bunker-enabled-admin-alert" : "admin-ui-panic-bunker-disabled-admin-alert" @@ -251,52 +251,52 @@ namespace Content.Server.Administration.Systems private void OnPanicBunkerDisableWithAdminsChanged(bool enabled) { - _panicBunker.DisableWithAdmins = enabled; + PanicBunker.DisableWithAdmins = enabled; UpdatePanicBunker(); } private void OnPanicBunkerEnableWithoutAdminsChanged(bool enabled) { - _panicBunker.EnableWithoutAdmins = enabled; + PanicBunker.EnableWithoutAdmins = enabled; UpdatePanicBunker(); } private void OnPanicBunkerCountDeadminnedAdminsChanged(bool enabled) { - _panicBunker.CountDeadminnedAdmins = enabled; + PanicBunker.CountDeadminnedAdmins = enabled; UpdatePanicBunker(); } private void OnShowReasonChanged(bool enabled) { - _panicBunker.ShowReason = enabled; + PanicBunker.ShowReason = enabled; SendPanicBunkerStatusAll(); } private void OnPanicBunkerMinAccountAgeChanged(int minutes) { - _panicBunker.MinAccountAgeHours = minutes / 60; + PanicBunker.MinAccountAgeHours = minutes / 60; SendPanicBunkerStatusAll(); } private void OnPanicBunkerMinOverallHoursChanged(int hours) { - _panicBunker.MinOverallHours = hours; + PanicBunker.MinOverallHours = hours; SendPanicBunkerStatusAll(); } private void UpdatePanicBunker() { - var admins = _panicBunker.CountDeadminnedAdmins + var admins = PanicBunker.CountDeadminnedAdmins ? _adminManager.AllAdmins : _adminManager.ActiveAdmins; var hasAdmins = admins.Any(); - if (hasAdmins && _panicBunker.DisableWithAdmins) + if (hasAdmins && PanicBunker.DisableWithAdmins) { _config.SetCVar(CCVars.PanicBunkerEnabled, false); } - else if (!hasAdmins && _panicBunker.EnableWithoutAdmins) + else if (!hasAdmins && PanicBunker.EnableWithoutAdmins) { _config.SetCVar(CCVars.PanicBunkerEnabled, true); } @@ -306,7 +306,7 @@ namespace Content.Server.Administration.Systems private void SendPanicBunkerStatusAll() { - var ev = new PanicBunkerChangedEvent(_panicBunker); + var ev = new PanicBunkerChangedEvent(PanicBunker); foreach (var admin in _adminManager.AllAdmins) { RaiseNetworkEvent(ev, admin); diff --git a/Content.Server/Entry/EntryPoint.cs b/Content.Server/Entry/EntryPoint.cs index bf7f3ea84a..3cdf3bfe8e 100644 --- a/Content.Server/Entry/EntryPoint.cs +++ b/Content.Server/Entry/EntryPoint.cs @@ -102,6 +102,7 @@ namespace Content.Server.Entry IoCManager.Resolve().Initialize(); IoCManager.Resolve().Initialize(); IoCManager.Resolve().Initialize(); + IoCManager.Resolve().Initialize(); _voteManager.Initialize(); _updateManager.Initialize(); @@ -167,6 +168,7 @@ namespace Content.Server.Entry { _playTimeTracking?.Shutdown(); _dbManager?.Shutdown(); + IoCManager.Resolve().Shutdown(); } private static void LoadConfigPresets(IConfigurationManager cfg, IResourceManager res, ISawmill sawmill) diff --git a/Content.Server/IoC/ServerContentIoC.cs b/Content.Server/IoC/ServerContentIoC.cs index 2a63ace8e3..25bb1072a5 100644 --- a/Content.Server/IoC/ServerContentIoC.cs +++ b/Content.Server/IoC/ServerContentIoC.cs @@ -58,6 +58,7 @@ namespace Content.Server.IoC IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); } } } diff --git a/Content.Shared/CCVar/CCVars.cs b/Content.Shared/CCVar/CCVars.cs index c9271331f3..aa14c565a0 100644 --- a/Content.Shared/CCVar/CCVars.cs +++ b/Content.Shared/CCVar/CCVars.cs @@ -773,6 +773,13 @@ namespace Content.Shared.CCVar public static readonly CVarDef AdminAnnounceLogout = CVarDef.Create("admin.announce_logout", true, CVar.SERVERONLY); + /// + /// The token used to authenticate with the admin API. Leave empty to disable the admin API. This is a secret! Do not share! + /// + public static readonly CVarDef AdminApiToken = + CVarDef.Create("admin.api_token", string.Empty, CVar.SERVERONLY | CVar.CONFIDENTIAL); + + /// /// Should users be able to see their own notes? Admins will be able to see and set notes regardless /// -- 2.51.2