* Revert "Revert "Game server api (#23129)"" * Review pt.1 * Reviews pt.2 * Reviews pt. 3 * Reviews pt. 4
1034 lines
34 KiB
C#
1034 lines
34 KiB
C#
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<string, string> 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
|
|
|
|
/// <summary>
|
|
/// Changes the panic bunker settings.
|
|
/// </summary>
|
|
private async Task<bool> 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<Dictionary<string, object>>(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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the current MOTD.
|
|
/// </summary>
|
|
private async Task<bool> 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<MotdActionBody>(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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Forces the next preset-
|
|
/// </summary>
|
|
private async Task<bool> 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<PresetActionBody>(context);
|
|
var (success, actor) = await CheckActor(context);
|
|
if (!success)
|
|
return true;
|
|
|
|
var ticker = await RunOnMainThread(() => _entitySystemManager.GetEntitySystem<GameTicker>());
|
|
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ends an active game rule.
|
|
/// </summary>
|
|
private async Task<bool> 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<GameRuleActionBody>(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<GameTicker>());
|
|
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a game rule to the current round.
|
|
/// </summary>
|
|
private async Task<bool> 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<GameRuleActionBody>(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<EntityUid?>(() =>
|
|
{
|
|
var ticker = _entitySystemManager.GetEntitySystem<GameTicker>();
|
|
// 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Kicks a player.
|
|
/// </summary>
|
|
private async Task<bool> 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<KickActionBody>(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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round restart/end
|
|
/// </summary>
|
|
private async Task<bool> 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<GameTicker>();
|
|
var roundEndSystem = _entitySystemManager.GetEntitySystem<RoundEndSystem>();
|
|
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
|
|
|
|
/// <summary>
|
|
/// Returns an array containing all available presets.
|
|
/// </summary>
|
|
private async Task<bool> 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<GamePresetPrototype>())
|
|
{
|
|
presets.Add((preset.ID, preset.Description));
|
|
}
|
|
|
|
await context.RespondJsonAsync(new PresetResponse()
|
|
{
|
|
Presets = presets
|
|
});
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns an array containing all game rules.
|
|
/// </summary>
|
|
private async Task<bool> 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<string>();
|
|
foreach (var gameRule in _prototypeManager.EnumeratePrototypes<EntityPrototype>())
|
|
{
|
|
if (gameRule.Abstract)
|
|
continue;
|
|
|
|
if (gameRule.HasComponent<GameRuleComponent>(_componentFactory))
|
|
gameRules.Add(gameRule.ID);
|
|
}
|
|
|
|
await context.RespondJsonAsync(new GameruleResponse()
|
|
{
|
|
GameRules = gameRules
|
|
});
|
|
return true;
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Handles fetching information.
|
|
/// </summary>
|
|
private async Task<bool> 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<GameTicker>();
|
|
var adminSystem = _entitySystemManager.GetEntitySystem<AdminSystem>();
|
|
return (ticker, adminSystem);
|
|
});
|
|
|
|
var players = new List<Actor>();
|
|
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<string>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Async helper function which runs a task on the main thread and returns the result.
|
|
/// </summary>
|
|
private async Task<T> RunOnMainThread<T>(Func<T> func)
|
|
{
|
|
var taskCompletionSource = new TaskCompletionSource<T>();
|
|
_taskManager.RunOnMainThread(() =>
|
|
{
|
|
try
|
|
{
|
|
taskCompletionSource.TrySetResult(func());
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
taskCompletionSource.TrySetException(e);
|
|
}
|
|
});
|
|
|
|
var result = await taskCompletionSource.Task;
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs an action on the main thread. This does not return any value and is meant to be used for void functions. Use <see cref="RunOnMainThread{T}"/> for functions that return a value.
|
|
/// </summary>
|
|
private async Task RunOnMainThread(Action action)
|
|
{
|
|
var taskCompletionSource = new TaskCompletionSource<bool>();
|
|
_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>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Helper function to read JSON encoded data from the request body.
|
|
/// </summary>
|
|
private async Task<T?> ReadJson<T>(IStatusHandlerContext context)
|
|
{
|
|
try
|
|
{
|
|
var json = await context.RequestBodyJsonAsync<T>();
|
|
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
|
|
|
|
/// <summary>
|
|
/// Record used to send the response for the info endpoint.
|
|
/// </summary>
|
|
private record InfoResponse
|
|
{
|
|
public int RoundId { get; init; } = 0;
|
|
public List<Actor> Players { get; init; } = new();
|
|
public List<string> 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<string> GameRules { get; init; } = new();
|
|
}
|
|
|
|
#endregion
|
|
|
|
}
|