diff --git a/build/BuildDox.ps1 b/build/BuildDox.ps1 index e41968ebb8..a88025e3d7 100644 --- a/build/BuildDox.ps1 +++ b/build/BuildDox.ps1 @@ -25,6 +25,9 @@ if($publish_dox){ git config user.email "tgstation-server@tgstation13.org" echo '# THIS BRANCH IS AUTO GENERATED BY APPVEYOR CI' > README.md + # Add in the swagger specification + mv C:/swagger.json "$doxdir/swagger.json" + # Need to create a .nojekyll file to allow filenames starting with an underscore # to be seen on the gh-pages site. Therefore creating an empty .nojekyll file. echo "" > .nojekyll diff --git a/docs/API.dox b/docs/API.dox index 15416efd79..532597365d 100644 --- a/docs/API.dox +++ b/docs/API.dox @@ -3,6 +3,14 @@ @tableofcontents +@section api_swag OpenAPI Spec + +TGS4 has a, from code, generated OpenAPI 3.0 specification. It is much more authorative than these documents. + +The most up to date version should be found here: https://raw.githubusercontent.com/tgstation/tgstation-server/gh-pages/swagger.json + +You can use the API explorer SwaggerUI to interact with it: https://petstore.swagger.io + @section api_intro Introduction The TGS4 API is designed to be a fully realized RESTful service. Once hosted, follow the specified protocol for developing new clients or one off requests that provide full control over the server diff --git a/src/Tgstation.Server.Api/ApiHeaders.cs b/src/Tgstation.Server.Api/ApiHeaders.cs index b1be91ff6f..ff31d48e6d 100644 --- a/src/Tgstation.Server.Api/ApiHeaders.cs +++ b/src/Tgstation.Server.Api/ApiHeaders.cs @@ -7,6 +7,7 @@ using System.Globalization; using System.Linq; using System.Net.Http.Headers; using System.Reflection; +using System.Text; namespace Tgstation.Server.Api { @@ -23,27 +24,32 @@ namespace Tgstation.Server.Api /// /// The header key /// - const string ApiVersionHeader = "Api"; - - /// - /// The header key - /// - const string UsernameHeader = "Username"; + public const string ApiVersionHeader = "api"; /// /// The header key /// - const string InstanceIdHeader = "Instance"; + public const string InstanceIdHeader = "instance"; /// /// The JWT authentication header scheme /// - const string JwtAuthenticationScheme = "Bearer"; + public const string JwtAuthenticationScheme = "bearer"; /// - /// The password authentication header scheme + /// The JWT authentication header scheme /// - const string PasswordAuthenticationScheme = "Password"; + public const string BasicAuthenticationScheme = "basic"; + + /// + /// The header key + /// + const string UsernameHeader = "username"; + + /// + /// The basic authentication header scheme + /// + const string PasswordAuthenticationScheme = "password"; /// /// The current @@ -180,7 +186,9 @@ namespace Tgstation.Server.Api InstanceId = instanceId; } - switch (scheme) +#pragma warning disable CA1308 // Normalize strings to uppercase + switch (scheme.ToLowerInvariant()) +#pragma warning restore CA1308 // Normalize strings to uppercase { case JwtAuthenticationScheme: Token = parameter; @@ -197,6 +205,25 @@ namespace Tgstation.Server.Api if (fail) throw new InvalidOperationException("Missing Username header!"); break; + case BasicAuthenticationScheme: + string joinedString; + try + { + var base64Bytes = Convert.FromBase64String(parameter); + joinedString = Encoding.UTF8.GetString(base64Bytes); + } + catch + { + throw new InvalidOperationException("Invalid basic Authorization header!"); + } + + var basicAuthSplits = joinedString.Split(new char[] { ':' }, StringSplitOptions.RemoveEmptyEntries); + if (basicAuthSplits.Length < 2) + throw new InvalidOperationException("Invalid basic Authorization header!"); + + Username = basicAuthSplits.First(); + Password = String.Concat(basicAuthSplits.Skip(1)); + break; default: throw new InvalidOperationException("Invalid authentication scheme!"); } @@ -241,10 +268,9 @@ namespace Tgstation.Server.Api if (IsTokenAuthentication) headers.Authorization = new AuthenticationHeaderValue(JwtAuthenticationScheme, Token); else - { - headers.Authorization = new AuthenticationHeaderValue(PasswordAuthenticationScheme, Password); - headers.Add(UsernameHeader, Username); - } + headers.Authorization = new AuthenticationHeaderValue( + BasicAuthenticationScheme, + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Username}:{Password}"))); headers.UserAgent.Add(new ProductInfoHeaderValue(UserAgent)); headers.Add(ApiVersionHeader, new ProductHeaderValue(AssemblyName.Name, ApiVersion.ToString()).ToString()); diff --git a/src/Tgstation.Server.Api/Models/EntityId.cs b/src/Tgstation.Server.Api/Models/EntityId.cs new file mode 100644 index 0000000000..879b3b2ec8 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/EntityId.cs @@ -0,0 +1,13 @@ +namespace Tgstation.Server.Api.Models +{ + /// + /// Base of s. + /// + public class EntityId + { + /// + /// The ID of the entity. + /// + public long Id { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/Internal/CompileJob.cs b/src/Tgstation.Server.Api/Models/Internal/CompileJob.cs index c659c49737..204657b883 100644 --- a/src/Tgstation.Server.Api/Models/Internal/CompileJob.cs +++ b/src/Tgstation.Server.Api/Models/Internal/CompileJob.cs @@ -6,13 +6,8 @@ namespace Tgstation.Server.Api.Models.Internal /// /// Represents a run of /// - public class CompileJob + public class CompileJob : EntityId { - /// - /// The ID of the job - /// - public long Id { get; set; } - /// /// The .dme file used for compilation /// diff --git a/src/Tgstation.Server.Api/Models/Internal/Job.cs b/src/Tgstation.Server.Api/Models/Internal/Job.cs index e64bb68115..945d0d6aaa 100644 --- a/src/Tgstation.Server.Api/Models/Internal/Job.cs +++ b/src/Tgstation.Server.Api/Models/Internal/Job.cs @@ -7,13 +7,8 @@ namespace Tgstation.Server.Api.Models.Internal /// /// Represents a long running job /// - public class Job + public class Job : EntityId { - /// - /// The ID - /// - public long Id { get; set; } - /// /// English description of the /// diff --git a/src/Tgstation.Server.Api/Rights/RightsHelper.cs b/src/Tgstation.Server.Api/Rights/RightsHelper.cs index 6df923331e..52947aaa6d 100644 --- a/src/Tgstation.Server.Api/Rights/RightsHelper.cs +++ b/src/Tgstation.Server.Api/Rights/RightsHelper.cs @@ -60,7 +60,7 @@ namespace Tgstation.Server.Api.Rights /// A representing the claim role name public static string RoleName(RightsType rightsType, Enum right) { - var enumType = TypeMap[rightsType]; + var enumType = RightToType(rightsType); return String.Concat(enumType.Name, '.', Enum.GetName(enumType, right)); } diff --git a/src/Tgstation.Server.Api/Routes.cs b/src/Tgstation.Server.Api/Routes.cs index 2ba4b4b19b..6a2da6c8f0 100644 --- a/src/Tgstation.Server.Api/Routes.cs +++ b/src/Tgstation.Server.Api/Routes.cs @@ -78,6 +78,11 @@ namespace Tgstation.Server.Api /// public const string Jobs = Root + nameof(Models.Job); + /// + /// The postfix for list operations + /// + public const string List = "List"; + /// /// Apply an postfix to a /// @@ -91,6 +96,6 @@ namespace Tgstation.Server.Api /// /// The route /// The with /List appended - public static string List(string route) => String.Format(CultureInfo.InvariantCulture, "{0}/List", route); + public static string ListRoute(string route) => String.Format(CultureInfo.InvariantCulture, "{0}/{1}", route, List); } } diff --git a/src/Tgstation.Server.Client/ApiClient.cs b/src/Tgstation.Server.Client/ApiClient.cs index 3d01a67871..b6ae18e747 100644 --- a/src/Tgstation.Server.Client/ApiClient.cs +++ b/src/Tgstation.Server.Client/ApiClient.cs @@ -112,7 +112,11 @@ namespace Tgstation.Server.Client throw new MethodNotSupportedException(); case HttpStatusCode.InternalServerError: // response json is html - throw new ServerErrorException(json); + throw new ServerErrorException(errorMessage ?? new ErrorMessage + { + Message = "An internal server error occurred!", + SeverApiVersion = null + }, response.StatusCode); case (HttpStatusCode)429: // rate limited response.Headers.TryGetValues("Retry-After", out var values); diff --git a/src/Tgstation.Server.Client/Components/ByondClient.cs b/src/Tgstation.Server.Client/Components/ByondClient.cs index f3675dc265..23d56dc64a 100644 --- a/src/Tgstation.Server.Client/Components/ByondClient.cs +++ b/src/Tgstation.Server.Client/Components/ByondClient.cs @@ -35,7 +35,7 @@ namespace Tgstation.Server.Client.Components public Task ActiveVersion(CancellationToken cancellationToken) => apiClient.Read(Routes.Byond, instance.Id, cancellationToken); /// - public Task> InstalledVersions(CancellationToken cancellationToken) => apiClient.Read>(Routes.List(Routes.Byond), instance.Id, cancellationToken); + public Task> InstalledVersions(CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.Byond), instance.Id, cancellationToken); /// public Task SetActiveVersion(Byond byond, CancellationToken cancellationToken) => apiClient.Update(Routes.Byond, byond ?? throw new ArgumentNullException(nameof(byond)), instance.Id, cancellationToken); diff --git a/src/Tgstation.Server.Client/Components/ChatBotsClient.cs b/src/Tgstation.Server.Client/Components/ChatBotsClient.cs index f7890f1f22..5a05e8c930 100644 --- a/src/Tgstation.Server.Client/Components/ChatBotsClient.cs +++ b/src/Tgstation.Server.Client/Components/ChatBotsClient.cs @@ -38,7 +38,7 @@ namespace Tgstation.Server.Client.Components public Task Delete(ChatBot settings, CancellationToken cancellationToken) => apiClient.Delete(Routes.SetID(Routes.Chat, settings?.Id ?? throw new ArgumentNullException(nameof(settings))), instance.Id, cancellationToken); /// - public Task> List(CancellationToken cancellationToken) => apiClient.Read>(Routes.List(Routes.Chat), instance.Id, cancellationToken); + public Task> List(CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.Chat), instance.Id, cancellationToken); /// public Task Update(ChatBot settings, CancellationToken cancellationToken) => apiClient.Update(Routes.Chat, settings ?? throw new ArgumentNullException(nameof(settings)), instance.Id, cancellationToken); diff --git a/src/Tgstation.Server.Client/Components/ConfigurationClient.cs b/src/Tgstation.Server.Client/Components/ConfigurationClient.cs index 54dc6acf39..4063e73963 100644 --- a/src/Tgstation.Server.Client/Components/ConfigurationClient.cs +++ b/src/Tgstation.Server.Client/Components/ConfigurationClient.cs @@ -52,7 +52,7 @@ namespace Tgstation.Server.Client.Components public Task CreateDirectory(ConfigurationFile directory, CancellationToken cancellationToken) => apiClient.Create(Routes.Configuration, directory, instance.Id, cancellationToken); /// - public Task> List(string directory, CancellationToken cancellationToken) => apiClient.Read>(Routes.List(Routes.Configuration) + SanitizeGetPath(directory), instance.Id, cancellationToken); + public Task> List(string directory, CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.Configuration) + SanitizeGetPath(directory), instance.Id, cancellationToken); /// public Task Read(ConfigurationFile file, CancellationToken cancellationToken) diff --git a/src/Tgstation.Server.Client/Components/DreamMakerClient.cs b/src/Tgstation.Server.Client/Components/DreamMakerClient.cs index 942d252812..dafa049834 100644 --- a/src/Tgstation.Server.Client/Components/DreamMakerClient.cs +++ b/src/Tgstation.Server.Client/Components/DreamMakerClient.cs @@ -35,10 +35,10 @@ namespace Tgstation.Server.Client.Components public Task Compile(CancellationToken cancellationToken) => apiClient.Create(Routes.DreamMaker, instance.Id, cancellationToken); /// - public Task GetCompileJob(CompileJob compileJob, CancellationToken cancellationToken) => apiClient.Read(Routes.SetID(Routes.DreamMaker, compileJob?.Id ?? throw new ArgumentNullException(nameof(compileJob))), instance.Id, cancellationToken); + public Task GetCompileJob(EntityId compileJob, CancellationToken cancellationToken) => apiClient.Read(Routes.SetID(Routes.DreamMaker, compileJob?.Id ?? throw new ArgumentNullException(nameof(compileJob))), instance.Id, cancellationToken); /// - public Task> GetJobIds(CancellationToken cancellationToken) => apiClient.Read>(Routes.List(Routes.DreamMaker), instance.Id, cancellationToken); + public Task> GetJobIds(CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.DreamMaker), instance.Id, cancellationToken); /// public Task Read(CancellationToken cancellationToken) => apiClient.Read(Routes.DreamMaker, instance.Id, cancellationToken); diff --git a/src/Tgstation.Server.Client/Components/IDreamMakerClient.cs b/src/Tgstation.Server.Client/Components/IDreamMakerClient.cs index 011475d5a5..174634bdb4 100644 --- a/src/Tgstation.Server.Client/Components/IDreamMakerClient.cs +++ b/src/Tgstation.Server.Client/Components/IDreamMakerClient.cs @@ -33,18 +33,18 @@ namespace Tgstation.Server.Client.Components Task Compile(CancellationToken cancellationToken); /// - /// Gets the s of all s for the instance + /// Gets the s of all s for the instance /// /// The for the operation - /// A resulting in a of s with only the field populated - Task> GetJobIds(CancellationToken cancellationToken); + /// A resulting in a of s. + Task> GetJobIds(CancellationToken cancellationToken); /// /// Get a /// - /// The to get + /// The to get /// The for the operation /// A resulting in the - Task GetCompileJob(CompileJob compileJob, CancellationToken cancellationToken); + Task GetCompileJob(EntityId compileJob, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Client/Components/IJobsClient.cs b/src/Tgstation.Server.Client/Components/IJobsClient.cs index 8ce64a95e0..ae879f485e 100644 --- a/src/Tgstation.Server.Client/Components/IJobsClient.cs +++ b/src/Tgstation.Server.Client/Components/IJobsClient.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; @@ -12,11 +11,11 @@ namespace Tgstation.Server.Client.Components public interface IJobsClient { /// - /// List the s in the + /// List the s in the /// /// The for the operation - /// A resulting in a of the s in the - Task> List(CancellationToken cancellationToken); + /// A resulting in a of the s in the + Task> List(CancellationToken cancellationToken); /// /// List the active s in the @@ -28,10 +27,10 @@ namespace Tgstation.Server.Client.Components /// /// Get a /// - /// The to get + /// The 's to get /// The for the operation /// A resulting in the - Task GetId(Job job, CancellationToken cancellationToken); + Task GetId(EntityId job, CancellationToken cancellationToken); /// /// Cancels a diff --git a/src/Tgstation.Server.Client/Components/InstanceUserClient.cs b/src/Tgstation.Server.Client/Components/InstanceUserClient.cs index 440340b6c4..cc9757f00b 100644 --- a/src/Tgstation.Server.Client/Components/InstanceUserClient.cs +++ b/src/Tgstation.Server.Client/Components/InstanceUserClient.cs @@ -44,7 +44,7 @@ namespace Tgstation.Server.Client.Components public Task Update(InstanceUser instanceUser, CancellationToken cancellationToken) => apiClient.Update(Routes.InstanceUser, instanceUser ?? throw new ArgumentNullException(nameof(instanceUser)), instance.Id, cancellationToken); /// - public Task> List(CancellationToken cancellationToken) => apiClient.Read>(Routes.List(Routes.InstanceUser), instance.Id, cancellationToken); + public Task> List(CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.InstanceUser), instance.Id, cancellationToken); /// public Task GetId(InstanceUser instanceUser, CancellationToken cancellationToken) => apiClient.Read(Routes.SetID(Routes.InstanceUser, instanceUser?.UserId ?? throw new ArgumentNullException(nameof(instanceUser))), instance.Id, cancellationToken); diff --git a/src/Tgstation.Server.Client/Components/JobsClient.cs b/src/Tgstation.Server.Client/Components/JobsClient.cs index d6c421e65d..228a2c4a9b 100644 --- a/src/Tgstation.Server.Client/Components/JobsClient.cs +++ b/src/Tgstation.Server.Client/Components/JobsClient.cs @@ -35,12 +35,12 @@ namespace Tgstation.Server.Client.Components public Task Cancel(Job job, CancellationToken cancellationToken) => apiClient.Delete(Routes.SetID(Routes.Jobs, job?.Id ?? throw new ArgumentNullException(nameof(job))), instance.Id, cancellationToken); /// - public Task> List(CancellationToken cancellationToken) => apiClient.Read>(Routes.List(Routes.Jobs), instance.Id, cancellationToken); + public Task> List(CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.Jobs), instance.Id, cancellationToken); /// public Task> ListActive(CancellationToken cancellationToken) => apiClient.Read>(Routes.Jobs, instance.Id, cancellationToken); /// - public Task GetId(Job job, CancellationToken cancellationToken) => apiClient.Read(Routes.SetID(Routes.Jobs, job?.Id ?? throw new ArgumentNullException(nameof(job))), instance.Id, cancellationToken); + public Task GetId(EntityId job, CancellationToken cancellationToken) => apiClient.Read(Routes.SetID(Routes.Jobs, job?.Id ?? throw new ArgumentNullException(nameof(job))), instance.Id, cancellationToken); } } \ No newline at end of file diff --git a/src/Tgstation.Server.Client/InstanceManagerClient.cs b/src/Tgstation.Server.Client/InstanceManagerClient.cs index 62d99de98d..aa5458f0e6 100644 --- a/src/Tgstation.Server.Client/InstanceManagerClient.cs +++ b/src/Tgstation.Server.Client/InstanceManagerClient.cs @@ -39,7 +39,7 @@ namespace Tgstation.Server.Client public Task Detach(Instance instance, CancellationToken cancellationToken) => apiClient.Delete(Routes.SetID(Routes.InstanceManager, instance?.Id ?? throw new ArgumentNullException(nameof(instance))), cancellationToken); /// - public Task> List(CancellationToken cancellationToken) => apiClient.Read>(Routes.List(Routes.InstanceManager), cancellationToken); + public Task> List(CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.InstanceManager), cancellationToken); /// public Task Update(Instance instance, CancellationToken cancellationToken) => apiClient.Update(Routes.InstanceManager, instance ?? throw new ArgumentNullException(nameof(instance)), cancellationToken); diff --git a/src/Tgstation.Server.Client/ServerErrorException.cs b/src/Tgstation.Server.Client/ServerErrorException.cs index 940f3513a7..24794c8027 100644 --- a/src/Tgstation.Server.Client/ServerErrorException.cs +++ b/src/Tgstation.Server.Client/ServerErrorException.cs @@ -9,27 +9,18 @@ namespace Tgstation.Server.Client /// public sealed class ServerErrorException : ClientException { - /// - /// The raw HTML of the error - /// - public string Html { get; } - /// /// Construct an /// public ServerErrorException() { } /// - /// Construct an with + /// Construct an with a given /// - /// The raw HTML response of the - public ServerErrorException(string html) : base(new ErrorMessage + /// The for the + /// The for the + public ServerErrorException(ErrorMessage errorMessage, HttpStatusCode statusCode) : base(errorMessage, statusCode) { - Message = "An internal server error occurred!", - SeverApiVersion = null - }, HttpStatusCode.InternalServerError) - { - Html = html; } /// diff --git a/src/Tgstation.Server.Client/UsersClient.cs b/src/Tgstation.Server.Client/UsersClient.cs index 1e6c5fd45e..9d4aa53de6 100644 --- a/src/Tgstation.Server.Client/UsersClient.cs +++ b/src/Tgstation.Server.Client/UsersClient.cs @@ -31,7 +31,7 @@ namespace Tgstation.Server.Client public Task GetId(User user, CancellationToken cancellationToken) => apiClient.Read(Routes.SetID(Routes.User, user?.Id ?? throw new ArgumentNullException(nameof(user))), cancellationToken); /// - public Task> List(CancellationToken cancellationToken) => apiClient.Read>(Routes.List(Routes.User), cancellationToken); + public Task> List(CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.User), cancellationToken); /// public Task Read(CancellationToken cancellationToken) => apiClient.Read(Routes.User, cancellationToken); diff --git a/src/Tgstation.Server.Host/Components/Byond/ByondInstallerBase.cs b/src/Tgstation.Server.Host/Components/Byond/ByondInstallerBase.cs new file mode 100644 index 0000000000..7d7c98bf9c --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Byond/ByondInstallerBase.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Host.IO; + +namespace Tgstation.Server.Host.Components.Byond +{ + /// + abstract class ByondInstallerBase : IByondInstaller + { + /// + public abstract string DreamDaemonName { get; } + + /// + public abstract string DreamMakerName { get; } + + /// + /// Gets the URL formatter string for downloading a byond version of {0:Major} {1:Minor}. + /// + protected abstract string ByondRevisionsURLTemplate { get; } + + /// + /// Gets the for the . + /// + protected IIOManager IOManager { get; } + + /// + /// Gets the for the . + /// + protected ILogger Logger { get; } + + /// + /// Initializes a new instance of the . + /// + /// The value of . + /// The value of . + protected ByondInstallerBase(IIOManager ioManager, ILogger logger) + { + IOManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public abstract Task CleanCache(CancellationToken cancellationToken); + + /// + public abstract Task InstallByond(string path, Version version, CancellationToken cancellationToken); + + /// + public Task DownloadVersion(Version version, CancellationToken cancellationToken) + { + if (version == null) + throw new ArgumentNullException(nameof(version)); + + var url = String.Format(CultureInfo.InvariantCulture, ByondRevisionsURLTemplate, version.Major, version.Minor); + + Logger.LogTrace("Downloading from: {0}", url); + + return IOManager.DownloadFile(new Uri(url), cancellationToken); + } + } +} diff --git a/src/Tgstation.Server.Host/Components/Byond/PosixByondInstaller.cs b/src/Tgstation.Server.Host/Components/Byond/PosixByondInstaller.cs index 6d741bb952..8b42a813ee 100644 --- a/src/Tgstation.Server.Host/Components/Byond/PosixByondInstaller.cs +++ b/src/Tgstation.Server.Host/Components/Byond/PosixByondInstaller.cs @@ -11,13 +11,8 @@ namespace Tgstation.Server.Host.Components.Byond /// /// for Posix systems /// - sealed class PosixByondInstaller : IByondInstaller + sealed class PosixByondInstaller : ByondInstallerBase { - /// - /// The URL format string for getting BYOND linux version {0}.{1} zipfile - /// - const string ByondRevisionsURLTemplate = "https://secure.byond.com/download/build/{0}/{0}.{1}_byond_linux.zip"; - /// /// Path to the BYOND cache /// @@ -28,45 +23,37 @@ namespace Tgstation.Server.Host.Components.Byond const string ShellScriptExtension = ".sh"; /// - public string DreamDaemonName => DreamDaemonExecutableName + ShellScriptExtension; + public override string DreamDaemonName => DreamDaemonExecutableName + ShellScriptExtension; /// - public string DreamMakerName => DreamMakerExecutableName + ShellScriptExtension; + public override string DreamMakerName => DreamMakerExecutableName + ShellScriptExtension; - /// - /// The for the - /// - readonly IIOManager ioManager; + /// + protected override string ByondRevisionsURLTemplate => "https://secure.byond.com/download/build/{0}/{0}.{1}_byond_linux.zip"; /// /// The for the /// readonly IPostWriteHandler postWriteHandler; - /// - /// The for the - /// - readonly ILogger logger; - /// /// Construct a /// - /// The value of /// The value of - /// The value of - public PosixByondInstaller(IIOManager ioManager, IPostWriteHandler postWriteHandler, ILogger logger) + /// The for the . + /// The for the . + public PosixByondInstaller(IPostWriteHandler postWriteHandler, IIOManager ioManager, ILogger logger) + : base(ioManager, logger) { - this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); this.postWriteHandler = postWriteHandler ?? throw new ArgumentNullException(nameof(postWriteHandler)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// - public async Task CleanCache(CancellationToken cancellationToken) + public override async Task CleanCache(CancellationToken cancellationToken) { try { - await ioManager.DeleteDirectory(ByondCachePath, cancellationToken).ConfigureAwait(false); + await IOManager.DeleteDirectory(ByondCachePath, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -74,23 +61,12 @@ namespace Tgstation.Server.Host.Components.Byond } catch (Exception e) { - logger.LogWarning("Error deleting BYOND cache! Exception: {0}", e); + Logger.LogWarning("Error deleting BYOND cache! Exception: {0}", e); } } /// - public async Task DownloadVersion(Version version, CancellationToken cancellationToken) - { - if (version == null) - throw new ArgumentNullException(nameof(version)); - - var url = String.Format(CultureInfo.InvariantCulture, ByondRevisionsURLTemplate, version.Major, version.Minor); - - return await ioManager.DownloadFile(new Uri(url), cancellationToken).ConfigureAwait(false); - } - - /// - public Task InstallByond(string path, Version version, CancellationToken cancellationToken) + public override Task InstallByond(string path, Version version, CancellationToken cancellationToken) { if (path == null) throw new ArgumentNullException(nameof(path)); @@ -106,16 +82,16 @@ namespace Tgstation.Server.Host.Components.Byond async Task WriteAndMakeExecutable(string fullPath, string script) { - await ioManager.WriteAllBytes(fullPath, Encoding.ASCII.GetBytes(script), cancellationToken).ConfigureAwait(false); + await IOManager.WriteAllBytes(fullPath, Encoding.ASCII.GetBytes(script), cancellationToken).ConfigureAwait(false); postWriteHandler.HandleWrite(fullPath); } - var basePath = ioManager.ConcatPath(path, ByondManager.BinPath); + var basePath = IOManager.ConcatPath(path, ByondManager.BinPath); - var task = Task.WhenAll(WriteAndMakeExecutable(ioManager.ConcatPath(basePath, DreamDaemonName), dreamDaemonScript), WriteAndMakeExecutable(ioManager.ConcatPath(basePath, DreamMakerName), dreamMakerScript)); + var task = Task.WhenAll(WriteAndMakeExecutable(IOManager.ConcatPath(basePath, DreamDaemonName), dreamDaemonScript), WriteAndMakeExecutable(IOManager.ConcatPath(basePath, DreamMakerName), dreamMakerScript)); - postWriteHandler.HandleWrite(ioManager.ConcatPath(basePath, DreamDaemonExecutableName)); - postWriteHandler.HandleWrite(ioManager.ConcatPath(basePath, DreamMakerExecutableName)); + postWriteHandler.HandleWrite(IOManager.ConcatPath(basePath, DreamDaemonExecutableName)); + postWriteHandler.HandleWrite(IOManager.ConcatPath(basePath, DreamMakerExecutableName)); return task; } diff --git a/src/Tgstation.Server.Host/Components/Byond/WindowsByondInstaller.cs b/src/Tgstation.Server.Host/Components/Byond/WindowsByondInstaller.cs index 801b0468fc..4b48d6f0bf 100644 --- a/src/Tgstation.Server.Host/Components/Byond/WindowsByondInstaller.cs +++ b/src/Tgstation.Server.Host/Components/Byond/WindowsByondInstaller.cs @@ -12,13 +12,8 @@ namespace Tgstation.Server.Host.Components.Byond /// /// for windows systems /// - sealed class WindowsByondInstaller : IByondInstaller, IDisposable + sealed class WindowsByondInstaller : ByondInstallerBase, IDisposable { - /// - /// The URL format string for getting BYOND windows version {0}.{1} zipfile - /// - const string ByondRevisionsURLTemplate = "https://secure.byond.com/download/build/{0}/{0}.{1}_byond.zip"; - /// /// Directory to byond installation configuration /// @@ -40,26 +35,19 @@ namespace Tgstation.Server.Host.Components.Byond const string ByondDXDir = "byond/directx"; /// - public string DreamDaemonName => "dreamdaemon.exe"; + public override string DreamDaemonName => "dreamdaemon.exe"; /// - public string DreamMakerName => "dm.exe"; + public override string DreamMakerName => "dm.exe"; - /// - /// The for the - /// - readonly IIOManager ioManager; + /// + protected override string ByondRevisionsURLTemplate => "https://secure.byond.com/download/build/{0}/{0}.{1}_byond.zip"; /// /// The for the /// readonly IProcessExecutor processExecutor; - /// - /// The for the - /// - readonly ILogger logger; - /// /// The for the /// @@ -73,14 +61,13 @@ namespace Tgstation.Server.Host.Components.Byond /// /// Construct a /// - /// The value of /// The value of - /// The value of - public WindowsByondInstaller(IIOManager ioManager, IProcessExecutor processExecutor, ILogger logger) + /// The for the . + /// The for the . + public WindowsByondInstaller(IProcessExecutor processExecutor, IIOManager ioManager, ILogger logger) + : base(ioManager, logger) { - this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); this.processExecutor = processExecutor ?? throw new ArgumentNullException(nameof(processExecutor)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); semaphore = new SemaphoreSlim(1); installedDirectX = false; @@ -90,11 +77,11 @@ namespace Tgstation.Server.Host.Components.Byond public void Dispose() => semaphore.Dispose(); /// - public async Task CleanCache(CancellationToken cancellationToken) + public override async Task CleanCache(CancellationToken cancellationToken) { try { - await ioManager.DeleteDirectory(ioManager.ConcatPath(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "byond/cache"), cancellationToken).ConfigureAwait(false); + await IOManager.DeleteDirectory(IOManager.ConcatPath(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "byond/cache"), cancellationToken).ConfigureAwait(false); } catch(OperationCanceledException) { @@ -102,26 +89,18 @@ namespace Tgstation.Server.Host.Components.Byond } catch (Exception e) { - logger.LogWarning("Error deleting BYOND cache! Exception: {0}", e); + Logger.LogWarning("Error deleting BYOND cache! Exception: {0}", e); } } /// - public Task DownloadVersion(Version version, CancellationToken cancellationToken) - { - var url = String.Format(CultureInfo.InvariantCulture, ByondRevisionsURLTemplate, version.Major, version.Minor); - - return ioManager.DownloadFile(new Uri(url), cancellationToken); - } - - /// - public async Task InstallByond(string path, Version version, CancellationToken cancellationToken) + public override async Task InstallByond(string path, Version version, CancellationToken cancellationToken) { async Task SetNoPromptTrusted() { - var configPath = ioManager.ConcatPath(path, ByondConfigDir); - await ioManager.CreateDirectory(configPath, cancellationToken).ConfigureAwait(false); - await ioManager.WriteAllBytes(ioManager.ConcatPath(configPath, ByondDDConfig), Encoding.UTF8.GetBytes(ByondNoPromptTrustedMode), cancellationToken).ConfigureAwait(false); + var configPath = IOManager.ConcatPath(path, ByondConfigDir); + await IOManager.CreateDirectory(configPath, cancellationToken).ConfigureAwait(false); + await IOManager.WriteAllBytes(IOManager.ConcatPath(configPath, ByondDDConfig), Encoding.UTF8.GetBytes(ByondNoPromptTrustedMode), cancellationToken).ConfigureAwait(false); } var setNoPromptTrustedModeTask = SetNoPromptTrusted(); @@ -135,13 +114,13 @@ namespace Tgstation.Server.Host.Components.Byond { // ^check again because race conditions // always install it, it's pretty fast and will do better redundancy checking than us - var rbdx = ioManager.ConcatPath(path, ByondDXDir); + var rbdx = IOManager.ConcatPath(path, ByondDXDir); // noShellExecute because we aren't doing runas shennanigans IProcess directXInstaller; try { - directXInstaller = processExecutor.LaunchProcess(ioManager.ConcatPath(rbdx, "DXSETUP.exe"), rbdx, "/silent", noShellExecute: true); + directXInstaller = processExecutor.LaunchProcess(IOManager.ConcatPath(rbdx, "DXSETUP.exe"), rbdx, "/silent", noShellExecute: true); } catch (Exception e) { diff --git a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs index 61198121f6..def822cfb8 100644 --- a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs +++ b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs @@ -22,10 +22,10 @@ using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.Controllers { /// - /// for + /// for purposes /// [Route(Routes.Administration)] - public sealed class AdministrationController : ModelController + public sealed class AdministrationController : ApiController { const string RestartNotSupportedException = "This deployment of tgstation-server is lacking the Tgstation.Server.Host.Watchdog component. Restarts and version changes cannot be completed!"; @@ -79,7 +79,7 @@ namespace Tgstation.Server.Host.Controllers /// The for the /// The containing value of /// The containing value of - public AdministrationController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IGitHubClientFactory gitHubClientFactory, IServerControl serverUpdater, IApplication application, IIOManager ioManager, IPlatformIdentifier platformIdentifier, ILogger logger, IOptions updatesConfigurationOptions, IOptions generalConfigurationOptions) : base(databaseContext, authenticationContextFactory, logger, false) + public AdministrationController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IGitHubClientFactory gitHubClientFactory, IServerControl serverUpdater, IApplication application, IIOManager ioManager, IPlatformIdentifier platformIdentifier, ILogger logger, IOptions updatesConfigurationOptions, IOptions generalConfigurationOptions) : base(databaseContext, authenticationContextFactory, logger, false, true) { this.gitHubClientFactory = gitHubClientFactory ?? throw new ArgumentNullException(nameof(gitHubClientFactory)); this.serverUpdater = serverUpdater ?? throw new ArgumentNullException(nameof(serverUpdater)); @@ -152,9 +152,19 @@ namespace Tgstation.Server.Host.Controllers IGitHubClient GetGitHubClient() => String.IsNullOrEmpty(generalConfiguration.GitHubAccessToken) ? gitHubClientFactory.CreateClient() : gitHubClientFactory.CreateClient(generalConfiguration.GitHubAccessToken); - /// + /// + /// Get server information. + /// + /// A resulting in the for the operation. + /// Retrieved data successfully. + /// The GitHub API rate limit was hit. See response header Retry-After. + /// A GitHub API error occurred. See error message for details. + [HttpGet] [TgsAuthorize] - public override async Task Read(CancellationToken cancellationToken) + [ProducesResponseType(typeof(Administration), 200)] + [ProducesResponseType(424)] + [ProducesResponseType(typeof(ErrorMessage), 429)] + public async Task Read() { try { @@ -192,13 +202,24 @@ namespace Tgstation.Server.Host.Controllers catch (ApiException e) { Logger.LogWarning(OctokitException, e); - return StatusCode((int)HttpStatusCode.FailedDependency); + return StatusCode((int)HttpStatusCode.FailedDependency, new ErrorMessage + { + Message = e.Message + }); } } - /// + /// + /// Attempt to perform a server upgrade. + /// + /// The model containing the to update to. + /// The for the operation. + /// A resulting in the for the operation. + /// Upgrade operations are unavailable due to the launch configuration of TGS. + [HttpPost] [TgsAuthorize(AdministrationRights.ChangeVersion)] - public override async Task Update([FromBody] Administration model, CancellationToken cancellationToken) + [ProducesResponseType(typeof(ErrorMessage), 422)] + public async Task Update([FromBody] Administration model, CancellationToken cancellationToken) { if (model == null) throw new ArgumentNullException(nameof(model)); @@ -222,8 +243,12 @@ namespace Tgstation.Server.Host.Controllers /// Attempts to restart the server /// /// A resulting in the of the request - [HttpDelete] + /// Restart begun successfully. + /// Restart operations are unavailable due to the launch configuration of TGS. + [HttpDelete("{id}")] [TgsAuthorize(AdministrationRights.RestartHost)] + [ProducesResponseType(200)] + [ProducesResponseType(typeof(ErrorMessage), 422)] public async Task Delete() { try diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index 9565072343..c5804466aa 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -18,6 +18,7 @@ namespace Tgstation.Server.Host.Controllers /// A for API functions /// [Produces(ApiHeaders.ApplicationJson)] + [ApiController] public abstract class ApiController : Controller { /// @@ -129,25 +130,9 @@ namespace Tgstation.Server.Host.Controllers if (ModelState?.IsValid == false) { - var errorMessages = ModelState.SelectMany(x => x.Value.Errors).Select(x => x.ErrorMessage).ToList(); - - // HACK - // do some fuckery to remove RequiredAttribute errors - for (var I = 0; I < errorMessages.Count; ++I) - { - var message = errorMessages[I]; - if (message.StartsWith("The ", StringComparison.Ordinal) && message.EndsWith(" field is required.", StringComparison.Ordinal)) - { - errorMessages.RemoveAt(I); - --I; - } - } - - if (errorMessages.Count > 0) - { - await BadRequest(new ErrorMessage { Message = String.Join(Environment.NewLine, errorMessages) }).ExecuteResultAsync(context).ConfigureAwait(false); - return; - } + var errorMessages = ModelState.SelectMany(x => x.Value.Errors).Select(x => x.ErrorMessage); + await BadRequest(new ErrorMessage { Message = String.Join(Environment.NewLine, errorMessages) }).ExecuteResultAsync(context).ConfigureAwait(false); + return; } if (ApiHeaders != null) diff --git a/src/Tgstation.Server.Host/Controllers/ByondController.cs b/src/Tgstation.Server.Host/Controllers/ByondController.cs index 10229aaef1..52872441b6 100644 --- a/src/Tgstation.Server.Host/Controllers/ByondController.cs +++ b/src/Tgstation.Server.Host/Controllers/ByondController.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using System; -using System.Globalization; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -19,7 +19,7 @@ namespace Tgstation.Server.Host.Controllers /// Controller for managing s /// [Route(Routes.Byond)] - public sealed class ByondController : ModelController + public sealed class ByondController : ApiController { /// /// The for the @@ -39,31 +39,53 @@ namespace Tgstation.Server.Host.Controllers /// The value of /// The value of /// The for the - public ByondController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IInstanceManager instanceManager, IJobManager jobManager, ILogger logger) : base(databaseContext, authenticationContextFactory, logger, true) + public ByondController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IInstanceManager instanceManager, IJobManager jobManager, ILogger logger) : base(databaseContext, authenticationContextFactory, logger, true, true) { this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); } - /// + /// + /// Gets the active version. + /// + /// A resulting in the for the operation. + /// Retrieved version information successfully. + [HttpGet] [TgsAuthorize(ByondRights.ReadActive)] - public override Task Read(CancellationToken cancellationToken) => Task.FromResult( + [ProducesResponseType(typeof(Api.Models.Byond), 200)] + public Task Read() => Task.FromResult( Json(new Api.Models.Byond { Version = instanceManager.GetInstance(Instance).ByondManager.ActiveVersion })); - /// + /// + /// Lists installed versions. + /// + /// A resulting in the for the operation. + /// Retrieved version information successfully. + [HttpGet(Routes.List)] [TgsAuthorize(ByondRights.ListInstalled)] - public override Task List(CancellationToken cancellationToken) => Task.FromResult( + [ProducesResponseType(typeof(IEnumerable), 200)] + public Task List() => Task.FromResult( Json(instanceManager.GetInstance(Instance).ByondManager.InstalledVersions.Select(x => new Api.Models.Byond { Version = x }))); - /// + /// + /// Changes the active BYOND version to the one specified in a given . + /// + /// The to switch to. + /// The for the operation. + /// A resulting in the for the operation. + /// Switched active version successfully. + /// Created to install and switch active version successfully. + [HttpPost] [TgsAuthorize(ByondRights.ChangeVersion)] - public override async Task Update([FromBody] Api.Models.Byond model, CancellationToken cancellationToken) + [ProducesResponseType(typeof(Api.Models.Byond), 200)] + [ProducesResponseType(typeof(Api.Models.Byond), 202)] + public async Task Update([FromBody] Api.Models.Byond model, CancellationToken cancellationToken) { if (model == null) throw new ArgumentNullException(nameof(model)); @@ -90,7 +112,7 @@ namespace Tgstation.Server.Host.Controllers // run the install through the job manager var job = new Models.Job { - Description = String.Format(CultureInfo.InvariantCulture, "Install BYOND version {0}", installingVersion), + Description = $"Install BYOND version {installingVersion}", StartedBy = AuthenticationContext.User, CancelRightsType = RightsType.Byond, CancelRight = (ulong)ByondRights.CancelInstall, diff --git a/src/Tgstation.Server.Host/Controllers/ChatController.cs b/src/Tgstation.Server.Host/Controllers/ChatController.cs index 26ac8bc834..bbc3ba1f97 100644 --- a/src/Tgstation.Server.Host/Controllers/ChatController.cs +++ b/src/Tgstation.Server.Host/Controllers/ChatController.cs @@ -20,10 +20,10 @@ using Z.EntityFramework.Plus; namespace Tgstation.Server.Host.Controllers { /// - /// for managing s + /// for managing s /// [Route(Routes.Chat)] - public sealed class ChatController : ModelController + public sealed class ChatController : ApiController { /// /// The for the @@ -37,7 +37,7 @@ namespace Tgstation.Server.Host.Controllers /// The for the /// The value of /// The for the - public ChatController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IInstanceManager instanceManager, ILogger logger) : base(databaseContext, authenticationContextFactory, logger, true) + public ChatController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IInstanceManager instanceManager, ILogger logger) : base(databaseContext, authenticationContextFactory, logger, true, true) { this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); } @@ -57,9 +57,17 @@ namespace Tgstation.Server.Host.Controllers Tag = api.Tag }; - /// + /// + /// Create a new chat bot . + /// + /// The to create. + /// The for the operation. + /// A resulting in the for the operation. + /// Created chat bot successfully. + [HttpPut] [TgsAuthorize(ChatBotRights.Create)] - public override async Task Create([FromBody] Api.Models.ChatBot model, CancellationToken cancellationToken) + [ProducesResponseType(typeof(Api.Models.ChatBot), 201)] + public async Task Create([FromBody] Api.Models.ChatBot model, CancellationToken cancellationToken) { if (model == null) throw new ArgumentNullException(nameof(model)); @@ -129,9 +137,17 @@ namespace Tgstation.Server.Host.Controllers return StatusCode((int)HttpStatusCode.Created, dbModel.ToApi()); } - /// + /// + /// Delete a . + /// + /// The to delete. + /// The for the operation. + /// A resulting in the for the operation. + /// Chat bot deleted or does not exist. + [HttpDelete("{id}")] [TgsAuthorize(ChatBotRights.Delete)] - public override async Task Delete(long id, CancellationToken cancellationToken) + [ProducesResponseType(200)] + public async Task Delete(long id, CancellationToken cancellationToken) { var instance = instanceManager.GetInstance(Instance); await Task.WhenAll(instance.Chat.DeleteConnection(id, cancellationToken), DatabaseContext.ChatBots.Where(x => x.Id == id).DeleteAsync(cancellationToken)).ConfigureAwait(false); @@ -139,9 +155,16 @@ namespace Tgstation.Server.Host.Controllers return Ok(); } - /// + /// + /// List s. + /// + /// The for the operation. + /// A resulting in the for the operation. + /// Listed chat bots successfully. + [HttpGet(Routes.List)] [TgsAuthorize(ChatBotRights.Read)] - public override async Task List(CancellationToken cancellationToken) + [ProducesResponseType(typeof(IEnumerable), 200)] + public async Task List(CancellationToken cancellationToken) { var query = DatabaseContext.ChatBots.Where(x => x.InstanceId == Instance.Id).Include(x => x.Channels); @@ -156,9 +179,19 @@ namespace Tgstation.Server.Host.Controllers return Json(results.Select(x => x.ToApi())); } - /// + /// + /// Get a specific . + /// + /// The to retrieve. + /// The for the operation. + /// A resulting in the for the operation. + /// Retrieved successfully. + /// Chat bot does not exist. + [HttpGet("{id}")] [TgsAuthorize(ChatBotRights.Read)] - public override async Task GetId(long id, CancellationToken cancellationToken) + [ProducesResponseType(typeof(Api.Models.ChatBot), 200)] + [ProducesResponseType(410)] + public async Task GetId(long id, CancellationToken cancellationToken) { var query = DatabaseContext.ChatBots.Where(x => x.Id == id).Include(x => x.Channels); @@ -174,10 +207,19 @@ namespace Tgstation.Server.Host.Controllers return Json(results.ToApi()); } - /// - #pragma warning disable CA1506 // TODO: Decomplexify + /// + /// Updates a chat bot . + /// + /// The update to apply. + /// The for the operation. + /// A resulting in the for the operation. + /// Update applied successfully. may or may not be returned based on user permissions. + [HttpPost] [TgsAuthorize(ChatBotRights.WriteChannels | ChatBotRights.WriteConnectionString | ChatBotRights.WriteEnabled | ChatBotRights.WriteName | ChatBotRights.WriteProvider)] - public override async Task Update([FromBody] Api.Models.ChatBot model, CancellationToken cancellationToken) + [ProducesResponseType(200)] + [ProducesResponseType(typeof(Api.Models.ChatBot), 200)] + #pragma warning disable CA1506 // TODO: Decomplexify + public async Task Update([FromBody] Api.Models.ChatBot model, CancellationToken cancellationToken) { if (model == null) throw new ArgumentNullException(nameof(model)); diff --git a/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs b/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs index 49bbb78e67..363be52b3a 100644 --- a/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs +++ b/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using System; +using System.Collections.Generic; using System.IO; using System.Net; using System.Threading; @@ -16,10 +17,10 @@ using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.Controllers { /// - /// The for s + /// The for s /// [Route(Routes.Configuration)] - public sealed class ConfigurationController : ModelController + public sealed class ConfigurationController : ApiController { /// /// The for the @@ -39,7 +40,7 @@ namespace Tgstation.Server.Host.Controllers /// The value of /// The value of /// The for the - public ConfigurationController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IInstanceManager instanceManager, IIOManager ioManager, ILogger logger) : base(databaseContext, authenticationContextFactory, logger, true) + public ConfigurationController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IInstanceManager instanceManager, IIOManager ioManager, ILogger logger) : base(databaseContext, authenticationContextFactory, logger, true, true) { this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); @@ -63,9 +64,21 @@ namespace Tgstation.Server.Host.Controllers return false; } - /// + /// + /// Write to a configuration file. + /// + /// The representing the file. + /// The for the operation. + /// A resulting in the for the operation. + /// File updated successfully. + /// File created successfully. + /// POSIX system impersonation requested but not implemented. + [HttpPost] [TgsAuthorize(ConfigurationRights.Write)] - public override async Task Update([FromBody] ConfigurationFile model, CancellationToken cancellationToken) + [ProducesResponseType(typeof(ConfigurationFile), 200)] + [ProducesResponseType(typeof(ConfigurationFile), 201)] + [ProducesResponseType(501)] + public async Task Update([FromBody] ConfigurationFile model, CancellationToken cancellationToken) { if (model == null) throw new ArgumentNullException(nameof(model)); @@ -106,8 +119,13 @@ namespace Tgstation.Server.Host.Controllers /// The path of the file to get /// The for the operation /// A resulting in the for the operation + /// File not found on disk. + /// POSIX system impersonation requested but not implemented. [HttpGet(Routes.File + "/{*filePath}")] [TgsAuthorize(ConfigurationRights.Read)] + [ProducesResponseType(typeof(ConfigurationFile), 200)] + [ProducesResponseType(410)] + [ProducesResponseType(501)] public async Task File(string filePath, CancellationToken cancellationToken) { if (ForbidDueToModeConflicts(filePath, out var systemIdentity)) @@ -141,8 +159,13 @@ namespace Tgstation.Server.Host.Controllers /// The path of the directory to get /// The for the operation /// A resulting in the for the operation - [HttpGet("List/{*directoryPath}")] + /// Directory not found on disk. + /// POSIX system impersonation requested but not implemented. + [HttpGet(Routes.List + "/{*directoryPath}")] [TgsAuthorize(ConfigurationRights.List)] + [ProducesResponseType(typeof(IReadOnlyList), 200)] + [ProducesResponseType(410)] + [ProducesResponseType(501)] public async Task Directory(string directoryPath, CancellationToken cancellationToken) { if (ForbidDueToModeConflicts(directoryPath, out var systemIdentity)) @@ -166,13 +189,35 @@ namespace Tgstation.Server.Host.Controllers } } - /// + /// + /// Get the contents of the root configuration directory. + /// + /// The for the operation. + /// A resulting in the for the operation. + /// Directory not found on disk. + /// POSIX system impersonation requested but not implemented. + [HttpGet(Routes.List)] [TgsAuthorize(ConfigurationRights.List)] - public override Task List(CancellationToken cancellationToken) => Directory(null, cancellationToken); + [ProducesResponseType(typeof(IReadOnlyList), 200)] + [ProducesResponseType(410)] + [ProducesResponseType(501)] + public Task List(CancellationToken cancellationToken) => Directory(null, cancellationToken); - /// + /// + /// Create a configuration directory. + /// + /// The representing the directory. + /// The for the operation. + /// A resulting in the for the operation. + /// Directory already exists. + /// Directory created successfully. + /// POSIX system impersonation requested but not implemented. + [HttpPut] [TgsAuthorize(ConfigurationRights.Write)] - public override async Task Create([FromBody] ConfigurationFile model, CancellationToken cancellationToken) + [ProducesResponseType(typeof(ConfigurationFile), 200)] + [ProducesResponseType(typeof(ConfigurationFile), 201)] + [ProducesResponseType(501)] + public async Task Create([FromBody] ConfigurationFile model, CancellationToken cancellationToken) { if (model == null) throw new ArgumentNullException(nameof(model)); @@ -185,6 +230,14 @@ namespace Tgstation.Server.Host.Controllers model.IsDirectory = true; return await instanceManager.GetInstance(Instance).Configuration.CreateDirectory(model.Path, systemIdentity, cancellationToken).ConfigureAwait(false) ? (IActionResult)Json(model) : StatusCode((int)HttpStatusCode.Created, model); } + catch (IOException e) + { + Logger.LogInformation("IOException while creating directory {0}: {1}", model.Path, e); + return Conflict(new ErrorMessage + { + Message = e.Message + }); + } catch (NotImplementedException) { return StatusCode((int)HttpStatusCode.NotImplemented); @@ -201,8 +254,12 @@ namespace Tgstation.Server.Host.Controllers /// A representing the path to the directory to delete /// The for the operation /// A resulting in the of the operation + /// Empty directory deleted successfully. + /// POSIX system impersonation requested but not implemented. [HttpDelete] [TgsAuthorize(ConfigurationRights.Delete)] + [ProducesResponseType(200)] + [ProducesResponseType(501)] public async Task Delete([FromBody] ConfigurationFile directory, CancellationToken cancellationToken) { if (directory == null) diff --git a/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs b/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs index 3a33d10457..d9d8324e25 100644 --- a/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs +++ b/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs @@ -20,10 +20,10 @@ using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.Controllers { /// - /// for managing the + /// for managing the /// [Route(Routes.DreamDaemon)] - public sealed class DreamDaemonController : ModelController + public sealed class DreamDaemonController : ApiController { /// /// The for the @@ -43,15 +43,24 @@ namespace Tgstation.Server.Host.Controllers /// The value of /// The value of /// The for the - public DreamDaemonController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IJobManager jobManager, IInstanceManager instanceManager, ILogger logger) : base(databaseContext, authenticationContextFactory, logger, true) + public DreamDaemonController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IJobManager jobManager, IInstanceManager instanceManager, ILogger logger) : base(databaseContext, authenticationContextFactory, logger, true, true) { this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); } - /// + /// + /// Launches the watchdog. + /// + /// The for the operation. + /// A resulting in the of the operation. + /// to launch the watchdog started successfully. + /// Watchdog already running. + [HttpPut] [TgsAuthorize(DreamDaemonRights.Start)] - public override async Task Create([FromBody] DreamDaemon model, CancellationToken cancellationToken) + [ProducesResponseType(typeof(Api.Models.Job), 202)] + [ProducesResponseType(410)] + public async Task Create(CancellationToken cancellationToken) { // alias for launching DD var instance = instanceManager.GetInstance(Instance); @@ -71,9 +80,17 @@ namespace Tgstation.Server.Host.Controllers return Accepted(job.ToApi()); } - /// + /// + /// Get the watchdog status. + /// + /// The for the operation. + /// A resulting in the of the operation. + /// Read information successfully. + [HttpGet] [TgsAuthorize(DreamDaemonRights.ReadMetadata | DreamDaemonRights.ReadRevision)] - public override Task Read(CancellationToken cancellationToken) => ReadImpl(null, cancellationToken); + [ProducesResponseType(typeof(DreamDaemon), 200)] + [ProducesResponseType(410)] + public Task Read(CancellationToken cancellationToken) => ReadImpl(null, cancellationToken); /// /// Implementation of @@ -128,12 +145,14 @@ namespace Tgstation.Server.Host.Controllers } /// - /// Stops DreamDaemon if it's running + /// Stops the Watchdog if it's running. /// - /// The for the operation - /// A resulting in the of the operation - [HttpDelete] + /// The for the operation. + /// A resulting in the of the operation. + /// Watchdog terminated. + [HttpDelete("{id}")] [TgsAuthorize(DreamDaemonRights.Shutdown)] + [ProducesResponseType(200)] public async Task Delete(CancellationToken cancellationToken) { var instance = instanceManager.GetInstance(Instance); @@ -141,10 +160,20 @@ namespace Tgstation.Server.Host.Controllers return Ok(); } - /// - #pragma warning disable CA1506 // TODO: Decomplexify + /// + /// Update watchdog settings to be applied at next server reboot. + /// + /// The updated settings. + /// The for the operation. + /// A resulting in the of the operation. + /// Settings applied successfully. + /// Instance no longer available. + [HttpPost] [TgsAuthorize(DreamDaemonRights.SetAutoStart | DreamDaemonRights.SetPorts | DreamDaemonRights.SetSecurity | DreamDaemonRights.SetWebClient | DreamDaemonRights.SoftRestart | DreamDaemonRights.SoftShutdown | DreamDaemonRights.Start | DreamDaemonRights.SetStartupTimeout)] - public override async Task Update([FromBody] DreamDaemon model, CancellationToken cancellationToken) + [ProducesResponseType(typeof(DreamDaemon), 200)] + [ProducesResponseType(410)] + #pragma warning disable CA1506 // TODO: Decomplexify + public async Task Update([FromBody] DreamDaemon model, CancellationToken cancellationToken) { if (model == null) throw new ArgumentNullException(nameof(model)); @@ -213,12 +242,14 @@ namespace Tgstation.Server.Host.Controllers #pragma warning restore CA1506 /// - /// Handle a HTTP PATCH to the + /// Creates a to restart the Watchdog. It will start if it wasn't already running. /// /// The for the operation /// A resulting in the of the request + /// Restart started successfully. [HttpPatch] [TgsAuthorize(DreamDaemonRights.Restart)] + [ProducesResponseType(typeof(Api.Models.Job), 202)] public async Task Restart(CancellationToken cancellationToken) { var job = new Models.Job diff --git a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs index f9d0a57451..1a9b4e6bcd 100644 --- a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs +++ b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using System; +using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading; @@ -17,11 +18,11 @@ using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.Controllers { /// - /// Controller for managing the compiler + /// for managing the deployment system. /// [Route(Routes.DreamMaker)] #pragma warning disable CA1506 // TODO: Decomplexify - public sealed class DreamMakerController : ModelController + public sealed class DreamMakerController : ApiController { /// /// The for the @@ -41,24 +42,41 @@ namespace Tgstation.Server.Host.Controllers /// The value of /// The value of /// The for the - public DreamMakerController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IJobManager jobManager, IInstanceManager instanceManager, ILogger logger) : base(databaseContext, authenticationContextFactory, logger, true) + public DreamMakerController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IJobManager jobManager, IInstanceManager instanceManager, ILogger logger) : base(databaseContext, authenticationContextFactory, logger, true, true) { this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); } - /// + /// + /// Read current status. + /// + /// The for the operation. + /// A resulting in the of the request. + /// Read status successfully. + [HttpGet] [TgsAuthorize(DreamMakerRights.Read)] - public override async Task Read(CancellationToken cancellationToken) + [ProducesResponseType(typeof(DreamMaker), 200)] + public async Task Read(CancellationToken cancellationToken) { var instance = instanceManager.GetInstance(Instance); var dreamMakerSettings = await DatabaseContext.DreamMakerSettings.Where(x => x.InstanceId == Instance.Id).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); return Json(dreamMakerSettings.ToApi()); } - /// + /// + /// Get a specified by a given . + /// + /// The . + /// The for the operation. + /// A resulting in the of the request. + /// retrieved successfully. + /// Specified compile job does not exist in this instance. + [HttpGet("{id}")] [TgsAuthorize(DreamMakerRights.CompileJobs)] - public override async Task GetId(long id, CancellationToken cancellationToken) + [ProducesResponseType(typeof(Api.Models.CompileJob), 200)] + [ProducesResponseType(404)] + public async Task GetId(long id, CancellationToken cancellationToken) { var compileJob = await DatabaseContext.CompileJobs .Where(x => x.Id == id && x.Job.Instance.Id == Instance.Id) @@ -71,20 +89,34 @@ namespace Tgstation.Server.Host.Controllers return Json(compileJob.ToApi()); } - /// + /// + /// List all s for the instance. + /// + /// The for the operation. + /// A resulting in the of the request. + /// Retrieved s successfully. + [HttpGet(Routes.List)] [TgsAuthorize(DreamMakerRights.CompileJobs)] - public override async Task List(CancellationToken cancellationToken) + [ProducesResponseType(typeof(List), 200)] + public async Task List(CancellationToken cancellationToken) { - var compileJobs = await DatabaseContext.CompileJobs.Where(x => x.Job.Instance.Id == Instance.Id).OrderByDescending(x => x.Job.StoppedAt).Select(x => new Api.Models.CompileJob + var compileJobs = await DatabaseContext.CompileJobs.Where(x => x.Job.Instance.Id == Instance.Id).OrderByDescending(x => x.Job.StoppedAt).Select(x => new EntityId { Id = x.Id }).ToListAsync(cancellationToken).ConfigureAwait(false); return Json(compileJobs); } - /// + /// + /// Begin deploying repository code. + /// + /// The for the operation. + /// A resulting in the of the request. + /// Created deployment successfully. + [HttpPut] [TgsAuthorize(DreamMakerRights.Compile)] - public override async Task Create([FromBody] DreamMaker model, CancellationToken cancellationToken) + [ProducesResponseType(typeof(Api.Models.Job), 202)] + public async Task Create(CancellationToken cancellationToken) { var job = new Models.Job { @@ -98,10 +130,24 @@ namespace Tgstation.Server.Host.Controllers return Accepted(job.ToApi()); } - /// + /// + /// Update deployment settings. + /// + /// The updated settings. + /// The for the operation. + /// A resulting in the of the request. + /// Changes applied successfully. The updated settings will be returned based on user permissions. + /// Instance no longer available. + [HttpPost] [TgsAuthorize(DreamMakerRights.SetDme | DreamMakerRights.SetApiValidationPort | DreamMakerRights.SetApiValidationPort)] - public override async Task Update([FromBody] DreamMaker model, CancellationToken cancellationToken) + [ProducesResponseType(typeof(DreamMaker), 200)] + [ProducesResponseType(200)] + [ProducesResponseType(410)] + public async Task Update([FromBody] DreamMaker model, CancellationToken cancellationToken) { + if (model == null) + throw new ArgumentNullException(nameof(model)); + if (model.ApiValidationPort == 0) return BadRequest(new ErrorMessage { Message = "API Validation port cannot be 0!" }); diff --git a/src/Tgstation.Server.Host/Controllers/HomeController.cs b/src/Tgstation.Server.Host/Controllers/HomeController.cs index baea142ac0..f278244ddc 100644 --- a/src/Tgstation.Server.Host/Controllers/HomeController.cs +++ b/src/Tgstation.Server.Host/Controllers/HomeController.cs @@ -1,8 +1,11 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; using System; using System.Linq; using System.Net.Mime; @@ -85,10 +88,14 @@ namespace Tgstation.Server.Host.Controllers /// /// Main page of the /// - /// The of the if a properly authenticated API request, the web control panel if on a browser and enabled, otherwise + /// + /// The of the if a properly authenticated API request, the web control panel if on a browser and enabled, otherwise. + /// + /// retrieved successfully. + [HttpGet] [TgsAuthorize] [AllowAnonymous] - [HttpGet] + [ProducesResponseType(typeof(Api.Models.ServerInformation), 200)] public IActionResult Home() { if (AuthenticationContext != null) @@ -113,12 +120,33 @@ namespace Tgstation.Server.Host.Controllers /// /// The for the operation /// A resulting in the of the operation + /// User logged in and generated successfully. + /// User authentication failed. + /// User authenticated but is disabled by an administrator. [HttpPost] + [ProducesResponseType(typeof(Api.Models.Token), 200)] + [ProducesResponseType(401)] + [ProducesResponseType(403)] #pragma warning disable CA1506 // TODO: Decomplexify public async Task CreateToken(CancellationToken cancellationToken) { if (ApiHeaders == null) - return BadRequest(new Api.Models.ErrorMessage { Message = "Missing API headers!" }); + { + // Get the exact error + var errorMessage = "Missing API headers!"; + try + { + var _ = new ApiHeaders(Request.GetTypedHeaders()); + } + catch (InvalidOperationException ex) + { + errorMessage = ex.Message; + } + + Response.Headers.Add(HeaderNames.WWWAuthenticate, new StringValues("basic realm=\"Create TGS4 bearer token\"")); + + return BadRequest(new Api.Models.ErrorMessage { Message = errorMessage }); + } if (ApiHeaders.IsTokenAuthentication) return BadRequest(new Api.Models.ErrorMessage { Message = "Cannot create a token using another token!" }); @@ -197,7 +225,10 @@ namespace Tgstation.Server.Host.Controllers // Now that the bookeeping is done, tell them to fuck off if necessary if (!user.Enabled.Value) + { + Logger.LogTrace("Not logging in disabled user {0}.", user.Id); return Forbid(); + } var token = await tokenFactory.CreateToken(user, cancellationToken).ConfigureAwait(false); if (usingSystemIdentity) diff --git a/src/Tgstation.Server.Host/Controllers/InstanceController.cs b/src/Tgstation.Server.Host/Controllers/InstanceController.cs index 22419b3be2..1481b1019f 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceController.cs @@ -23,11 +23,11 @@ using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.Controllers { /// - /// Controller for managing s + /// for managing s /// [Route(Routes.InstanceManager)] #pragma warning disable CA1506 // TODO: Decomplexify - public sealed class InstanceController : ModelController + public sealed class InstanceController : ApiController { /// /// File name to allow attaching instances @@ -72,7 +72,7 @@ namespace Tgstation.Server.Host.Controllers /// The value of /// The value of /// The for the - public InstanceController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IJobManager jobManager, IInstanceManager instanceManager, IIOManager ioManager, IApplication application, IPlatformIdentifier platformIdentifier, ILogger logger) : base(databaseContext, authenticationContextFactory, logger, false) + public InstanceController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IJobManager jobManager, IInstanceManager instanceManager, IIOManager ioManager, IApplication application, IPlatformIdentifier platformIdentifier, ILogger logger) : base(databaseContext, authenticationContextFactory, logger, false, true) { this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); @@ -108,9 +108,19 @@ namespace Tgstation.Server.Host.Controllers UserId = AuthenticationContext.User.Id }; - /// + /// + /// Create or attach an . + /// + /// The settings. + /// The for the operation. + /// A resulting in the of the request. + /// Instance attached successfully. + /// Instance created successfully. + [HttpPut] [TgsAuthorize(InstanceManagerRights.Create)] - public override async Task Create([FromBody] Api.Models.Instance model, CancellationToken cancellationToken) + [ProducesResponseType(typeof(Api.Models.Instance), 200)] + [ProducesResponseType(typeof(Api.Models.Instance), 201)] + public async Task Create([FromBody] Api.Models.Instance model, CancellationToken cancellationToken) { if (model == null) throw new ArgumentNullException(nameof(model)); @@ -130,7 +140,14 @@ namespace Tgstation.Server.Host.Controllers }, out var normalizedLocalPath); if (rawPath.StartsWith(normalizedLocalPath, StringComparison.Ordinal)) - return Conflict("Instances cannot be created in the installation directory!"); + { + bool sameLength = rawPath.Length == normalizedLocalPath.Length; + char dirSeparatorChar = rawPath.ToCharArray()[normalizedLocalPath.Length]; + if(sameLength + || dirSeparatorChar == Path.DirectorySeparatorChar + || dirSeparatorChar == Path.AltDirectorySeparatorChar) + return Conflict(new ErrorMessage { Message = "Instances cannot be created in the installation directory!" }); + } var dirExistsTask = ioManager.DirectoryExists(model.Path, cancellationToken); bool attached = false; @@ -214,9 +231,19 @@ namespace Tgstation.Server.Host.Controllers return attached ? (IActionResult)Json(api) : StatusCode((int)HttpStatusCode.Created, api); } - /// + /// + /// Detach an with the given . + /// + /// The to detach. + /// The for the operation. + /// A resulting in the of the request. + /// Instance detatched successfully. + /// Instance not available. + [HttpDelete("{id}")] [TgsAuthorize(InstanceManagerRights.Delete)] - public override async Task Delete(long id, CancellationToken cancellationToken) + [ProducesResponseType(200)] + [ProducesResponseType(410)] + public async Task Delete(long id, CancellationToken cancellationToken) { var originalModel = await DatabaseContext.Instances.Where(x => x.Id == id) .Include(x => x.WatchdogReattachInformation) @@ -248,11 +275,24 @@ namespace Tgstation.Server.Host.Controllers return Ok(); } - /// + /// + /// Modify an 's settings. + /// + /// The updated settings. + /// The for the operation. + /// A resulting in the of the request. + /// Instance updated successfully. + /// Instance updated successfully and relocation job created. + [HttpPost] [TgsAuthorize(InstanceManagerRights.Relocate | InstanceManagerRights.Rename | InstanceManagerRights.SetAutoUpdate | InstanceManagerRights.SetConfiguration | InstanceManagerRights.SetOnline)] - #pragma warning disable CA1502 // TODO: Decomplexify - public override async Task Update([FromBody] Api.Models.Instance model, CancellationToken cancellationToken) + [ProducesResponseType(typeof(Api.Models.Instance), 200)] + [ProducesResponseType(410)] +#pragma warning disable CA1502 // TODO: Decomplexify + public async Task Update([FromBody] Api.Models.Instance model, CancellationToken cancellationToken) { + if (model == null) + throw new ArgumentNullException(nameof(model)); + var instanceQuery = DatabaseContext.Instances.Where(x => x.Id == model.Id); var moveJob = await instanceQuery @@ -372,7 +412,9 @@ namespace Tgstation.Server.Host.Controllers { Id = originalModel.Id }; - if (originalModelPath != null) + + var moving = originalModelPath != null; + if (moving) { var job = new Models.Job { @@ -390,13 +432,20 @@ namespace Tgstation.Server.Host.Controllers if (originalModel.Online.Value && model.AutoUpdateInterval.HasValue && oldAutoUpdateInterval != model.AutoUpdateInterval) await instanceManager.GetInstance(originalModel).SetAutoUpdateInterval(model.AutoUpdateInterval.Value).ConfigureAwait(false); - return Json(api); + return moving ? (IActionResult)Accepted(api) : Json(api); } - #pragma warning restore CA1502 +#pragma warning restore CA1502 - /// + /// + /// List s. + /// + /// The for the operation. + /// A resulting in the of the request. + /// Retrieved s successfully. + [HttpGet(Routes.List)] [TgsAuthorize(InstanceManagerRights.List | InstanceManagerRights.Read)] - public override async Task List(CancellationToken cancellationToken) + [ProducesResponseType(typeof(IEnumerable), 200)] + public async Task List(CancellationToken cancellationToken) { IQueryable query = DatabaseContext.Instances; if (!AuthenticationContext.User.InstanceManagerRights.Value.HasFlag(InstanceManagerRights.List)) @@ -418,9 +467,19 @@ namespace Tgstation.Server.Host.Controllers return Json(apis); } - /// + /// + /// Get a specific . + /// + /// The to retrieve. + /// The for the operation. + /// A resulting in the of the request. + /// Retrieved successfully. + /// Instance not available. + [HttpGet("{id}")] [TgsAuthorize(InstanceManagerRights.List | InstanceManagerRights.Read)] - public override async Task GetId(long id, CancellationToken cancellationToken) + [ProducesResponseType(typeof(Api.Models.Instance), 200)] + [ProducesResponseType(410)] + public async Task GetId(long id, CancellationToken cancellationToken) { var query = DatabaseContext.Instances.Where(x => x.Id == id); var cantList = !AuthenticationContext.User.InstanceManagerRights.Value.HasFlag(InstanceManagerRights.List); diff --git a/src/Tgstation.Server.Host/Controllers/InstanceUserController.cs b/src/Tgstation.Server.Host/Controllers/InstanceUserController.cs index 67a4d0189d..dc998b9788 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceUserController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceUserController.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using System; +using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading; @@ -16,10 +17,10 @@ using Z.EntityFramework.Plus; namespace Tgstation.Server.Host.Controllers { /// - /// For managing s + /// for managing s. /// [Route(Routes.InstanceUser)] - public sealed class InstanceUserController : ModelController + public sealed class InstanceUserController : ApiController { /// /// Construct a @@ -27,7 +28,7 @@ namespace Tgstation.Server.Host.Controllers /// The for the /// The for the /// The for the - public InstanceUserController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, ILogger logger) : base(databaseContext, authenticationContextFactory, logger, true) // false instance requirement, we handle this ourself + public InstanceUserController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, ILogger logger) : base(databaseContext, authenticationContextFactory, logger, true, true) { } /// @@ -46,9 +47,17 @@ namespace Tgstation.Server.Host.Controllers return null; } - /// + /// + /// Create am . + /// + /// The to create. + /// The for the operation. + /// A resulting in the of the request. + /// created successfully. + [HttpPut] [TgsAuthorize(InstanceUserRights.CreateUsers)] - public override async Task Create([FromBody] Api.Models.InstanceUser model, CancellationToken cancellationToken) + [ProducesResponseType(typeof(Api.Models.InstanceUser), 201)] + public async Task Create([FromBody] Api.Models.InstanceUser model, CancellationToken cancellationToken) { var test = StandardModelChecks(model); if (test != null) @@ -73,10 +82,20 @@ namespace Tgstation.Server.Host.Controllers return StatusCode((int)HttpStatusCode.Created, dbUser.ToApi()); } - /// + /// + /// Update the permissions for an . + /// + /// The updated . + /// The for the operation. + /// A resulting in the of the request. + /// updated successfully. + /// Instance user unavailable. + [HttpPost] [TgsAuthorize(InstanceUserRights.WriteUsers)] + [ProducesResponseType(typeof(Api.Models.InstanceUser), 200)] + [ProducesResponseType(410)] #pragma warning disable CA1506 // TODO: Decomplexify - public override async Task Update([FromBody] Api.Models.InstanceUser model, CancellationToken cancellationToken) + public async Task Update([FromBody] Api.Models.InstanceUser model, CancellationToken cancellationToken) { var test = StandardModelChecks(model); if (test != null) @@ -100,23 +119,45 @@ namespace Tgstation.Server.Host.Controllers UserId = originalUser.UserId }); } - #pragma warning restore CA1506 - - /// +#pragma warning restore CA1506 + /// + /// Read the active . + /// + /// The of the request. + /// retrieved successfully. + [HttpGet] [TgsAuthorize] - public override Task Read(CancellationToken cancellationToken) => Task.FromResult(AuthenticationContext.InstanceUser != null ? (IActionResult)Json(AuthenticationContext.InstanceUser.ToApi()) : NotFound()); + [ProducesResponseType(typeof(Api.Models.InstanceUser), 200)] + public IActionResult Read() => Json(AuthenticationContext.InstanceUser.ToApi()); - /// + /// + /// Lists s for the instance. + /// + /// The for the operation. + /// A resulting in the of the request. + /// Retrieved s successfully. + [HttpGet(Routes.List)] [TgsAuthorize(InstanceUserRights.ReadUsers)] - public override async Task List(CancellationToken cancellationToken) + [ProducesResponseType(typeof(IEnumerable), 200)] + public async Task List(CancellationToken cancellationToken) { var users = await DatabaseContext.Instances.Where(x => x.Id == Instance.Id).SelectMany(x => x.InstanceUsers).ToListAsync(cancellationToken).ConfigureAwait(false); return Json(users.Select(x => x.ToApi())); } - /// + /// + /// Gets a specific . + /// + /// The . + /// The for the operation. + /// A resulting in the of the request. + /// Retrieve successfully. + /// Instance user unavailable. + [HttpGet("{id}")] [TgsAuthorize(InstanceUserRights.ReadUsers)] - public override async Task GetId(long id, CancellationToken cancellationToken) + [ProducesResponseType(typeof(Api.Models.InstanceUser), 200)] + [ProducesResponseType(410)] + public async Task GetId(long id, CancellationToken cancellationToken) { // this functions as userId var user = await DatabaseContext.Instances.Where(x => x.Id == Instance.Id).SelectMany(x => x.InstanceUsers).Where(x => x.UserId == id).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); @@ -125,9 +166,17 @@ namespace Tgstation.Server.Host.Controllers return Json(user.ToApi()); } - /// + /// + /// Delete an . + /// + /// The to delete. + /// The for the operation. + /// A resulting in the of the request. + /// deleted or no longer exists. + [HttpDelete("{id}")] [TgsAuthorize(InstanceUserRights.WriteUsers)] - public override async Task Delete(long id, CancellationToken cancellationToken) + [ProducesResponseType(200)] + public async Task Delete(long id, CancellationToken cancellationToken) { await DatabaseContext.Instances.Where(x => x.Id == Instance.Id).SelectMany(x => x.InstanceUsers).Where(x => x.UserId == id).DeleteAsync(cancellationToken).ConfigureAwait(false); return Ok(); diff --git a/src/Tgstation.Server.Host/Controllers/JobController.cs b/src/Tgstation.Server.Host/Controllers/JobController.cs index e5c08bbc99..5dddb4bd01 100644 --- a/src/Tgstation.Server.Host/Controllers/JobController.cs +++ b/src/Tgstation.Server.Host/Controllers/JobController.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using System; +using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading; @@ -14,10 +15,10 @@ using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.Controllers { /// - /// for s + /// for s /// [Route(Routes.Jobs)] - public sealed class JobController : ModelController + public sealed class JobController : ApiController { /// /// The for the @@ -31,37 +32,63 @@ namespace Tgstation.Server.Host.Controllers /// The for the /// The value of /// The for the - public JobController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IJobManager jobManager, ILogger logger) : base(databaseContext, authenticationContextFactory, logger, true) + public JobController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IJobManager jobManager, ILogger logger) : base(databaseContext, authenticationContextFactory, logger, true, true) { this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); } - /// + /// + /// Get active s for the instance. + /// + /// The for the operation. + /// A resulting in the of the request. + /// Retrieved active s successfully. + [HttpGet] [TgsAuthorize] - public override async Task Read(CancellationToken cancellationToken) + [ProducesResponseType(typeof(IEnumerable), 200)] + public async Task Read(CancellationToken cancellationToken) { var result = await DatabaseContext.Jobs.Where(x => x.Instance.Id == Instance.Id && !x.StoppedAt.HasValue).OrderByDescending(x => x.StartedAt).ToListAsync(cancellationToken).ConfigureAwait(false); return Json(result.Select(x => x.ToApi())); } - /// + /// + /// List all s for the instance in reverse creation order. + /// + /// The for the operation. + /// A resulting in the of the request. + /// Retrieved s successfully. + [HttpGet(Routes.List)] [TgsAuthorize] - public override async Task List(CancellationToken cancellationToken) + [ProducesResponseType(typeof(List), 200)] + public async Task List(CancellationToken cancellationToken) { // you KNOW this will need pagination eventually right? - var jobs = await DatabaseContext.Jobs.Where(x => x.Instance.Id == Instance.Id).OrderByDescending(x => x.StartedAt).Select(x => new Api.Models.Job + var jobs = await DatabaseContext.Jobs.Where(x => x.Instance.Id == Instance.Id).OrderByDescending(x => x.StartedAt).Select(x => new Api.Models.EntityId { Id = x.Id }).ToListAsync(cancellationToken).ConfigureAwait(false); return Json(jobs); } - /// + /// + /// Cancel a running . + /// + /// The of the to cancel. + /// The for the operation. + /// A resulting in the of the request. + /// cancellation requested successfully. + /// does not exist in this instance. + /// already cancelled or completed. + [HttpDelete("{id}")] [TgsAuthorize] - public override async Task Delete(long id, CancellationToken cancellationToken) + [ProducesResponseType(202)] + [ProducesResponseType(404)] + [ProducesResponseType(410)] + public async Task Delete(long id, CancellationToken cancellationToken) { // don't care if an instance post or not at this point - var job = await DatabaseContext.Jobs.Where(x => x.Id == id).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + var job = await DatabaseContext.Jobs.Where(x => x.Id == id && x.Instance.Id == Instance.Id).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); if (job == default(Job)) return NotFound(); @@ -75,11 +102,21 @@ namespace Tgstation.Server.Host.Controllers return cancelled ? (IActionResult)Accepted() : StatusCode((int)HttpStatusCode.Gone); } - /// + /// + /// Get a specific . + /// + /// The of the to retrieve. + /// The for the operation. + /// A resulting in the of the request. + /// Retrieved successfully. + /// does not exist in this instance. + [HttpGet("{id}")] [TgsAuthorize] - public override async Task GetId(long id, CancellationToken cancellationToken) + [ProducesResponseType(typeof(Api.Models.Job), 200)] + [ProducesResponseType(404)] + public async Task GetId(long id, CancellationToken cancellationToken) { - var job = await DatabaseContext.Jobs.Where(x => x.Id == id).Include(x => x.StartedBy).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + var job = await DatabaseContext.Jobs.Where(x => x.Id == id && x.Instance.Id == Instance.Id).Include(x => x.StartedBy).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); if (job == default(Job)) return NotFound(); var api = job.ToApi(); diff --git a/src/Tgstation.Server.Host/Controllers/ModelController.cs b/src/Tgstation.Server.Host/Controllers/ModelController.cs deleted file mode 100644 index 3434992040..0000000000 --- a/src/Tgstation.Server.Host/Controllers/ModelController.cs +++ /dev/null @@ -1,77 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using System.Threading; -using System.Threading.Tasks; -using Tgstation.Server.Host.Models; -using Tgstation.Server.Host.Security; - -namespace Tgstation.Server.Host.Controllers -{ - /// - /// An representing a - /// - /// The model being represented - public abstract class ModelController : ApiController where TModel : class - { - /// - /// Construct a - /// - /// The for the - /// The for the - /// The for the - /// If the requires an - public ModelController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, ILogger logger, bool requireInstance) : base(databaseContext, authenticationContextFactory, logger, requireInstance, true) { } - - /// - /// Attempt to create a - /// - /// The being created - /// The for the operation - /// A resulting in the of the operation - [HttpPut] - public virtual Task Create([FromBody]TModel model, CancellationToken cancellationToken) => Task.FromResult((IActionResult)NotFound()); - - /// - /// Attempt to read a - /// - /// The for the operation - /// A resulting in the of the operation - [HttpGet] - public virtual Task Read(CancellationToken cancellationToken) => Task.FromResult((IActionResult)NotFound()); - - /// - /// Attempt to get a specific a - /// - /// The ID of the model to get - /// The for the operation - /// A resulting in the of the operation - [HttpGet("{id}")] - public virtual Task GetId(long id, CancellationToken cancellationToken) => Task.FromResult((IActionResult)NotFound()); - - /// - /// Attempt to update a - /// - /// The being updated - /// The for the operation - /// A resulting in the of the operation - [HttpPost] - public virtual Task Update([FromBody]TModel model, CancellationToken cancellationToken) => Task.FromResult((IActionResult)NotFound()); - - /// - /// Attempt to delete a model with a particular - /// - /// The ID of the model to delete - /// The for the operation - /// A resulting in the of the operation - [HttpDelete("{id}")] - public virtual Task Delete(long id, CancellationToken cancellationToken) => Task.FromResult((IActionResult)NotFound()); - - /// - /// Attempt to list entries of the - /// - /// The for the operation - /// A resulting in the of the operation - [HttpGet("List")] - public virtual Task List(CancellationToken cancellationToken) => Task.FromResult((IActionResult)NotFound()); - } -} diff --git a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs index 69d0d6417b..c6fd18a741 100644 --- a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs +++ b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs @@ -23,11 +23,11 @@ using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.Controllers { /// - /// Controller for managing the s + /// for managing the s /// [Route(Routes.Repository)] #pragma warning disable CA1506 // TODO: Decomplexify - public sealed class RepositoryController : ModelController + public sealed class RepositoryController : ApiController { /// /// The for the @@ -59,7 +59,7 @@ namespace Tgstation.Server.Host.Controllers /// The value of /// The for the /// The containing value of - public RepositoryController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IInstanceManager instanceManager, IGitHubClientFactory gitHubClientFactory, IJobManager jobManager, ILogger logger, IOptions generalConfigurationOptions) : base(databaseContext, authenticationContextFactory, logger, true) + public RepositoryController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IInstanceManager instanceManager, IGitHubClientFactory gitHubClientFactory, IJobManager jobManager, ILogger logger, IOptions generalConfigurationOptions) : base(databaseContext, authenticationContextFactory, logger, true, true) { this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); this.gitHubClientFactory = gitHubClientFactory ?? throw new ArgumentNullException(nameof(gitHubClientFactory)); @@ -126,9 +126,19 @@ namespace Tgstation.Server.Host.Controllers return needsDbUpdate; } - /// + /// + /// Begin cloning the repository if it doesn't exist. + /// + /// Initial settings. + /// The for the operation. + /// A resulting in the of the request. + /// The was created successfully and the to clone it has begun. + /// Instance no longer available. + [HttpPut] [TgsAuthorize(RepositoryRights.SetOrigin)] - public override async Task Create([FromBody] Repository model, CancellationToken cancellationToken) + [ProducesResponseType(typeof(Repository), 201)] + [ProducesResponseType(410)] + public async Task Create([FromBody] Repository model, CancellationToken cancellationToken) { if (model == null) throw new ArgumentNullException(nameof(model)); @@ -214,11 +224,16 @@ namespace Tgstation.Server.Host.Controllers } /// - /// Delete the + /// Delete the . /// /// The for the operation /// A resulting in the of the operation + /// Job to delete the repository created successfully. + /// Instance no longer available. + [HttpDelete("{id}")] [TgsAuthorize(RepositoryRights.Delete)] + [ProducesResponseType(typeof(Repository), 202)] + [ProducesResponseType(410)] public async Task Delete(CancellationToken cancellationToken) { var currentModel = await DatabaseContext.RepositorySettings.Where(x => x.InstanceId == Instance.Id).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); @@ -245,9 +260,20 @@ namespace Tgstation.Server.Host.Controllers return Accepted(api); } - /// + /// + /// Get status. + /// + /// The for the operation. + /// A resulting in the of the operation. + /// Retrieved the settings successfully. + /// Retrieved the settings successfully, though they did not previously exist. + /// Instance no longer available. + [HttpGet] [TgsAuthorize(RepositoryRights.Read)] - public override async Task Read(CancellationToken cancellationToken) + [ProducesResponseType(typeof(Repository), 200)] + [ProducesResponseType(typeof(Repository), 201)] + [ProducesResponseType(410)] + public async Task Read(CancellationToken cancellationToken) { var currentModel = await DatabaseContext.RepositorySettings.Where(x => x.InstanceId == Instance.Id).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); @@ -273,7 +299,7 @@ namespace Tgstation.Server.Host.Controllers { if (repo != null && await PopulateApi(api, repo, DatabaseContext, Instance, cancellationToken).ConfigureAwait(false)) { - // user may have fucked with the repo without telling us, do what we can + // user may have fucked with the repo manually, do what we can await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); return StatusCode((int)HttpStatusCode.Created, api); } @@ -282,11 +308,23 @@ namespace Tgstation.Server.Host.Controllers } } - /// + /// + /// Perform updats to the . + /// + /// The updated . + /// The for the operation. + /// A resulting in the of the operation. + /// Updated the settings successfully. + /// Updated the settings successfully and a was created to make the requested git changes. + /// Instance no longer available. + [HttpPost] [TgsAuthorize(RepositoryRights.ChangeAutoUpdateSettings | RepositoryRights.ChangeCommitter | RepositoryRights.ChangeCredentials | RepositoryRights.ChangeTestMergeCommits | RepositoryRights.MergePullRequest | RepositoryRights.SetReference | RepositoryRights.SetSha | RepositoryRights.UpdateBranch)] + [ProducesResponseType(typeof(Repository), 200)] + [ProducesResponseType(typeof(Repository), 202)] + [ProducesResponseType(410)] #pragma warning disable CA1502 // TODO: Decomplexify #pragma warning disable CA1505 - public override async Task Update([FromBody]Repository model, CancellationToken cancellationToken) + public async Task Update([FromBody]Repository model, CancellationToken cancellationToken) { if (model == null) throw new ArgumentNullException(nameof(model)); diff --git a/src/Tgstation.Server.Host/Controllers/TgsAuthorizeAttribute.cs b/src/Tgstation.Server.Host/Controllers/TgsAuthorizeAttribute.cs index 0d52ac6ae1..4501a9783f 100644 --- a/src/Tgstation.Server.Host/Controllers/TgsAuthorizeAttribute.cs +++ b/src/Tgstation.Server.Host/Controllers/TgsAuthorizeAttribute.cs @@ -11,6 +11,11 @@ namespace Tgstation.Server.Host.Controllers [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] sealed class TgsAuthorizeAttribute : AuthorizeAttribute { + /// + /// Gets the associated with the if any. + /// + public RightsType? RightsType { get; } + /// /// Construct a /// @@ -23,6 +28,7 @@ namespace Tgstation.Server.Host.Controllers public TgsAuthorizeAttribute(AdministrationRights requiredRights) { Roles = RightsHelper.RoleNames(requiredRights); + RightsType = Api.Rights.RightsType.Administration; } /// @@ -32,6 +38,7 @@ namespace Tgstation.Server.Host.Controllers public TgsAuthorizeAttribute(InstanceManagerRights requiredRights) { Roles = RightsHelper.RoleNames(requiredRights); + RightsType = Api.Rights.RightsType.InstanceManager; } /// @@ -41,6 +48,7 @@ namespace Tgstation.Server.Host.Controllers public TgsAuthorizeAttribute(RepositoryRights requiredRights) { Roles = RightsHelper.RoleNames(requiredRights); + RightsType = Api.Rights.RightsType.Repository; } /// @@ -50,6 +58,7 @@ namespace Tgstation.Server.Host.Controllers public TgsAuthorizeAttribute(ByondRights requiredRights) { Roles = RightsHelper.RoleNames(requiredRights); + RightsType = Api.Rights.RightsType.Byond; } /// @@ -59,6 +68,7 @@ namespace Tgstation.Server.Host.Controllers public TgsAuthorizeAttribute(DreamMakerRights requiredRights) { Roles = RightsHelper.RoleNames(requiredRights); + RightsType = Api.Rights.RightsType.DreamMaker; } /// @@ -68,6 +78,7 @@ namespace Tgstation.Server.Host.Controllers public TgsAuthorizeAttribute(DreamDaemonRights requiredRights) { Roles = RightsHelper.RoleNames(requiredRights); + RightsType = Api.Rights.RightsType.DreamDaemon; } /// @@ -77,6 +88,7 @@ namespace Tgstation.Server.Host.Controllers public TgsAuthorizeAttribute(ChatBotRights requiredRights) { Roles = RightsHelper.RoleNames(requiredRights); + RightsType = Api.Rights.RightsType.ChatBots; } /// @@ -86,6 +98,7 @@ namespace Tgstation.Server.Host.Controllers public TgsAuthorizeAttribute(ConfigurationRights requiredRights) { Roles = RightsHelper.RoleNames(requiredRights); + RightsType = Api.Rights.RightsType.Configuration; } /// @@ -95,6 +108,7 @@ namespace Tgstation.Server.Host.Controllers public TgsAuthorizeAttribute(InstanceUserRights requiredRights) { Roles = RightsHelper.RoleNames(requiredRights); + RightsType = Api.Rights.RightsType.InstanceUser; } } } diff --git a/src/Tgstation.Server.Host/Controllers/TgsOpenApiFilters.cs b/src/Tgstation.Server.Host/Controllers/TgsOpenApiFilters.cs new file mode 100644 index 0000000000..b902770af5 --- /dev/null +++ b/src/Tgstation.Server.Host/Controllers/TgsOpenApiFilters.cs @@ -0,0 +1,227 @@ +using Microsoft.Net.Http.Headers; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Rights; + +namespace Tgstation.Server.Host.Controllers +{ + /// + /// and for the server. + /// + sealed class TgsOpenApiFilters : IOperationFilter, IDocumentFilter + { + /// + /// The name for password authentication. + /// + public const string PasswordSecuritySchemeId = "Password_Login_Scheme"; + + /// + /// The name for token authentication. + /// + public const string TokenSecuritySchemeId = "Token_Authorization_Scheme"; + + /// + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + if (operation == null) + throw new ArgumentNullException(nameof(operation)); + if (context == null) + throw new ArgumentNullException(nameof(context)); + + var authAttributes = context + .MethodInfo + .DeclaringType + .GetCustomAttributes(true) + .Union( + context + .MethodInfo + .GetCustomAttributes(true)) + .OfType(); + + if (authAttributes.Any()) + { + var tokenScheme = new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = TokenSecuritySchemeId + } + }; + + operation.Security = new List + { + new OpenApiSecurityRequirement + { + { + tokenScheme, + new List() + } + } + }; + + if (authAttributes.Any(attr => attr.RightsType.HasValue && RightsHelper.IsInstanceRight(attr.RightsType.Value))) + operation.Parameters.Add(new OpenApiParameter + { + Reference = new OpenApiReference + { + Type = ReferenceType.Header, + Id = ApiHeaders.InstanceIdHeader + } + }); + } + else + { + // HomeController.CreateToken + var passwordScheme = new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = PasswordSecuritySchemeId + } + }; + + operation.Security = new List + { + new OpenApiSecurityRequirement + { + { + passwordScheme, + new List() + } + } + }; + } + } + + /// + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + swaggerDoc.Components.Headers.Add(ApiHeaders.InstanceIdHeader, new OpenApiHeader + { + Description = "The instance ID being accessed", + Required = true, + Style = ParameterStyle.Simple + }); + + swaggerDoc.Components.Headers.Add(ApiHeaders.ApiVersionHeader, new OpenApiHeader + { + Description = "The API version being used in the form \"Tgstation.Server.Api/[API version]\"", + Required = true, + Style = ParameterStyle.Simple, + Example = new OpenApiString($"Tgstation.Server.Api/{ApiHeaders.Version}") + }); + + swaggerDoc.Components.Headers.Add(HeaderNames.UserAgent, new OpenApiHeader + { + Description = "The user agent of the calling client.", + Required = true, + Style = ParameterStyle.Simple, + Example = new OpenApiString("Your-user-agent/1.0.0.0") + }); + + foreach (var operation in swaggerDoc + .Paths + .SelectMany(path => path.Value.Operations) + .Select(kvp => kvp.Value)) + { + operation.Parameters.Add(new OpenApiParameter + { + Reference = new OpenApiReference + { + Type = ReferenceType.Header, + Id = ApiHeaders.ApiVersionHeader + } + }); + + operation.Parameters.Add(new OpenApiParameter + { + Reference = new OpenApiReference + { + Type = ReferenceType.Header, + Id = HeaderNames.UserAgent + } + }); + } + + var errorMessageContent = new Dictionary + { + { + ApiHeaders.ApplicationJson, + new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Reference = new OpenApiReference + { + Id = nameof(ErrorMessage), + Type = ReferenceType.Schema + } + } + } + } + }; + + void AddDefaultResponse(HttpStatusCode code, OpenApiResponse concrete) + { + string responseKey = $"{(int)code}"; + + swaggerDoc.Components.Responses.Add(responseKey, concrete); + + var referenceResponse = new OpenApiResponse + { + Reference = new OpenApiReference + { + Type = ReferenceType.Response, + Id = responseKey + } + }; + + foreach (var path in swaggerDoc.Paths) + foreach (var operation in path.Value.Operations) + operation.Value.Responses.TryAdd(responseKey, referenceResponse); + } + + AddDefaultResponse(HttpStatusCode.BadRequest, new OpenApiResponse + { + Description = "A badly formatted request was made. See error message for details.", + Content = errorMessageContent, + }); + + AddDefaultResponse(HttpStatusCode.Unauthorized, new OpenApiResponse + { + Description = "No/invalid token provided." + }); + + AddDefaultResponse(HttpStatusCode.Forbidden, new OpenApiResponse + { + Description = "User lacks sufficient permissions for the operation." + }); + + AddDefaultResponse(HttpStatusCode.Conflict, new OpenApiResponse + { + Description = "A data integrity check failed while performing the operation. See error message for details.", + Content = errorMessageContent + }); + + AddDefaultResponse(HttpStatusCode.InternalServerError, new OpenApiResponse + { + Description = "The server encountered an unhandled error. See error message for details.", + Content = errorMessageContent + }); + + AddDefaultResponse(HttpStatusCode.ServiceUnavailable, new OpenApiResponse + { + Description = "The server may be starting up or shutting down." + }); + } + } +} diff --git a/src/Tgstation.Server.Host/Controllers/UserController.cs b/src/Tgstation.Server.Host/Controllers/UserController.cs index e41f4f7380..8988c62cbb 100644 --- a/src/Tgstation.Server.Host/Controllers/UserController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserController.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Net; using System.Threading; @@ -19,10 +18,10 @@ using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.Controllers { /// - /// For managing s + /// for managing s. /// [Route(Routes.User)] - public sealed class UserController : ModelController + public sealed class UserController : ApiController { /// /// The for the @@ -53,7 +52,7 @@ namespace Tgstation.Server.Host.Controllers /// The value of /// The value of /// The containing the value of - public UserController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, ISystemIdentityFactory systemIdentityFactory, ICryptographySuite cryptographySuite, ILogger logger, IOptions generalConfigurationOptions) : base(databaseContext, authenticationContextFactory, logger, false) + public UserController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, ISystemIdentityFactory systemIdentityFactory, ICryptographySuite cryptographySuite, ILogger logger, IOptions generalConfigurationOptions) : base(databaseContext, authenticationContextFactory, logger, false, true) { this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory)); @@ -61,9 +60,47 @@ namespace Tgstation.Server.Host.Controllers generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); } - /// + /// + /// Check if a given has a valid specified. + /// + /// The to check. + /// if is valid, a otherwise. + BadRequestObjectResult CheckValidName(UserUpdate model) + { + if (model.Name != null && model.Name.Contains(':', StringComparison.InvariantCulture)) + return BadRequest(new ErrorMessage { Message = "Username must not contain colons!" }); + return null; + } + + /// + /// Attempt to change the password of a given . + /// + /// The to update. + /// The new password. + /// on success, if is too short. + BadRequestObjectResult TrySetPassword(Models.User dbUser, string newPassword) + { + if (newPassword.Length < generalConfiguration.MinimumPasswordLength) + return BadRequest(new ErrorMessage { Message = $"Password must be at least {generalConfiguration.MinimumPasswordLength} characters long!" }); + cryptographySuite.SetUserPassword(dbUser, newPassword, true); + return null; + } + + /// + /// Create a . + /// + /// The to create. + /// The for the operation. + /// A resulting in the of the operation. + /// created successfully. + /// The requested could not be loaded. + /// A system user was requested but this is not implemented on POSIX. + [HttpPut] [TgsAuthorize(AdministrationRights.WriteUsers)] - public override async Task Create([FromBody] UserUpdate model, CancellationToken cancellationToken) + [ProducesResponseType(typeof(Api.Models.User), 201)] + [ProducesResponseType(410)] + [ProducesResponseType(501)] + public async Task Create([FromBody] UserUpdate model, CancellationToken cancellationToken) { if (model == null) throw new ArgumentNullException(nameof(model)); @@ -78,6 +115,10 @@ namespace Tgstation.Server.Host.Controllers if (!(model.Name == null ^ model.SystemIdentifier == null)) return BadRequest(new ErrorMessage { Message = "User must have a name if and only if user has no system identifier!" }); + var fail = CheckValidName(model); + if (fail != null) + return fail; + var dbUser = new Models.User { AdministrationRights = model.AdministrationRights ?? AdministrationRights.None, @@ -107,9 +148,9 @@ namespace Tgstation.Server.Host.Controllers } else { - if (model.Password.Length < generalConfiguration.MinimumPasswordLength) - return BadRequest(new ErrorMessage { Message = String.Format(CultureInfo.InvariantCulture, "Password must be at least {0} characters long!", generalConfiguration.MinimumPasswordLength) }); - cryptographySuite.SetUserPassword(dbUser, model.Password, true); + var result = TrySetPassword(dbUser, model.Password); + if (result != null) + return result; } dbUser.CanonicalName = dbUser.Name.ToUpperInvariant(); @@ -121,32 +162,48 @@ namespace Tgstation.Server.Host.Controllers return StatusCode((int)HttpStatusCode.Created, dbUser.ToApi(true)); } - /// + /// + /// Update a . + /// + /// The to update. + /// The for the operation. + /// A resulting in the of the operation. + /// updated successfully. + /// Requested does not exist. + [HttpPost] [TgsAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword)] - public override async Task Update([FromBody] UserUpdate model, CancellationToken cancellationToken) + [ProducesResponseType(typeof(Api.Models.User), 200)] + [ProducesResponseType(404)] + public async Task Update([FromBody] UserUpdate model, CancellationToken cancellationToken) { if (model == null) throw new ArgumentNullException(nameof(model)); - var passwordEditOnly = !AuthenticationContext.User.AdministrationRights.Value.HasFlag(AdministrationRights.WriteUsers); + var callerAdministrationRights = (AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration); + var passwordEditOnly = !callerAdministrationRights.HasFlag(AdministrationRights.WriteUsers); - var originalUser = passwordEditOnly ? AuthenticationContext.User : await DatabaseContext.Users.Where(x => x.Id == model.Id) - .Include(x => x.CreatedBy) - .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + var originalUser = passwordEditOnly + ? AuthenticationContext.User + : await DatabaseContext.Users.Where(x => x.Id == model.Id) + .Include(x => x.CreatedBy) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); if (originalUser == default) return NotFound(); - if (passwordEditOnly && (model.Id != originalUser.Id || model.InstanceManagerRights.HasValue || model.AdministrationRights.HasValue || model.Enabled.HasValue || model.SystemIdentifier != null || model.Name != null)) + // Ensure they are only trying to edit password (system identity change will trigger a bad request) + if (passwordEditOnly && (model.Id != originalUser.Id || model.InstanceManagerRights.HasValue || model.AdministrationRights.HasValue || model.Enabled.HasValue || model.Name != null)) return Forbid(); + if (model.SystemIdentifier != null && model.SystemIdentifier != originalUser.SystemIdentifier) + return BadRequest(new ErrorMessage { Message = "Cannot change a user's system identifier!" }); + if (model.Password != null) { - if (originalUser.PasswordHash == null) - return BadRequest(new ErrorMessage { Message = "Cannot convert a system user to a password user!" }); - cryptographySuite.SetUserPassword(originalUser, model.Password, false); + var result = TrySetPassword(originalUser, model.Password); + if (result != null) + return result; } - else if (model.SystemIdentifier != null && model.SystemIdentifier != originalUser.SystemIdentifier) - return BadRequest(new ErrorMessage { Message = "Cannot change a user's system identifier!" }); if (model.Name != null && model.Name.ToUpperInvariant() != originalUser.CanonicalName) return BadRequest(new ErrorMessage { Message = "Can only change capitalization of a user's name!" }); @@ -154,23 +211,46 @@ namespace Tgstation.Server.Host.Controllers originalUser.InstanceManagerRights = model.InstanceManagerRights ?? originalUser.InstanceManagerRights; originalUser.AdministrationRights = model.AdministrationRights ?? originalUser.AdministrationRights; originalUser.Enabled = model.Enabled ?? originalUser.Enabled; + + var fail = CheckValidName(model); + if (fail != null) + return fail; + originalUser.Name = model.Name ?? originalUser.Name; await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); - return Json(model.Id == originalUser.Id || (AuthenticationContext.GetRight(RightsType.Administration) & (ulong)AdministrationRights.ReadUsers) != 0 ? originalUser.ToApi(true) : new Api.Models.User - { - Id = originalUser.Id - }); + // return id only if not a self update or and cannot read users + return Json( + model.Id == originalUser.Id + || callerAdministrationRights.HasFlag(AdministrationRights.ReadUsers) + ? originalUser.ToApi(true) + : new Api.Models.User + { + Id = originalUser.Id + }); } - /// + /// + /// Get information about the current . + /// + /// The of the operation. + /// The was retrieved successfully. + [HttpGet] [TgsAuthorize] - public override Task Read(CancellationToken cancellationToken) => Task.FromResult(Json(AuthenticationContext.User.ToApi(true))); + [ProducesResponseType(typeof(Api.Models.User), 200)] + public IActionResult Read() => Json(AuthenticationContext.User.ToApi(true)); - /// + /// + /// List all s in the server. + /// + /// The for the operation. + /// A resulting in the of the operation. + /// Retrieved s successfully. + [HttpGet(Routes.List)] [TgsAuthorize(AdministrationRights.ReadUsers)] - public override async Task List(CancellationToken cancellationToken) + [ProducesResponseType(typeof(IEnumerable), 200)] + public async Task List(CancellationToken cancellationToken) { var users = await DatabaseContext.Users .Include(x => x.CreatedBy) @@ -178,12 +258,22 @@ namespace Tgstation.Server.Host.Controllers return Json(users.Select(x => x.ToApi(true))); } - /// + /// + /// Get a specific . + /// + /// The to retrieve. + /// The for the operation. + /// A resulting in the of the operation. + /// The was retrieved successfully. + /// The does not exist. + [HttpGet("{id}")] [TgsAuthorize] - public override async Task GetId(long id, CancellationToken cancellationToken) + [ProducesResponseType(typeof(Api.Models.User), 200)] + [ProducesResponseType(404)] + public async Task GetId(long id, CancellationToken cancellationToken) { if (id == AuthenticationContext.User.Id) - return await Read(cancellationToken).ConfigureAwait(false); + return Read(); if (!((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers)) return Forbid(); diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 3e849b67a9..d3e7c9763b 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -5,12 +5,15 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; +using Microsoft.OpenApi.Models; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Serilog; @@ -19,14 +22,16 @@ using Serilog.Formatting.Display; using System; using System.Globalization; using System.IdentityModel.Tokens.Jwt; -using System.Reflection; +using System.Linq; using System.Threading.Tasks; +using Tgstation.Server.Api; using Tgstation.Server.Host.Components; using Tgstation.Server.Host.Components.Byond; using Tgstation.Server.Host.Components.Chat; using Tgstation.Server.Host.Components.Repository; using Tgstation.Server.Host.Components.Watchdog; using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Controllers; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; @@ -34,7 +39,7 @@ using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.Core { /// - #pragma warning disable CA1506 +#pragma warning disable CA1506 sealed class Application : IApplication { /// @@ -51,6 +56,11 @@ namespace Tgstation.Server.Host.Core /// readonly IConfiguration configuration; + /// + /// The for the . + /// + readonly IAssemblyInformationProvider assemblyInformationProvider; + /// /// The for the /// @@ -70,15 +80,20 @@ namespace Tgstation.Server.Host.Core /// Construct an /// /// The value of + /// The for the . /// The value of - public Application(IConfiguration configuration, Microsoft.AspNetCore.Hosting.IHostingEnvironment hostingEnvironment) + public Application( + IConfiguration configuration, + IAssemblyInformationProvider assemblyInformationProvider, + Microsoft.AspNetCore.Hosting.IHostingEnvironment hostingEnvironment) { this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); this.hostingEnvironment = hostingEnvironment ?? throw new ArgumentNullException(nameof(hostingEnvironment)); startupTcs = new TaskCompletionSource(); - Version = Assembly.GetExecutingAssembly().GetName().Version; + Version = assemblyInformationProvider.Name.Version; VersionString = String.Format(CultureInfo.InvariantCulture, "{0} v{1}", VersionPrefix, Version); } @@ -221,15 +236,61 @@ namespace Tgstation.Server.Host.Core JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); // add mvc, configure the json serializer settings - services.AddMvc().AddJsonOptions(options => - { - options.AllowInputFormatterExceptionMessages = true; - options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; - options.SerializerSettings.CheckAdditionalContent = true; - options.SerializerSettings.MissingMemberHandling = MissingMemberHandling.Error; - options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; - options.SerializerSettings.Converters = new[] { new VersionConverter() }; - }); + services + .AddMvc(options => + { + var dataAnnotationValidator = options.ModelValidatorProviders.Single(validator => validator.GetType().Name == "DataAnnotationsModelValidatorProvider"); + options.ModelValidatorProviders.Remove(dataAnnotationValidator); + }) + .SetCompatibilityVersion(CompatibilityVersion.Version_2_1) + .AddJsonOptions(options => + { + options.AllowInputFormatterExceptionMessages = true; + options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; + options.SerializerSettings.CheckAdditionalContent = true; + options.SerializerSettings.MissingMemberHandling = MissingMemberHandling.Error; + options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; + options.SerializerSettings.Converters = new[] { new VersionConverter() }; + }); + + if (hostingEnvironment.IsDevelopment()) + services.AddSwaggerGen( + c => + { + c.SwaggerDoc( + "v1", + new OpenApiInfo + { + Title = "TGS API", + Version = "v4" + }); + + // Important to do this before applying our own filters + // Otherwise we'll get NullReferenceExceptions on parameters to be setup in our document filter + var assemblyLocation = assemblyInformationProvider.Path; + var filePath = ioManager.ConcatPath(ioManager.GetDirectoryName(assemblyLocation), String.Concat(ioManager.GetFileNameWithoutExtension(assemblyLocation), ".xml")); + c.IncludeXmlComments(filePath); + + c.OperationFilter(); + c.DocumentFilter(); + + c.AddSecurityDefinition(TgsOpenApiFilters.PasswordSecuritySchemeId, new OpenApiSecurityScheme + { + In = ParameterLocation.Header, + Type = SecuritySchemeType.Http, + Name = HeaderNames.Authorization, + Scheme = ApiHeaders.BasicAuthenticationScheme + }); + + c.AddSecurityDefinition(TgsOpenApiFilters.TokenSecuritySchemeId, new OpenApiSecurityScheme + { + BearerFormat = "JWT", + In = ParameterLocation.Header, + Type = SecuritySchemeType.Http, + Name = HeaderNames.Authorization, + Scheme = ApiHeaders.JwtAuthenticationScheme + }); + }); // enable browser detection services.AddDetectionCore().AddBrowser(); @@ -352,17 +413,26 @@ namespace Tgstation.Server.Host.Core logger.LogTrace("Web Root: {0}", hostingEnvironment.WebRootPath); // attempt to restart the server if the configuration changes - if(serverControl.WatchdogPresent) + if (serverControl.WatchdogPresent) ChangeToken.OnChange(configuration.GetReloadToken, () => serverControl.Restart()); // setup the HTTP request pipeline + // Final point where we wrap exceptions in a 500 (ErrorMessage) response + applicationBuilder.UseServerErrorHandling(); // should anything after this throw an exception, catch it and display a detailed html page - applicationBuilder.UseDeveloperExceptionPage(); // it is not worth it to limit this, you should only ever get it if you're an authorized user + if (hostingEnvironment.IsDevelopment()) + applicationBuilder.UseDeveloperExceptionPage(); // it is not worth it to limit this, you should only ever get it if you're an authorized user // suppress OperationCancelledExceptions, they are just aborted HTTP requests applicationBuilder.UseCancelledRequestSuppression(); + if (hostingEnvironment.IsDevelopment()) + { + applicationBuilder.UseSwagger(); + applicationBuilder.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "TGS API V4")); + } + // Set up CORS based on configuration if necessary Action corsBuilder = null; if (controlPanelConfiguration.AllowAnyOrigin) diff --git a/src/Tgstation.Server.Host/Core/ApplicationBuilderExtensions.cs b/src/Tgstation.Server.Host/Core/ApplicationBuilderExtensions.cs index 971d018f73..a9290b8c01 100644 --- a/src/Tgstation.Server.Host/Core/ApplicationBuilderExtensions.cs +++ b/src/Tgstation.Server.Host/Core/ApplicationBuilderExtensions.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System; using System.Globalization; +using System.Net; using Tgstation.Server.Api.Models; namespace Tgstation.Server.Host.Core @@ -69,5 +70,39 @@ namespace Tgstation.Server.Host.Core } }); } + + /// + /// Suppress all in flight exceptions with error 500. + /// + /// The to configure + public static void UseServerErrorHandling(this IApplicationBuilder applicationBuilder) + { + if (applicationBuilder == null) + throw new ArgumentNullException(nameof(applicationBuilder)); + applicationBuilder.Use(async (context, next) => + { + var logger = GetLogger(context); + try + { + await next().ConfigureAwait(false); + } + catch (Exception e) + { + logger.LogError("Failed request: {0}", e); + await new ObjectResult( + new ErrorMessage + { + Message = $"A unhandled exception has occurred: {e}" + }) + { + StatusCode = (int)HttpStatusCode.InternalServerError + } + .ExecuteResultAsync(new ActionContext + { + HttpContext = context + }).ConfigureAwait(false); + } + }); + } } } diff --git a/src/Tgstation.Server.Host/Core/AssemblyInformationProvider.cs b/src/Tgstation.Server.Host/Core/AssemblyInformationProvider.cs new file mode 100644 index 0000000000..41fe7919ca --- /dev/null +++ b/src/Tgstation.Server.Host/Core/AssemblyInformationProvider.cs @@ -0,0 +1,24 @@ +using System.Reflection; + +namespace Tgstation.Server.Host.Core +{ + /// + sealed class AssemblyInformationProvider : IAssemblyInformationProvider + { + /// + public string Path { get; } + + /// + public AssemblyName Name { get; } + + /// + /// Initializes a new instance of the . + /// + public AssemblyInformationProvider() + { + Assembly assembly = Assembly.GetExecutingAssembly(); + Path = assembly.Location; + Name = assembly.GetName(); + } + } +} diff --git a/src/Tgstation.Server.Host/Core/IAssemblyInformationProvider.cs b/src/Tgstation.Server.Host/Core/IAssemblyInformationProvider.cs new file mode 100644 index 0000000000..8112deb276 --- /dev/null +++ b/src/Tgstation.Server.Host/Core/IAssemblyInformationProvider.cs @@ -0,0 +1,20 @@ +using System.Reflection; + +namespace Tgstation.Server.Host.Core +{ + /// + /// For retrieving the 's location. + /// + interface IAssemblyInformationProvider + { + /// + /// Gets the path to the executing assembly. + /// + string Path { get; } + + /// + /// Gets the . + /// + AssemblyName Name { get; } + } +} diff --git a/src/Tgstation.Server.Host/Core/JobManager.cs b/src/Tgstation.Server.Host/Core/JobManager.cs index dbbc1eba6d..42b9bba454 100644 --- a/src/Tgstation.Server.Host/Core/JobManager.cs +++ b/src/Tgstation.Server.Host/Core/JobManager.cs @@ -23,7 +23,7 @@ namespace Tgstation.Server.Host.Core readonly ILogger logger; /// - /// of to running s + /// of s to running s /// readonly Dictionary jobs; diff --git a/src/Tgstation.Server.Host/Core/SetupWizard.cs b/src/Tgstation.Server.Host/Core/SetupWizard.cs index f921df1515..d461cbff98 100644 --- a/src/Tgstation.Server.Host/Core/SetupWizard.cs +++ b/src/Tgstation.Server.Host/Core/SetupWizard.cs @@ -10,6 +10,7 @@ using System.Data.SqlClient; using System.Globalization; using System.Linq; using System.Text; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Host.Configuration; @@ -163,11 +164,39 @@ namespace Tgstation.Server.Host.Core } while (true); - await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); - await console.WriteAsync("Enter the server's address and port (blank for local): ", false, cancellationToken).ConfigureAwait(false); - var serverAddress = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false); - if (String.IsNullOrWhiteSpace(serverAddress)) - serverAddress = null; + string serverAddress; + uint? mySQLServerPort = null; + do + { + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync("Enter the server's address and port [: or ] (blank for local): ", false, cancellationToken).ConfigureAwait(false); + serverAddress = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false); + if (String.IsNullOrWhiteSpace(serverAddress)) + { + serverAddress = null; + break; + } + else if (databaseConfiguration.DatabaseType != DatabaseType.SqlServer) + { + var m = Regex.Match(serverAddress, @"^(?.+):(?.+)$"); + if (m.Success) + { + serverAddress = m.Groups["server"].Value; + if (uint.TryParse(m.Groups["port"].Value, out uint port)) + { + mySQLServerPort = port; + break; + } + else + { + await console.WriteAsync($@"Failed to parse port ""{m.Groups["port"].Value}"", please try again.", true, cancellationToken).ConfigureAwait(false); + } + } + else break; + } + else break; + } + while (true); await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); await console.WriteAsync("Enter the database name (Can be from previous installation. Otherwise, should not exist): ", false, cancellationToken).ConfigureAwait(false); @@ -243,6 +272,9 @@ namespace Tgstation.Server.Host.Core Password = password }; + if (mySQLServerPort.HasValue) + csb.Port = mySQLServerPort.Value; + CreateTestConnection(csb.ConnectionString); csb.Database = databaseName; databaseConfiguration.ConnectionString = csb.ConnectionString; @@ -611,41 +643,60 @@ namespace Tgstation.Server.Host.Core } var userConfigFileName = String.Format(CultureInfo.InvariantCulture, "appsettings.{0}.json", hostingEnvironment.EnvironmentName); - var exists = await ioManager.FileExists(userConfigFileName, cancellationToken).ConfigureAwait(false); - bool shouldRunBasedOnAutodetect; - if (exists) + async Task HandleSetupCancel() { - var bytes = await ioManager.ReadAllBytes(userConfigFileName, cancellationToken).ConfigureAwait(false); - var contents = Encoding.UTF8.GetString(bytes); - var existingConfigIsEmpty = String.IsNullOrWhiteSpace(contents) || contents.Trim() == "{}"; - logger.LogTrace("Configuration json detected. Empty: {0}", existingConfigIsEmpty); - shouldRunBasedOnAutodetect = existingConfigIsEmpty; - } - else - { - shouldRunBasedOnAutodetect = true; - logger.LogTrace("No configuration json detected"); + await console.WriteAsync(String.Empty, true, default).ConfigureAwait(false); + await console.WriteAsync("Aborting setup!", true, default).ConfigureAwait(false); } - if (!shouldRunBasedOnAutodetect) - { - if (forceRun) + // Link passed cancellationToken with cancel key press + Task finalTask = Task.CompletedTask; + using (var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, console.CancelKeyPress)) + using ((cancellationToken = cts.Token).Register(() => finalTask = HandleSetupCancel())) + try { - logger.LogTrace("Asking user to bypass due to force run request..."); - await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "The configuration settings are requesting the setup wizard be run, but you already appear to have a configuration file ({0})!", userConfigFileName), true, cancellationToken).ConfigureAwait(false); + var exists = await ioManager.FileExists(userConfigFileName, cancellationToken).ConfigureAwait(false); - forceRun = await PromptYesNo("Continue running setup wizard? (y/n): ", cancellationToken).ConfigureAwait(false); + bool shouldRunBasedOnAutodetect; + if (exists) + { + var bytes = await ioManager.ReadAllBytes(userConfigFileName, cancellationToken).ConfigureAwait(false); + var contents = Encoding.UTF8.GetString(bytes); + var existingConfigIsEmpty = String.IsNullOrWhiteSpace(contents) || contents.Trim() == "{}"; + logger.LogTrace("Configuration json detected. Empty: {0}", existingConfigIsEmpty); + shouldRunBasedOnAutodetect = existingConfigIsEmpty; + } + else + { + shouldRunBasedOnAutodetect = true; + logger.LogTrace("No configuration json detected"); + } + + if (!shouldRunBasedOnAutodetect) + { + if (forceRun) + { + logger.LogTrace("Asking user to bypass due to force run request..."); + await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "The configuration settings are requesting the setup wizard be run, but you already appear to have a configuration file ({0})!", userConfigFileName), true, cancellationToken).ConfigureAwait(false); + + forceRun = await PromptYesNo("Continue running setup wizard? (y/n): ", cancellationToken).ConfigureAwait(false); + } + + if (!forceRun) + return false; + } + + // flush the logs to prevent console conflicts + await asyncDelayer.Delay(TimeSpan.FromSeconds(1), cancellationToken).ConfigureAwait(false); + + await RunWizard(userConfigFileName, cancellationToken).ConfigureAwait(false); + } + finally + { + await finalTask.ConfigureAwait(false); } - if (!forceRun) - return false; - } - - // flush the logs to prevent console conflicts - await asyncDelayer.Delay(TimeSpan.FromSeconds(1), cancellationToken).ConfigureAwait(false); - - await RunWizard(userConfigFileName, cancellationToken).ConfigureAwait(false); return true; } } diff --git a/src/Tgstation.Server.Host/IO/Console.cs b/src/Tgstation.Server.Host/IO/Console.cs index 9f1fceba80..0d90d54c25 100644 --- a/src/Tgstation.Server.Host/IO/Console.cs +++ b/src/Tgstation.Server.Host/IO/Console.cs @@ -6,11 +6,50 @@ using System.Threading.Tasks; namespace Tgstation.Server.Host.IO { /// - sealed class Console : IConsole + sealed class Console : IConsole, IDisposable { /// public bool Available => Environment.UserInteractive; + /// + public CancellationToken CancelKeyPress => cancelKeyCts.Token; + + /// + /// The for . + /// + readonly CancellationTokenSource cancelKeyCts; + + /// + /// If the was disposed; + /// + bool disposed; + + /// + /// Initializes a new instance of the . + /// + public Console() + { + cancelKeyCts = new CancellationTokenSource(); + System.Console.CancelKeyPress += (sender, e) => + { + lock (cancelKeyCts) + { + if (!disposed) + cancelKeyCts.Cancel(); + } + }; + } + + /// + public void Dispose() + { + lock (cancelKeyCts) + { + cancelKeyCts.Dispose(); + disposed = true; + } + } + void CheckAvailable() { if (!Available) diff --git a/src/Tgstation.Server.Host/IO/IConsole.cs b/src/Tgstation.Server.Host/IO/IConsole.cs index 119b19d5dc..405ffb425b 100644 --- a/src/Tgstation.Server.Host/IO/IConsole.cs +++ b/src/Tgstation.Server.Host/IO/IConsole.cs @@ -13,6 +13,11 @@ namespace Tgstation.Server.Host.IO /// bool Available { get; } + /// + /// Gets a that triggers if Crtl+C or an equivalent is pressed. + /// + CancellationToken CancelKeyPress { get; } + /// /// Write some to the /// diff --git a/src/Tgstation.Server.Host/Models/CompileJob.cs b/src/Tgstation.Server.Host/Models/CompileJob.cs index 3fadc5b182..c2398e017c 100644 --- a/src/Tgstation.Server.Host/Models/CompileJob.cs +++ b/src/Tgstation.Server.Host/Models/CompileJob.cs @@ -13,7 +13,7 @@ namespace Tgstation.Server.Host.Models public Job Job { get; set; } /// - /// The of + /// The of /// public long JobId { get; set; } diff --git a/src/Tgstation.Server.Host/Models/Migrations/DesignTimeDbContextFactoryHelpers.cs b/src/Tgstation.Server.Host/Models/Migrations/DesignTimeDbContextFactoryHelpers.cs index cb4e730d1e..2de82a18bf 100644 --- a/src/Tgstation.Server.Host/Models/Migrations/DesignTimeDbContextFactoryHelpers.cs +++ b/src/Tgstation.Server.Host/Models/Migrations/DesignTimeDbContextFactoryHelpers.cs @@ -1,8 +1,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; -using System.IO; -using System.Reflection; using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Core; +using Tgstation.Server.Host.IO; namespace Tgstation.Server.Host.Models.Migrations { @@ -28,7 +28,9 @@ namespace Tgstation.Server.Host.Models.Migrations public static IOptions GetDbContextOptions() { var builder = new ConfigurationBuilder(); - builder.SetBasePath(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)); + var assemblyInfoProvider = new AssemblyInformationProvider(); + var ioManager = new DefaultIOManager(); + builder.SetBasePath(ioManager.GetDirectoryName(assemblyInfoProvider.Path)); builder.AddJsonFile(RootJson); builder.AddJsonFile(DevJson); var configuration = builder.Build(); diff --git a/src/Tgstation.Server.Host/Program.cs b/src/Tgstation.Server.Host/Program.cs index 83895df62b..0e81f1d1f9 100644 --- a/src/Tgstation.Server.Host/Program.cs +++ b/src/Tgstation.Server.Host/Program.cs @@ -16,7 +16,7 @@ namespace Tgstation.Server.Host /// The to use /// #pragma warning disable SA1401 // Fields must be private - internal static IServerFactory ServerFactory = new ServerFactory(); + internal static IServerFactory ServerFactory = Host.ServerFactory.CreateDefault(); #pragma warning restore SA1401 // Fields must be private /// diff --git a/src/Tgstation.Server.Host/Security/TokenFactory.cs b/src/Tgstation.Server.Host/Security/TokenFactory.cs index 2974f6d3b1..3023189b84 100644 --- a/src/Tgstation.Server.Host/Security/TokenFactory.cs +++ b/src/Tgstation.Server.Host/Security/TokenFactory.cs @@ -2,7 +2,6 @@ using System; using System.Globalization; using System.IdentityModel.Tokens.Jwt; -using System.Reflection; using System.Security.Claims; using System.Threading; using System.Threading.Tasks; @@ -42,7 +41,11 @@ namespace Tgstation.Server.Host.Security /// /// The value of /// The used for generating the - public TokenFactory(IAsyncDelayer asyncDelayer, ICryptographySuite cryptographySuite) + /// The used to generate the issuer name. + public TokenFactory( + IAsyncDelayer asyncDelayer, + ICryptographySuite cryptographySuite, + IAssemblyInformationProvider assemblyInformationProvider) { ValidationParameters = new TokenValidationParameters { @@ -50,7 +53,7 @@ namespace Tgstation.Server.Host.Security IssuerSigningKey = new SymmetricSecurityKey(cryptographySuite.GetSecureBytes(TokenSigningKeyByteAmount)), ValidateIssuer = true, - ValidIssuer = Assembly.GetExecutingAssembly().GetName().Name, + ValidIssuer = assemblyInformationProvider.Name.Name, ValidateLifetime = true, ValidateAudience = true, diff --git a/src/Tgstation.Server.Host/Server.cs b/src/Tgstation.Server.Host/Server.cs index 45a683a91e..7dcf93d719 100644 --- a/src/Tgstation.Server.Host/Server.cs +++ b/src/Tgstation.Server.Host/Server.cs @@ -30,6 +30,11 @@ namespace Tgstation.Server.Host /// readonly IWebHostBuilder webHostBuilder; + /// + /// The for the . + /// + readonly IIOManager ioManager; + /// /// The s to run when the restarts /// @@ -69,10 +74,12 @@ namespace Tgstation.Server.Host /// Construct a /// /// The value of + /// The value of . /// The value of - public Server(IWebHostBuilder webHostBuilder, string updatePath) + public Server(IWebHostBuilder webHostBuilder, IIOManager ioManager, string updatePath) { this.webHostBuilder = webHostBuilder ?? throw new ArgumentNullException(nameof(webHostBuilder)); + this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); this.updatePath = updatePath; webHostBuilder.ConfigureServices(serviceCollection => serviceCollection.AddSingleton(this)); diff --git a/src/Tgstation.Server.Host/ServerFactory.cs b/src/Tgstation.Server.Host/ServerFactory.cs index d06f20fa93..57c46c1ae2 100644 --- a/src/Tgstation.Server.Host/ServerFactory.cs +++ b/src/Tgstation.Server.Host/ServerFactory.cs @@ -1,29 +1,65 @@ using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using System; using System.IO; -using System.Reflection; using Tgstation.Server.Host.Core; +using Tgstation.Server.Host.IO; namespace Tgstation.Server.Host { /// public sealed class ServerFactory : IServerFactory { + /// + /// The for the . + /// + readonly IAssemblyInformationProvider assemblyInformationProvider; + + /// + /// The for the . + /// + readonly IIOManager ioManager; + + /// + /// Create the default . + /// + /// A new with the default settings. + public static IServerFactory CreateDefault() + => new ServerFactory( + new AssemblyInformationProvider(), + new DefaultIOManager()); + + /// + /// Initializes a new instance of the . + /// + /// The value of . + /// The value of . + internal ServerFactory(IAssemblyInformationProvider assemblyInformationProvider, IIOManager ioManager) + { + this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); + this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); + } + /// public IServer CreateServer(string[] args, string updatePath) { var webHost = WebHost.CreateDefaultBuilder(args ?? throw new ArgumentNullException(nameof(args))) .ConfigureAppConfiguration((context, configurationBuilder) => configurationBuilder.SetBasePath(Directory.GetCurrentDirectory())) + .ConfigureServices(serviceCollection => + { + serviceCollection.AddSingleton(ioManager); + serviceCollection.AddSingleton(assemblyInformationProvider); + }) .UseStartup() .SuppressStatusMessages(true) .UseShutdownTimeout(TimeSpan.FromMinutes(1)); if(updatePath != null) - webHost.UseContentRoot(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)); + webHost.UseContentRoot(Path.GetDirectoryName(assemblyInformationProvider.Path)); - return new Server(webHost, updatePath); + return new Server(webHost, ioManager, updatePath); } } } diff --git a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj index c7fb8a6fb4..2eba443cd5 100644 --- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj +++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj @@ -83,6 +83,7 @@ all runtime; build; native; contentfiles; analyzers + diff --git a/tests/Tgstation.Server.Host.Tests/Components/Byond/TestPosixByondInstaller.cs b/tests/Tgstation.Server.Host.Tests/Components/Byond/TestPosixByondInstaller.cs index e2fb2cff0f..b6337b1ebd 100644 --- a/tests/Tgstation.Server.Host.Tests/Components/Byond/TestPosixByondInstaller.cs +++ b/tests/Tgstation.Server.Host.Tests/Components/Byond/TestPosixByondInstaller.cs @@ -14,22 +14,22 @@ namespace Tgstation.Server.Host.Components.Byond.Tests public void TestConstruction() { Assert.ThrowsException(() => new PosixByondInstaller(null, null, null)); - var mockIOManager = new Mock(); - Assert.ThrowsException(() => new PosixByondInstaller(mockIOManager.Object, null, null)); var mockPostWriteHandler = new Mock(); - Assert.ThrowsException(() => new PosixByondInstaller(mockIOManager.Object, mockPostWriteHandler.Object, null)); + Assert.ThrowsException(() => new PosixByondInstaller(mockPostWriteHandler.Object, null, null)); + var mockIOManager = new Mock(); + Assert.ThrowsException(() => new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, null)); var mockLogger = new Mock>(); - new PosixByondInstaller(mockIOManager.Object, mockPostWriteHandler.Object, mockLogger.Object); + new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, mockLogger.Object); } [TestMethod] public async Task TestCacheClean() { - var mockIOManager = new Mock(); var mockPostWriteHandler = new Mock(); + var mockIOManager = new Mock(); var mockLogger = new Mock>(); - var installer = new PosixByondInstaller(mockIOManager.Object, mockPostWriteHandler.Object, mockLogger.Object); + var installer = new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, mockLogger.Object); const string ByondCachePath = "~/.byond/cache"; @@ -37,7 +37,7 @@ namespace Tgstation.Server.Host.Components.Byond.Tests await installer.CleanCache(default); - mockIOManager.Verify(); + mockPostWriteHandler.Verify(); mockIOManager.Setup(x => x.DeleteDirectory(ByondCachePath, default)).Throws(new OperationCanceledException()).Verifiable(); @@ -60,7 +60,7 @@ namespace Tgstation.Server.Host.Components.Byond.Tests var mockIOManager = new Mock(); var mockPostWriteHandler = new Mock(); var mockLogger = new Mock>(); - var installer = new PosixByondInstaller(mockIOManager.Object, mockPostWriteHandler.Object, mockLogger.Object); + var installer = new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, mockLogger.Object); await Assert.ThrowsExceptionAsync(() => installer.DownloadVersion(null, default)).ConfigureAwait(false); @@ -79,7 +79,7 @@ namespace Tgstation.Server.Host.Components.Byond.Tests var mockIOManager = new Mock(); var mockPostWriteHandler = new Mock(); var mockLogger = new Mock>(); - var installer = new PosixByondInstaller(mockIOManager.Object, mockPostWriteHandler.Object, mockLogger.Object); + var installer = new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, mockLogger.Object); const string FakePath = "fake"; await Assert.ThrowsExceptionAsync(() => installer.InstallByond(null, null, default)).ConfigureAwait(false); diff --git a/tests/Tgstation.Server.Host.Tests/Core/TestApplication.cs b/tests/Tgstation.Server.Host.Tests/Core/TestApplication.cs index 94eba126e5..d19f8c3aa8 100644 --- a/tests/Tgstation.Server.Host.Tests/Core/TestApplication.cs +++ b/tests/Tgstation.Server.Host.Tests/Core/TestApplication.cs @@ -23,13 +23,18 @@ namespace Tgstation.Server.Host.Core.Tests [TestMethod] public void TestMethodThrows() { - Assert.ThrowsException(() => new Application(null, null)); + Assert.ThrowsException(() => new Application(null, null, null)); var mockConfiguration = new Mock(); - Assert.ThrowsException(() => new Application(mockConfiguration.Object, null)); + Assert.ThrowsException(() => new Application(mockConfiguration.Object, null, null)); + + var mockAssemblyInfo = new Mock(); + mockAssemblyInfo.SetupGet(x => x.Name).Returns(typeof(Application).Assembly.GetName()); + + Assert.ThrowsException(() => new Application(mockConfiguration.Object, mockAssemblyInfo.Object, null)); var mockHostingEnvironment = new Mock(); - var app = new Application(mockConfiguration.Object, mockHostingEnvironment.Object); + var app = new Application(mockConfiguration.Object, mockAssemblyInfo.Object, mockHostingEnvironment.Object); Assert.ThrowsException(() => app.ConfigureServices(null)); Assert.ThrowsException(() => app.Configure(null, null, null, null, null)); @@ -69,11 +74,11 @@ namespace Tgstation.Server.Host.Core.Tests public void TestConfigureServicesThrowsWhenSetupWizardConfigurationDemands() { var mockConfiguration = new Mock(); - Assert.ThrowsException(() => new Application(mockConfiguration.Object, null)); - + var mockAssemblyInfo = new Mock(); + mockAssemblyInfo.SetupGet(x => x.Name).Returns(typeof(Application).Assembly.GetName()); var mockHostingEnvironment = new Mock(); - var app = new Application(mockConfiguration.Object, mockHostingEnvironment.Object); + var app = new Application(mockConfiguration.Object, mockAssemblyInfo.Object, mockHostingEnvironment.Object); var mockOptions = new Mock>(); mockOptions.SetupGet(x => x.Value).Returns(new GeneralConfiguration diff --git a/tests/Tgstation.Server.Host.Tests/TestServerFactory.cs b/tests/Tgstation.Server.Host.Tests/TestServerFactory.cs index 1334ae87c9..eede3ff41c 100644 --- a/tests/Tgstation.Server.Host.Tests/TestServerFactory.cs +++ b/tests/Tgstation.Server.Host.Tests/TestServerFactory.cs @@ -1,5 +1,8 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; using System; +using Tgstation.Server.Host.Core; +using Tgstation.Server.Host.IO; namespace Tgstation.Server.Host.Tests { @@ -9,10 +12,20 @@ namespace Tgstation.Server.Host.Tests [TestClass] public sealed class TestServerFactory { + [TestMethod] + public void TestContructor() + { + Assert.ThrowsException(() => new ServerFactory(null, null)); + IAssemblyInformationProvider assemblyInformationProvider = Mock.Of(); + Assert.ThrowsException(() => new ServerFactory(assemblyInformationProvider, null)); + IIOManager ioManager = Mock.Of(); + new ServerFactory(assemblyInformationProvider, ioManager); + } + [TestMethod] public void TestWorksWithoutUpdatePath() { - var factory = new ServerFactory(); + var factory = ServerFactory.CreateDefault(); Assert.ThrowsException(() => factory.CreateServer(null, null)); factory.CreateServer(Array.Empty(), null); @@ -21,7 +34,7 @@ namespace Tgstation.Server.Host.Tests [TestMethod] public void TestWorksWithUpdatePath() { - var factory = new ServerFactory(); + var factory = ServerFactory.CreateDefault(); const string Path = "/test"; Assert.ThrowsException(() => factory.CreateServer(null, null)); diff --git a/tests/Tgstation.Server.Tests/IntegrationTest.cs b/tests/Tgstation.Server.Tests/IntegrationTest.cs index d5b1fe13ef..355d25b031 100644 --- a/tests/Tgstation.Server.Tests/IntegrationTest.cs +++ b/tests/Tgstation.Server.Tests/IntegrationTest.cs @@ -29,7 +29,7 @@ namespace Tgstation.Server.Tests try { var updatePath = Path.Combine(updatePathRoot, Guid.NewGuid().ToString()); - var server = new TestingServer(updatePath); + var server = new TestingServer(clientFactory, updatePath); using (var serverCts = new CancellationTokenSource()) { var cancellationToken = serverCts.Token; @@ -98,7 +98,7 @@ namespace Tgstation.Server.Tests [TestCategory("SkipWhenLiveUnitTesting")] public async Task TestStandardOperation() { - var server = new TestingServer(null); + var server = new TestingServer(clientFactory, null); using (var serverCts = new CancellationTokenSource()) { var cancellationToken = serverCts.Token; diff --git a/tests/Tgstation.Server.Tests/TestingServer.cs b/tests/Tgstation.Server.Tests/TestingServer.cs index f75fbbea6e..29ece7cbca 100644 --- a/tests/Tgstation.Server.Tests/TestingServer.cs +++ b/tests/Tgstation.Server.Tests/TestingServer.cs @@ -3,8 +3,11 @@ using System; using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Net; using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Client; using Tgstation.Server.Host; using Tgstation.Server.Host.Configuration; @@ -19,8 +22,14 @@ namespace Tgstation.Server.Tests readonly IServer realServer; - public TestingServer(string updatePath) + readonly IServerClientFactory serverClientFactory; + + readonly bool dumpOpenAPISpecpath; + + public TestingServer(IServerClientFactory serverClientFactory, string updatePath) { + this.serverClientFactory = serverClientFactory; + Directory = Path.GetTempFileName(); File.Delete(Directory); System.IO.Directory.CreateDirectory(Directory); @@ -31,6 +40,7 @@ namespace Tgstation.Server.Tests var databaseType = Environment.GetEnvironmentVariable("TGS4_TEST_DATABASE_TYPE"); var connectionString = Environment.GetEnvironmentVariable("TGS4_TEST_CONNECTION_STRING"); var gitHubAccessToken = Environment.GetEnvironmentVariable("TGS4_TEST_GITHUB_TOKEN"); + var dumpOpenAPISpecPathEnvVar = Environment.GetEnvironmentVariable("TGS4_TEST_DUMP_API_SPEC"); if (String.IsNullOrEmpty(databaseType)) Assert.Inconclusive("No database type configured in env var TGS4_TEST_DATABASE_TYPE!"); @@ -40,6 +50,8 @@ namespace Tgstation.Server.Tests if (String.IsNullOrEmpty(gitHubAccessToken)) Console.WriteLine("WARNING: No GitHub access token configured, test may fail due to rate limits!"); + + dumpOpenAPISpecpath = !String.IsNullOrEmpty(dumpOpenAPISpecPathEnvVar); var args = new List() { @@ -53,7 +65,10 @@ namespace Tgstation.Server.Tests if (!String.IsNullOrEmpty(gitHubAccessToken)) args.Add(String.Format(CultureInfo.InvariantCulture, "General:GitHubAccessToken={0}", gitHubAccessToken)); - realServer = new ServerFactory().CreateServer(args.ToArray(), updatePath); + if (dumpOpenAPISpecpath) + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Development"); + + realServer = ServerFactory.CreateDefault().CreateServer(args.ToArray(), updatePath); } public void Dispose() @@ -61,6 +76,41 @@ namespace Tgstation.Server.Tests System.IO.Directory.Delete(Directory, true); } - public Task RunAsync(CancellationToken cancellationToken) => realServer.RunAsync(cancellationToken); + public async Task RunAsync(CancellationToken cancellationToken) + { + Task runTask = realServer.RunAsync(cancellationToken); + + if (dumpOpenAPISpecpath) + { + var giveUpAt = DateTimeOffset.Now.AddSeconds(60); + do + { + try + { + var client = await serverClientFactory.CreateServerClient(Url, User.AdminName, User.DefaultAdminPassword).ConfigureAwait(false); + break; + } + catch (ServiceUnavailableException) + { + //migrating, to be expected + if (DateTimeOffset.Now > giveUpAt) + throw; + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + } while (true); + + // Dump swagger to disk + // This is purely for CI + var webRequest = WebRequest.Create(Url.ToString() + "swagger/v1/swagger.json"); + using (var response = webRequest.GetResponse()) + using (var content = response.GetResponseStream()) + using (var output = new FileStream(@"C:\swagger.json", FileMode.Create)) + { + await content.CopyToAsync(output); + } + } + + await runTask; + } } }