diff --git a/src/Tgstation.Server.Api/ApiHeaders.cs b/src/Tgstation.Server.Api/ApiHeaders.cs index 3758ec9b34..9308ad9976 100644 --- a/src/Tgstation.Server.Api/ApiHeaders.cs +++ b/src/Tgstation.Server.Api/ApiHeaders.cs @@ -3,9 +3,9 @@ using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Net.Http.Headers; +using System.Net.Mime; using System.Reflection; using System.Text; @@ -16,20 +16,15 @@ namespace Tgstation.Server.Api /// public sealed class ApiHeaders { - /// - /// TODO: Remove this when we upgrade to .NET Standard 2.1 - /// - public const string ApplicationJson = "application/json"; - /// /// The header key /// - public const string ApiVersionHeader = "api"; + public const string ApiVersionHeader = "Api"; /// /// The header key /// - public const string InstanceIdHeader = "instance"; + public const string InstanceIdHeader = "Instance"; /// /// The JWT authentication header scheme @@ -41,16 +36,6 @@ namespace Tgstation.Server.Api /// public const string BasicAuthenticationScheme = "basic"; - /// - /// The header key - /// - const string UsernameHeader = "username"; - - /// - /// The basic authentication header scheme - /// - const string PasswordAuthenticationScheme = "password"; - /// /// The current /// @@ -141,92 +126,105 @@ namespace Tgstation.Server.Api /// Construct and validates from a set of /// /// The containing the + /// Thrown if the constitue invalid . public ApiHeaders(RequestHeaders requestHeaders) { if (requestHeaders == null) throw new ArgumentNullException(nameof(requestHeaders)); - var jsonAccept = new Microsoft.Net.Http.Headers.MediaTypeHeaderValue(ApplicationJson); - if (!requestHeaders.Accept.Any(x => x.MediaType == jsonAccept.MediaType)) - throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Client does not accept {0}!", ApplicationJson)); + var badHeaders = HeaderTypes.None; + var errorBuilder = new StringBuilder(); + + void AddError(HeaderTypes headerType, string message) + { + if (badHeaders != HeaderTypes.None) + errorBuilder.Append(Environment.NewLine); + badHeaders |= headerType; + errorBuilder.Append(message); + } + + var jsonAccept = new Microsoft.Net.Http.Headers.MediaTypeHeaderValue(MediaTypeNames.Application.Json); + if (!requestHeaders.Accept.Any(x => jsonAccept.IsSubsetOf(x))) + AddError(HeaderTypes.Accept, $"Client does not accept {MediaTypeNames.Application.Json}!"); if (!requestHeaders.Headers.TryGetValue(HeaderNames.UserAgent, out var userAgentValues) || userAgentValues.Count == 0) - throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Missing {0} headers!", HeaderNames.UserAgent)); - - RawUserAgent = userAgentValues.First(); - if (String.IsNullOrWhiteSpace(RawUserAgent)) - throw new InvalidOperationException("Malformed client User-Agent!"); + AddError(HeaderTypes.UserAgent, $"Missing {HeaderNames.UserAgent} header!"); + else + { + RawUserAgent = userAgentValues.First(); + if (String.IsNullOrWhiteSpace(RawUserAgent)) + AddError(HeaderTypes.UserAgent, $"Malformed {HeaderNames.UserAgent} header!"); + } // make sure the api header matches ours + Version? apiVersion = null; if (!requestHeaders.Headers.TryGetValue(ApiVersionHeader, out var apiUserAgentHeaderValues) || !ProductInfoHeaderValue.TryParse(apiUserAgentHeaderValues.FirstOrDefault(), out var apiUserAgent) || apiUserAgent.Product.Name != AssemblyName.Name) - throw new InvalidOperationException("Missing API version!"); - - if (!Version.TryParse(apiUserAgent.Product.Version, out var apiVersion)) - throw new InvalidOperationException("Malformed API version!"); - - ApiVersion = apiVersion.Semver(); + AddError(HeaderTypes.Api, $"Missing {ApiVersionHeader} header!"); + else if (!Version.TryParse(apiUserAgent.Product.Version, out apiVersion)) + AddError(HeaderTypes.Api, $"Malformed {ApiVersionHeader} header!"); if (!requestHeaders.Headers.TryGetValue(HeaderNames.Authorization, out StringValues authorization)) - throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Missing {0} header!", HeaderNames.Authorization)); - var auth = authorization.First(); - var splits = new List(auth.Split(' ')); - var scheme = splits.First(); - if (String.IsNullOrWhiteSpace(scheme)) - throw new InvalidOperationException("Missing authentication scheme!"); - - splits.RemoveAt(0); - var parameter = String.Concat(splits); - if (String.IsNullOrEmpty(parameter)) - throw new InvalidOperationException("Missing authentication parameter!"); - - if (requestHeaders.Headers.TryGetValue(InstanceIdHeader, out var instanceIdValues)) + AddError(HeaderTypes.Authorization, $"Missing {HeaderNames.Authorization} header!"); + else { - var instanceIdString = instanceIdValues.FirstOrDefault(); - if (instanceIdString != default && Int64.TryParse(instanceIdString, out var instanceId)) - InstanceId = instanceId; - } + var auth = authorization.First(); + var splits = new List(auth.Split(' ')); + var scheme = splits.First(); + if (String.IsNullOrWhiteSpace(scheme)) + AddError(HeaderTypes.Authorization, "Missing authentication scheme!"); + else + { + splits.RemoveAt(0); + var parameter = String.Concat(splits); + if (String.IsNullOrEmpty(parameter)) + AddError(HeaderTypes.Authorization, "Missing authentication parameter!"); + else + { + if (requestHeaders.Headers.TryGetValue(InstanceIdHeader, out var instanceIdValues)) + { + var instanceIdString = instanceIdValues.FirstOrDefault(); + if (instanceIdString != default && Int64.TryParse(instanceIdString, out var instanceId)) + InstanceId = instanceId; + } #pragma warning disable CA1308 // Normalize strings to uppercase - switch (scheme.ToLowerInvariant()) + switch (scheme.ToLowerInvariant()) #pragma warning restore CA1308 // Normalize strings to uppercase - { - case JwtAuthenticationScheme: - Token = parameter; - break; - case PasswordAuthenticationScheme: - Password = parameter; - var fail = !requestHeaders.Headers.TryGetValue(UsernameHeader, out var values); - if (!fail) - { - Username = values.FirstOrDefault(); - fail = String.IsNullOrWhiteSpace(Username); - } + { + case JwtAuthenticationScheme: + Token = parameter; + break; + case BasicAuthenticationScheme: + string joinedString; + try + { + var base64Bytes = Convert.FromBase64String(parameter); + joinedString = Encoding.UTF8.GetString(base64Bytes); + } + catch + { + throw new InvalidOperationException("Invalid basic Authorization header!"); + } - 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!"); - 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!"); + Username = basicAuthSplits.First(); + Password = String.Concat(basicAuthSplits.Skip(1)); + break; + default: + AddError(HeaderTypes.Authorization, "Invalid authentication scheme!"); + break; + } + } + } } + + if (badHeaders != HeaderTypes.None) + throw new HeadersException(badHeaders, errorBuilder.ToString()); + + ApiVersion = apiVersion!.Semver(); } /// @@ -261,10 +259,10 @@ namespace Tgstation.Server.Api if (headers == null) throw new ArgumentNullException(nameof(headers)); if (instanceId.HasValue && InstanceId.HasValue && instanceId != InstanceId) - throw new InvalidOperationException("Specified instance ID in constructor and SetRequestHeaders!"); + throw new InvalidOperationException("Specified different instance IDs in constructor and SetRequestHeaders!"); headers.Clear(); - headers.Accept.Add(new MediaTypeWithQualityHeaderValue(ApplicationJson)); + headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); if (IsTokenAuthentication) headers.Authorization = new AuthenticationHeaderValue(JwtAuthenticationScheme, Token); else diff --git a/src/Tgstation.Server.Api/HeaderTypes.cs b/src/Tgstation.Server.Api/HeaderTypes.cs new file mode 100644 index 0000000000..a1d0a6e17e --- /dev/null +++ b/src/Tgstation.Server.Api/HeaderTypes.cs @@ -0,0 +1,36 @@ +using System; + +namespace Tgstation.Server.Api +{ + /// + /// Types of individual . + /// + [Flags] + public enum HeaderTypes + { + /// + /// No headers. + /// + None = 0, + + /// + /// header. + /// + UserAgent = 1, + + /// + /// header. + /// + Accept = 2, + + /// + /// Api header. + /// + Api = 4, + + /// + /// + /// + Authorization = 8 + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Api/HeadersException.cs b/src/Tgstation.Server.Api/HeadersException.cs new file mode 100644 index 0000000000..e13ef45598 --- /dev/null +++ b/src/Tgstation.Server.Api/HeadersException.cs @@ -0,0 +1,49 @@ +using System; + +namespace Tgstation.Server.Api +{ + /// + /// Thrown when trying to generate from fails. + /// + public sealed class HeadersException : Exception + { + /// + /// The s that are missing or malformed. + /// + public HeaderTypes MissingOrMalformedHeaders { get; } + + /// + /// Initializes a new instance of the . + /// + /// The value of . + /// The error message. + public HeadersException(HeaderTypes missingOrMalformedHeaders, string message) : base(message) + { + MissingOrMalformedHeaders = missingOrMalformedHeaders; + } + + /// + /// Initializes a new instance of the . + /// + public HeadersException() + { + } + + /// + /// Initializes a new instance of the . + /// + /// The error message. + public HeadersException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the . + /// + /// The error message. + /// The inner for the base + public HeadersException(string message, Exception innerException) : base(message, innerException) + { + } + } +} diff --git a/src/Tgstation.Server.Client/ApiClient.cs b/src/Tgstation.Server.Client/ApiClient.cs index cf1041fc86..0e107e91f8 100644 --- a/src/Tgstation.Server.Client/ApiClient.cs +++ b/src/Tgstation.Server.Client/ApiClient.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; +using System.Net.Mime; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -157,7 +158,10 @@ namespace Tgstation.Server.Client using (var request = new HttpRequestMessage(method, fullUri)) { if (body != null) - request.Content = new StringContent(JsonConvert.SerializeObject(body, serializerSettings), Encoding.UTF8, ApiHeaders.ApplicationJson); + request.Content = new StringContent( + JsonConvert.SerializeObject(body, serializerSettings), + Encoding.UTF8, + MediaTypeNames.Application.Json); var headersToUse = tokenRefresh ? tokenRefreshHeaders! : headers; headersToUse.SetRequestHeaders(request.Headers, instanceId); diff --git a/src/Tgstation.Server.Host/Components/Deployment/DreamMaker.cs b/src/Tgstation.Server.Host/Components/Deployment/DreamMaker.cs index f8321e4ec3..f17c625e12 100644 --- a/src/Tgstation.Server.Host/Components/Deployment/DreamMaker.cs +++ b/src/Tgstation.Server.Host/Components/Deployment/DreamMaker.cs @@ -17,6 +17,7 @@ using Tgstation.Server.Host.Components.Repository; using Tgstation.Server.Host.Components.Session; using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Models; @@ -629,7 +630,8 @@ namespace Tgstation.Server.Host.Components.Deployment activeCompileJob?.RevisionInformation, repositorySettings, repoOwner, - repoName); + repoName, + cancellationToken); var eventTask = eventConsumer.HandleEvent(EventType.DeploymentComplete, null, cancellationToken); @@ -755,13 +757,15 @@ namespace Tgstation.Server.Host.Components.Deployment /// The . /// The GitHub repostiory owner. /// The GitHub repostiory name. + /// The for the operation. /// A representing the running operation. async Task PostDeploymentComments( Models.RevisionInformation deployedRevisionInformation, Models.RevisionInformation previousRevisionInformation, Models.RepositorySettings repositorySettings, string repoOwner, - string repoName) + string repoName, + CancellationToken cancellationToken) { if (repositorySettings?.AccessToken == null) return; @@ -781,7 +785,9 @@ namespace Tgstation.Server.Host.Components.Deployment { try { - await gitHubClient.Issue.Comment.Create(repoOwner, repoName, prNumber, comment).ConfigureAwait(false); + await gitHubClient.Issue.Comment.Create(repoOwner, repoName, prNumber, comment) + .WithToken(cancellationToken) + .ConfigureAwait(false); } catch (ApiException e) { diff --git a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs index 8c470d4f59..7a0219d18f 100644 --- a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs +++ b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs @@ -102,8 +102,7 @@ namespace Tgstation.Server.Host.Controllers databaseContext, authenticationContextFactory, logger, - false, - true) + false) { this.gitHubClientFactory = gitHubClientFactory ?? throw new ArgumentNullException(nameof(gitHubClientFactory)); this.serverUpdater = serverUpdater ?? throw new ArgumentNullException(nameof(serverUpdater)); @@ -120,7 +119,7 @@ namespace Tgstation.Server.Host.Controllers Logger.LogWarning("Exceeded GitHub rate limit! Exception {0}", exception); var secondsString = Math.Ceiling((exception.Reset - DateTimeOffset.Now).TotalSeconds).ToString(CultureInfo.InvariantCulture); Response.Headers.Add("Retry-After", new StringValues(secondsString)); - return StatusCode(429, new ErrorMessage(ErrorCode.GitHubApiRateLimit)); + return StatusCode(HttpStatusCode.TooManyRequests, new ErrorMessage(ErrorCode.GitHubApiRateLimit)); } /// @@ -150,7 +149,7 @@ namespace Tgstation.Server.Host.Controllers catch (ApiException e) { Logger.LogWarning(OctokitException, e); - return StatusCode((int)HttpStatusCode.FailedDependency); + return StatusCode(HttpStatusCode.FailedDependency); } releases = releases.Where(x => x.TagName.StartsWith(updatesConfiguration.GitTagPrefix, StringComparison.InvariantCulture)); @@ -158,7 +157,11 @@ namespace Tgstation.Server.Host.Controllers Logger.LogTrace("Release query complete!"); foreach (var release in releases) - if (Version.TryParse(release.TagName.Replace(updatesConfiguration.GitTagPrefix, String.Empty, StringComparison.Ordinal), out var version) && version == newVersion) + if (Version.TryParse( + release.TagName.Replace( + updatesConfiguration.GitTagPrefix, String.Empty, StringComparison.Ordinal), + out var version) + && version == newVersion) { var asset = release.Assets.Where(x => x.Name.Equals(updatesConfiguration.UpdatePackageAssetName, StringComparison.Ordinal)).FirstOrDefault(); if (asset == default) @@ -181,6 +184,7 @@ namespace Tgstation.Server.Host.Controllers /// /// Get server information. /// + /// The for the operation. /// A resulting in the for the operation. /// Retrieved data successfully. /// The GitHub API rate limit was hit. See response header Retry-After. @@ -190,7 +194,7 @@ namespace Tgstation.Server.Host.Controllers [ProducesResponseType(typeof(Administration), 200)] [ProducesResponseType(typeof(ErrorMessage), 424)] [ProducesResponseType(typeof(ErrorMessage), 429)] - public async Task Read() + public async Task Read(CancellationToken cancellationToken) { try { @@ -199,8 +203,19 @@ namespace Tgstation.Server.Host.Controllers try { var gitHubClient = GetGitHubClient(); - var repositoryTask = gitHubClient.Repository.Get(updatesConfiguration.GitHubRepositoryId); - var releases = (await gitHubClient.Repository.Release.GetAll(updatesConfiguration.GitHubRepositoryId).ConfigureAwait(false)).Where(x => x.TagName.StartsWith(updatesConfiguration.GitTagPrefix, StringComparison.InvariantCulture)); + var repositoryTask = gitHubClient + .Repository + .Get(updatesConfiguration.GitHubRepositoryId) + .WithToken(cancellationToken); + var releases = (await gitHubClient + .Repository + .Release + .GetAll(updatesConfiguration.GitHubRepositoryId) + .WithToken(cancellationToken) + .ConfigureAwait(false)) + .Where(x => x.TagName.StartsWith( + updatesConfiguration.GitTagPrefix, + StringComparison.InvariantCulture)); foreach (var I in releases) if (Version.TryParse(I.TagName.Replace(updatesConfiguration.GitTagPrefix, String.Empty, StringComparison.Ordinal), out var version) @@ -228,7 +243,7 @@ namespace Tgstation.Server.Host.Controllers catch (ApiException e) { Logger.LogWarning(OctokitException, e); - return StatusCode((int)HttpStatusCode.FailedDependency, new ErrorMessage(ErrorCode.GitHubApiError) + return StatusCode(HttpStatusCode.FailedDependency, new ErrorMessage(ErrorCode.GitHubApiError) { AdditionalData = e.Message }); @@ -298,7 +313,7 @@ namespace Tgstation.Server.Host.Controllers } catch (InvalidOperationException) { - return StatusCode((int)HttpStatusCode.ServiceUnavailable); + return StatusCode(HttpStatusCode.ServiceUnavailable); } } diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index 5cd3865257..88309c993a 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -6,6 +6,7 @@ using Serilog.Context; using System; using System.Linq; using System.Net; +using System.Net.Mime; using System.Threading.Tasks; using Tgstation.Server.Api; using Tgstation.Server.Api.Models; @@ -17,7 +18,7 @@ namespace Tgstation.Server.Host.Controllers /// /// A for API functions /// - [Produces(ApiHeaders.ApplicationJson)] + [Produces(MediaTypeNames.Application.Json)] [ApiController] public abstract class ApiController : Controller { @@ -64,7 +65,12 @@ namespace Tgstation.Server.Host.Controllers /// The value of /// The value of /// The value of - public ApiController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, ILogger logger, bool requireInstance, bool requireHeaders) + public ApiController( + IDatabaseContext databaseContext, + IAuthenticationContextFactory authenticationContextFactory, + ILogger logger, + bool requireInstance, + bool requireHeaders = true) { DatabaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext)); if (authenticationContextFactory == null) @@ -80,22 +86,75 @@ namespace Tgstation.Server.Host.Controllers /// Generic 410 response. /// /// An with . - protected ObjectResult Gone() => StatusCode((int)HttpStatusCode.Gone, new ErrorMessage(ErrorCode.ResourceNotPresent)); + protected ObjectResult Gone() => StatusCode(HttpStatusCode.Gone, new ErrorMessage(ErrorCode.ResourceNotPresent)); /// /// Generic 404 response. /// /// An with . - protected new ObjectResult NotFound() => NotFound(new ErrorMessage(ErrorCode.ResourceNeverPresent)); + protected new NotFoundObjectResult NotFound() => NotFound(new ErrorMessage(ErrorCode.ResourceNeverPresent)); /// /// Generic 501 response. /// /// An with . - protected ObjectResult RequiresPosixSystemIdentity() => StatusCode((int)HttpStatusCode.NotImplemented, new ErrorMessage(ErrorCode.RequiresPosixSystemIdentity)); + protected ObjectResult RequiresPosixSystemIdentity() => StatusCode(HttpStatusCode.NotImplemented, new ErrorMessage(ErrorCode.RequiresPosixSystemIdentity)); + + /// + /// Strongly type calls to . + /// + /// The . + /// A with the given . + protected StatusCodeResult StatusCode(HttpStatusCode statusCode) => StatusCode((int)statusCode); + + /// + /// Strongly type calls to . + /// + /// The . + /// The accompanying payload. + /// A with the given . + protected ObjectResult StatusCode(HttpStatusCode statusCode, object errorMessage) => StatusCode((int)statusCode, errorMessage); + + /// + /// Generic 201 response with a given . + /// + /// The accompanying API payload. + /// A with the given . + protected ObjectResult Created(object payload) => StatusCode((int)HttpStatusCode.Created, payload); + + /// + /// Response for missing/Invalid headers. + /// + /// The appropriate . + protected IActionResult HeadersIssue() + { + HeadersException headersException; + try + { + var _ = new ApiHeaders(Request.GetTypedHeaders()); + throw new InvalidOperationException("Expected a header parse exception!"); + } + catch (HeadersException ex) + { + headersException = ex; + } + + var errorMessage = new ErrorMessage(ErrorCode.BadHeaders) + { + AdditionalData = headersException.Message + }; + + if (headersException.MissingOrMalformedHeaders.HasFlag(HeaderTypes.Accept)) + return StatusCode(HttpStatusCode.NotAcceptable, errorMessage); + + if (headersException.MissingOrMalformedHeaders == HeaderTypes.Authorization) + return Unauthorized(errorMessage); + + return BadRequest(errorMessage); + } /// - #pragma warning disable CA1506 // TODO: Decomplexify +#pragma warning disable CA1506 // TODO: Decomplexify public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { // ALL valid token and login requests that match a route go through this function @@ -115,7 +174,7 @@ namespace Tgstation.Server.Host.Controllers if (!ApiHeaders.Compatible()) { await StatusCode( - (int)HttpStatusCode.UpgradeRequired, + HttpStatusCode.UpgradeRequired, new ErrorMessage(ErrorCode.ApiMismatch)) .ExecuteResultAsync(context) .ConfigureAwait(false); @@ -138,15 +197,11 @@ namespace Tgstation.Server.Host.Controllers } } } - catch (InvalidOperationException e) + catch (HeadersException) { if (requireHeaders) { - await BadRequest( - new ErrorMessage(ErrorCode.BadHeaders) - { - AdditionalData = e.Message - }) + await HeadersIssue() .ExecuteResultAsync(context) .ConfigureAwait(false); return; diff --git a/src/Tgstation.Server.Host/Controllers/BridgeController.cs b/src/Tgstation.Server.Host/Controllers/BridgeController.cs index 3ee3155cd8..1a0e794e9a 100644 --- a/src/Tgstation.Server.Host/Controllers/BridgeController.cs +++ b/src/Tgstation.Server.Host/Controllers/BridgeController.cs @@ -5,9 +5,9 @@ using Newtonsoft.Json; using Serilog.Context; using System; using System.Net; +using System.Net.Mime; using System.Threading; using System.Threading.Tasks; -using Tgstation.Server.Api; using Tgstation.Server.Host.Components.Interop; using Tgstation.Server.Host.Components.Interop.Bridge; @@ -17,7 +17,7 @@ namespace Tgstation.Server.Host.Controllers /// for recieving DMAPI requests from DreamDaemon. /// [Route("Bridge")] - [Produces(ApiHeaders.ApplicationJson)] + [Produces(MediaTypeNames.Application.Json)] public class BridgeController : Controller { /// @@ -86,7 +86,7 @@ namespace Tgstation.Server.Host.Controllers var responseJson = JsonConvert.SerializeObject(response, DMApiConstants.SerializerSettings); logger.LogTrace("Bridge Response: {0}", responseJson); - return Content(responseJson, ApiHeaders.ApplicationJson); + return Content(responseJson, MediaTypeNames.Application.Json); } } } diff --git a/src/Tgstation.Server.Host/Controllers/ByondController.cs b/src/Tgstation.Server.Host/Controllers/ByondController.cs index c6f9dbd0d7..0e27ba918c 100644 --- a/src/Tgstation.Server.Host/Controllers/ByondController.cs +++ b/src/Tgstation.Server.Host/Controllers/ByondController.cs @@ -40,7 +40,17 @@ 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, true) + public ByondController( + IDatabaseContext databaseContext, + IAuthenticationContextFactory authenticationContextFactory, + IInstanceManager instanceManager, + IJobManager jobManager, + ILogger logger) + : base( + databaseContext, + authenticationContextFactory, + logger, + true) { this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); diff --git a/src/Tgstation.Server.Host/Controllers/ChatController.cs b/src/Tgstation.Server.Host/Controllers/ChatController.cs index 5c47fbd619..f642156e0f 100644 --- a/src/Tgstation.Server.Host/Controllers/ChatController.cs +++ b/src/Tgstation.Server.Host/Controllers/ChatController.cs @@ -39,7 +39,16 @@ 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, true) + public ChatController( + IDatabaseContext databaseContext, + IAuthenticationContextFactory authenticationContextFactory, + IInstanceManager instanceManager, + ILogger logger) + : base( + databaseContext, + authenticationContextFactory, + logger, + true) { this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); } @@ -125,7 +134,7 @@ namespace Tgstation.Server.Host.Controllers throw; } - return StatusCode((int)HttpStatusCode.Created, dbModel.ToApi()); + return StatusCode(HttpStatusCode.Created, dbModel.ToApi()); } /// diff --git a/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs b/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs index 4cebfdbc8a..a697f36a12 100644 --- a/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs +++ b/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.IO; -using System.Net; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api; @@ -41,7 +40,17 @@ 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, true) + public ConfigurationController( + IDatabaseContext databaseContext, + IAuthenticationContextFactory authenticationContextFactory, + IInstanceManager instanceManager, + IIOManager ioManager, + ILogger logger) + : base( + databaseContext, + authenticationContextFactory, + logger, + true) { this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); @@ -95,7 +104,7 @@ namespace Tgstation.Server.Host.Controllers newFile.Content = null; - return model.LastReadHash == null ? (IActionResult)StatusCode((int)HttpStatusCode.Created, newFile) : Json(newFile); + return model.LastReadHash == null ? (IActionResult)Created(newFile) : Json(newFile); } catch(IOException e) { @@ -218,7 +227,13 @@ namespace Tgstation.Server.Host.Controllers try { model.IsDirectory = true; - return await instanceManager.GetInstance(Instance).Configuration.CreateDirectory(model.Path, systemIdentity, cancellationToken).ConfigureAwait(false) ? (IActionResult)Json(model) : StatusCode((int)HttpStatusCode.Created, model); + return await instanceManager + .GetInstance(Instance) + .Configuration + .CreateDirectory(model.Path, systemIdentity, cancellationToken) + .ConfigureAwait(false) + ? (IActionResult)Json(model) + : Created(model); } catch (IOException e) { diff --git a/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs b/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs index aa66421cc8..9ae5df0a0e 100644 --- a/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs +++ b/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs @@ -43,7 +43,17 @@ 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, true) + public DreamDaemonController( + IDatabaseContext databaseContext, + IAuthenticationContextFactory authenticationContextFactory, + IJobManager jobManager, + IInstanceManager instanceManager, + ILogger logger) + : base( + databaseContext, + authenticationContextFactory, + logger, + true) { this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); diff --git a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs index b40053100b..8839a3af5c 100644 --- a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs +++ b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs @@ -42,7 +42,17 @@ 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, true) + public DreamMakerController( + IDatabaseContext databaseContext, + IAuthenticationContextFactory authenticationContextFactory, + IJobManager jobManager, + IInstanceManager instanceManager, + ILogger logger) + : base( + databaseContext, + authenticationContextFactory, + logger, + true) { this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); diff --git a/src/Tgstation.Server.Host/Controllers/HomeController.cs b/src/Tgstation.Server.Host/Controllers/HomeController.cs index 5b0fa18a95..f8c5195413 100644 --- a/src/Tgstation.Server.Host/Controllers/HomeController.cs +++ b/src/Tgstation.Server.Host/Controllers/HomeController.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -12,6 +11,7 @@ using System.Net.Mime; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; using Tgstation.Server.Host.Components.Interop; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Core; @@ -95,14 +95,20 @@ namespace Tgstation.Server.Host.Controllers IOptions generalConfigurationOptions, IOptions controlPanelConfigurationOptions, ILogger logger) - : base(databaseContext, authenticationContextFactory, logger, false, false) + : base( + databaseContext, + authenticationContextFactory, + logger, + false, + (browserResolver ?? throw new ArgumentNullException(nameof(browserResolver))).Browser.Type != BrowserType.Generic + && (controlPanelConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(controlPanelConfigurationOptions))).Enable) { this.tokenFactory = tokenFactory ?? throw new ArgumentNullException(nameof(tokenFactory)); this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory)); this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite)); this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); this.identityCache = identityCache ?? throw new ArgumentNullException(nameof(identityCache)); - this.browserResolver = browserResolver ?? throw new ArgumentNullException(nameof(browserResolver)); + this.browserResolver = browserResolver; generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); controlPanelConfiguration = controlPanelConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(controlPanelConfigurationOptions)); } @@ -111,17 +117,17 @@ 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. + /// retrieved successfully. [HttpGet] [TgsAuthorize] [AllowAnonymous] - [ProducesResponseType(typeof(Api.Models.ServerInformation), 200)] + [ProducesResponseType(typeof(ServerInformation), 200)] public IActionResult Home() { if (AuthenticationContext != null) - return Json(new Api.Models.ServerInformation + return Json(new ServerInformation { Version = assemblyInformationProvider.Version, ApiVersion = ApiHeaders.Version, @@ -139,7 +145,7 @@ namespace Tgstation.Server.Host.Controllers return File("~/index.html", MediaTypeNames.Text.Html); } - return Unauthorized(); + return ApiHeaders == null ? HeadersIssue() : Unauthorized(); } /// @@ -147,38 +153,22 @@ namespace Tgstation.Server.Host.Controllers /// /// The for the operation /// A resulting in the of the operation - /// User logged in and generated successfully. + /// 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(typeof(Token), 200)] #pragma warning disable CA1506 // TODO: Decomplexify public async Task CreateToken(CancellationToken cancellationToken) { if (ApiHeaders == null) { - // 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(Api.Models.ErrorCode.BadHeaders) - { - AdditionalData = errorMessage - }); + return HeadersIssue(); } if (ApiHeaders.IsTokenAuthentication) - return BadRequest(new Api.Models.ErrorMessage(Api.Models.ErrorCode.TokenWithToken)); + return BadRequest(new ErrorMessage(ErrorCode.TokenWithToken)); ISystemIdentity systemIdentity; try @@ -194,13 +184,13 @@ namespace Tgstation.Server.Host.Controllers using (systemIdentity) { // Get the user from the database - IQueryable query = DatabaseContext.Users.AsQueryable(); + IQueryable query = DatabaseContext.Users.AsQueryable(); string canonicalName = Models.User.CanonicalizeName(ApiHeaders.Username); if (systemIdentity == null) query = query.Where(x => x.CanonicalName == canonicalName); else query = query.Where(x => x.CanonicalName == canonicalName || x.SystemIdentifier == systemIdentity.Uid); - var users = await query.Select(x => new User + var users = await query.Select(x => new Models.User { Id = x.Id, PasswordHash = x.PasswordHash, @@ -233,7 +223,7 @@ namespace Tgstation.Server.Host.Controllers if (user.PasswordHash != originalHash) { Logger.LogDebug("User ID {0}'s password hash needs a refresh, updating database.", user.Id); - var updatedUser = new User + var updatedUser = new Models.User { Id = user.Id }; diff --git a/src/Tgstation.Server.Host/Controllers/InstanceController.cs b/src/Tgstation.Server.Host/Controllers/InstanceController.cs index db738f96c6..ec5ca653d9 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceController.cs @@ -8,7 +8,6 @@ using System.Globalization; using System.IO; using System.Linq; using System.Linq.Expressions; -using System.Net; using System.Reflection; using System.Threading; using System.Threading.Tasks; @@ -92,7 +91,11 @@ namespace Tgstation.Server.Host.Controllers IPlatformIdentifier platformIdentifier, IOptions generalConfigurationOptions, ILogger logger) - : base(databaseContext, authenticationContextFactory, logger, false, true) + : base( + databaseContext, + authenticationContextFactory, + logger, + false) { this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); @@ -307,7 +310,7 @@ namespace Tgstation.Server.Host.Controllers Logger.LogInformation("{0} {1} instance {2}: {3} ({4})", AuthenticationContext.User.Name, attached ? "attached" : "created", newInstance.Name, newInstance.Id, newInstance.Path); var api = newInstance.ToApi(); - return attached ? (IActionResult)Json(api) : StatusCode((int)HttpStatusCode.Created, api); + return attached ? (IActionResult)Json(api) : Created(api); } /// diff --git a/src/Tgstation.Server.Host/Controllers/InstanceUserController.cs b/src/Tgstation.Server.Host/Controllers/InstanceUserController.cs index a6c4bef4f5..dbb3ff8dae 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceUserController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceUserController.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Linq; -using System.Net; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api; @@ -29,7 +28,15 @@ 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, true) + public InstanceUserController( + IDatabaseContext databaseContext, + IAuthenticationContextFactory authenticationContextFactory, + ILogger logger) + : base( + databaseContext, + authenticationContextFactory, + logger, + true) { } /// @@ -79,7 +86,7 @@ namespace Tgstation.Server.Host.Controllers DatabaseContext.InstanceUsers.Add(dbUser); await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); - return StatusCode((int)HttpStatusCode.Created, dbUser.ToApi()); + return Created(dbUser.ToApi()); } /// diff --git a/src/Tgstation.Server.Host/Controllers/JobController.cs b/src/Tgstation.Server.Host/Controllers/JobController.cs index e0d972805f..e6bca24a46 100644 --- a/src/Tgstation.Server.Host/Controllers/JobController.cs +++ b/src/Tgstation.Server.Host/Controllers/JobController.cs @@ -33,7 +33,16 @@ 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, true) + public JobController( + IDatabaseContext databaseContext, + IAuthenticationContextFactory authenticationContextFactory, + IJobManager jobManager, + ILogger logger) + : base( + databaseContext, + authenticationContextFactory, + logger, + true) { this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); } diff --git a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs index 8cd72e95f9..9b14db234b 100644 --- a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs +++ b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs @@ -7,7 +7,6 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Linq.Expressions; -using System.Net; using System.Reflection; using System.Threading; using System.Threading.Tasks; @@ -18,6 +17,7 @@ using Tgstation.Server.Host.Components; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; @@ -61,7 +61,20 @@ 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, 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)); @@ -233,7 +246,7 @@ namespace Tgstation.Server.Host.Controllers api.Reference = model.Reference; api.ActiveJob = job.ToApi(); - return StatusCode((int)HttpStatusCode.Created, api); + return Created(api); } /// @@ -317,7 +330,7 @@ namespace Tgstation.Server.Host.Controllers { // user may have fucked with the repo manually, do what we can await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); - return StatusCode((int)HttpStatusCode.Created, api); + return Created(api); } return Json(api); @@ -659,7 +672,9 @@ namespace Tgstation.Server.Host.Controllers try { // retrieve the latest sha - var pr = await gitHubClient.PullRequest.Get(repoOwner, repoName, I.Number).ConfigureAwait(false); + var pr = await gitHubClient.PullRequest.Get(repoOwner, repoName, I.Number) + .WithToken(ct) + .ConfigureAwait(false); prMap.Add(I.Number, pr); I.PullRequestRevision = pr.Head.Sha; } @@ -762,7 +777,11 @@ namespace Tgstation.Server.Host.Controllers { // load from cache if possible if (prMap == null || !prMap.TryGetValue(I.Number, out pr)) - pr = await gitHubClient.PullRequest.Get(repoOwner, repoName, I.Number).ConfigureAwait(false); + pr = await gitHubClient + .PullRequest + .Get(repoOwner, repoName, I.Number) + .WithToken(ct) + .ConfigureAwait(false); } catch (Octokit.RateLimitExceededException ex) { diff --git a/src/Tgstation.Server.Host/Controllers/UserController.cs b/src/Tgstation.Server.Host/Controllers/UserController.cs index 7bac3e59e8..dc7a1a2b71 100644 --- a/src/Tgstation.Server.Host/Controllers/UserController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserController.cs @@ -5,7 +5,6 @@ using Microsoft.Extensions.Options; using System; using System.Collections.Generic; using System.Linq; -using System.Net; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api; @@ -48,7 +47,18 @@ namespace Tgstation.Server.Host.Controllers /// The value of /// The for the . /// The containing the value of - public UserController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, ISystemIdentityFactory systemIdentityFactory, ICryptographySuite cryptographySuite, ILogger logger, IOptions generalConfigurationOptions) : base(databaseContext, authenticationContextFactory, logger, false, true) + public UserController( + IDatabaseContext databaseContext, + IAuthenticationContextFactory authenticationContextFactory, + ISystemIdentityFactory systemIdentityFactory, + ICryptographySuite cryptographySuite, + ILogger logger, + IOptions generalConfigurationOptions) + : base( + databaseContext, + authenticationContextFactory, + logger, + false) { this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory)); this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite)); @@ -160,7 +170,7 @@ namespace Tgstation.Server.Host.Controllers await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); - return StatusCode((int)HttpStatusCode.Created, dbUser.ToApi(true)); + return Created(dbUser.ToApi(true)); } /// diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 0b21fb8cc5..f07cdee7fa 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -173,6 +173,8 @@ namespace Tgstation.Server.Host.Core .AddMvc(options => { options.EnableEndpointRouting = false; + options.ReturnHttpNotAcceptable = true; + options.RespectBrowserAcceptHeader = true; }) .AddNewtonsoftJson(options => { diff --git a/src/Tgstation.Server.Host/Core/SwaggerConfiguration.cs b/src/Tgstation.Server.Host/Core/SwaggerConfiguration.cs index 218740c51b..7bd6d01449 100644 --- a/src/Tgstation.Server.Host/Core/SwaggerConfiguration.cs +++ b/src/Tgstation.Server.Host/Core/SwaggerConfiguration.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Net; +using System.Net.Mime; using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Rights; @@ -34,7 +35,7 @@ namespace Tgstation.Server.Host.Core var errorMessageContent = new Dictionary { { - ApiHeaders.ApplicationJson, + MediaTypeNames.Application.Json, new OpenApiMediaType { Schema = new OpenApiSchema @@ -90,6 +91,12 @@ namespace Tgstation.Server.Host.Core Content = errorMessageContent }); + AddDefaultResponse(HttpStatusCode.NotAcceptable, new OpenApiResponse + { + Description = "Invalid Accept header, TGS requires `Accept: application/json`.", + Content = errorMessageContent + }); + AddDefaultResponse(HttpStatusCode.InternalServerError, new OpenApiResponse { Description = ErrorCode.InternalServerError.Describe(), diff --git a/src/Tgstation.Server.Host/Extensions/ApplicationBuilderExtensions.cs b/src/Tgstation.Server.Host/Extensions/ApplicationBuilderExtensions.cs index f155aff190..fe3356d981 100644 --- a/src/Tgstation.Server.Host/Extensions/ApplicationBuilderExtensions.cs +++ b/src/Tgstation.Server.Host/Extensions/ApplicationBuilderExtensions.cs @@ -40,6 +40,12 @@ namespace Tgstation.Server.Host.Extensions } catch (DbUpdateException e) { + if (e.InnerException is OperationCanceledException) + { + logger.LogTrace("Rethrowing DbUpdateException as OperationCanceledException: {0}", e); + throw e.InnerException; + } + logger.LogDebug("Database conflict: {0}", e.Message); await new ConflictObjectResult(new ErrorMessage(ErrorCode.DatabaseIntegrityConflict) { diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs index aa019be91f..45935ac098 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs @@ -64,6 +64,7 @@ namespace Tgstation.Server.Host.Security .ConfigureAwait(false); if (user == default) { + logger.LogWarning("Unable to find user with ID {0}!", userId); CurrentAuthenticationContext = new AuthenticationContext(); return; } @@ -75,6 +76,7 @@ namespace Tgstation.Server.Host.Security { if (user.LastPasswordUpdate.HasValue && user.LastPasswordUpdate > validAfter) { + logger.LogDebug("Rejecting token for user {0} created before last password update: {1}", userId, user.LastPasswordUpdate.Value); CurrentAuthenticationContext = new AuthenticationContext(); return; } diff --git a/src/Tgstation.Server.Host/Security/ClaimsInjector.cs b/src/Tgstation.Server.Host/Security/ClaimsInjector.cs index 7fd31958de..00c3ce2bed 100644 --- a/src/Tgstation.Server.Host/Security/ClaimsInjector.cs +++ b/src/Tgstation.Server.Host/Security/ClaimsInjector.cs @@ -55,7 +55,7 @@ namespace Tgstation.Server.Host.Security { apiHeaders = new ApiHeaders(tokenValidatedContext.HttpContext.Request.GetTypedHeaders()); } - catch (InvalidOperationException) + catch (HeadersException) { // we are not responsible for handling header validation issues return; @@ -78,9 +78,12 @@ namespace Tgstation.Server.Host.Security // if there's no instance user, do a weird thing and add all the instance roles // we need it so we can get to OnActionExecutionAsync where we can properly decide between BadRequest and Forbid // if user is null that means they got the token with an expired password - var rightInt = authenticationContext.User == null || (RightsHelper.IsInstanceRight(I) && authenticationContext.InstanceUser == null) ? ~0U : authenticationContext.GetRight(I); + var rightAsULong = authenticationContext.User == null + || (RightsHelper.IsInstanceRight(I) && authenticationContext.InstanceUser == null) + ? ~0UL + : authenticationContext.GetRight(I); var rightEnum = RightsHelper.RightToType(I); - var right = (Enum)Enum.ToObject(rightEnum, rightInt); + var right = (Enum)Enum.ToObject(rightEnum, rightAsULong); foreach (Enum J in Enum.GetValues(rightEnum)) if (right.HasFlag(J)) claims.Add(new Claim(ClaimTypes.Role, RightsHelper.RoleName(I, J))); diff --git a/src/Tgstation.Server.Host/Server.cs b/src/Tgstation.Server.Host/Server.cs index 45b824bd39..45b37f2c74 100644 --- a/src/Tgstation.Server.Host/Server.cs +++ b/src/Tgstation.Server.Host/Server.cs @@ -21,7 +21,12 @@ namespace Tgstation.Server.Host public bool RestartRequested { get; private set; } /// - public bool WatchdogPresent => updatePath != null; + public bool WatchdogPresent => +#if WATCHDOG_FREE_RESTART + true; +#else + updatePath != null; +#endif /// /// The for the diff --git a/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs b/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs index 5420a0fb29..0f78162c50 100644 --- a/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs +++ b/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http.Headers; using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.Net.Http.Headers; +using System.Net.Mime; namespace Tgstation.Server.Api.Tests { @@ -28,11 +29,11 @@ namespace Tgstation.Server.Api.Tests const string BrowserHeader = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36."; const string ConformantHeader = "TGSClient/3.2.1.4"; - ApiHeaders TestHeader(string userAgent) + static ApiHeaders TestHeader(string userAgent) { var headers = new HeaderDictionary { - { "Accept", ApiHeaders.ApplicationJson }, + { "Accept", MediaTypeNames.Application.Json }, { "Api", "Tgstation.Server.Api/4.0.0.0" }, { "Authorization", "Bearer asdfasdf" }, { "User-Agent", userAgent } @@ -49,7 +50,7 @@ namespace Tgstation.Server.Api.Tests Assert.AreEqual(ConformantHeader, header.RawUserAgent); Assert.IsNotNull(header.UserAgent); - Assert.ThrowsException(() => TestHeader(String.Empty)); + Assert.ThrowsException(() => TestHeader(String.Empty)); } } } diff --git a/tests/Tgstation.Server.Tests/IntegrationTest.cs b/tests/Tgstation.Server.Tests/IntegrationTest.cs index 637a16ad9c..c73ed0f635 100644 --- a/tests/Tgstation.Server.Tests/IntegrationTest.cs +++ b/tests/Tgstation.Server.Tests/IntegrationTest.cs @@ -14,7 +14,6 @@ using System.Threading.Tasks; using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Client; -using Tgstation.Server.Host; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.System; using Tgstation.Server.Tests.Instance; @@ -36,34 +35,8 @@ namespace Tgstation.Server.Tests var serverTask = server.Run(cancellationToken); try { - IServerClient adminClient; - - var giveUpAt = DateTimeOffset.Now.AddSeconds(60); - do - { - try - { - adminClient = await clientFactory.CreateFromLogin(server.Url, User.AdminName, User.DefaultAdminPassword).ConfigureAwait(false); - break; - } - catch (HttpRequestException) - { - //migrating, to be expected - if (DateTimeOffset.Now > giveUpAt) - throw; - await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); - } - catch (ServiceUnavailableException) - { - //migrating, to be expected - if (DateTimeOffset.Now > giveUpAt) - throw; - await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); - } - } while (true); - var testUpdateVersion = new Version(4, 3, 0); - using (adminClient) + using (var adminClient = await CreateAdminClient(server.Url, cancellationToken)) //attempt to update to stable await adminClient.Administration.Update(new Administration { @@ -107,6 +80,32 @@ namespace Tgstation.Server.Tests proc.Kill(); } + async Task CreateAdminClient(Uri url, CancellationToken cancellationToken) + { + var giveUpAt = DateTimeOffset.Now.AddSeconds(60); + do + { + try + { + return await clientFactory.CreateFromLogin(url, User.AdminName, User.DefaultAdminPassword, attemptLoginRefresh: false).ConfigureAwait(false); + } + catch (HttpRequestException) + { + //migrating, to be expected + if (DateTimeOffset.Now > giveUpAt) + throw; + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + catch (ServiceUnavailableException) + { + // migrating, to be expected + if (DateTimeOffset.Now > giveUpAt) + throw; + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + } while (true); + } + [TestMethod] public async Task TestServer() { @@ -140,36 +139,11 @@ namespace Tgstation.Server.Tests TerminateAllDDs(); var serverTask = server.Run(cancellationToken); + try { - async Task CreateAdminClient() - { - var giveUpAt = DateTimeOffset.Now.AddSeconds(60); - do - { - try - { - return await clientFactory.CreateFromLogin(server.Url, User.AdminName, User.DefaultAdminPassword, attemptLoginRefresh: false).ConfigureAwait(false); - } - catch (HttpRequestException) - { - //migrating, to be expected - if (DateTimeOffset.Now > giveUpAt) - throw; - await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); - } - catch (ServiceUnavailableException) - { - // migrating, to be expected - if (DateTimeOffset.Now > giveUpAt) - throw; - await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); - } - } while (true); - } - Api.Models.Instance instance; - using (var adminClient = await CreateAdminClient()) + using (var adminClient = await CreateAdminClient(server.Url, cancellationToken)) { if (server.DumpOpenApiSpecpath) { @@ -182,34 +156,34 @@ namespace Tgstation.Server.Tests await content.CopyToAsync(output); } - var serverInfo = await adminClient.Version(default).ConfigureAwait(false); - - Assert.AreEqual(ApiHeaders.Version, serverInfo.ApiVersion); - var assemblyVersion = typeof(IServer).Assembly.GetName().Version.Semver(); - Assert.AreEqual(assemblyVersion, serverInfo.Version); - Assert.AreEqual(10U, serverInfo.MinimumPasswordLength); - Assert.AreEqual(11U, serverInfo.InstanceLimit); - Assert.AreEqual(150U, serverInfo.UserLimit); - - //check that modifying the token even slightly fucks up the auth - var newToken = new Token + async Task FailFast(Task task) { - ExpiresAt = adminClient.Token.ExpiresAt, - Bearer = adminClient.Token.Bearer + '0' - }; + try + { + await task; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Console.WriteLine($"[{DateTimeOffset.Now}] TEST ERROR: {ex}"); + serverCts.Cancel(); + throw; + } + } - var badClient = clientFactory.CreateFromToken(server.Url, newToken); - await Assert.ThrowsExceptionAsync(() => badClient.Version(cancellationToken)).ConfigureAwait(false); - - var adminTest = new AdministrationTest(adminClient.Administration).Run(cancellationToken); - var usersTest = new UsersTest(adminClient.Users).Run(cancellationToken); - instance = await new InstanceManagerTest(adminClient.Instances, adminClient.Users, server.Directory).RunPreInstanceTest(cancellationToken).ConfigureAwait(false); + var rootTest = FailFast(new RootTest().Run(clientFactory, adminClient, cancellationToken)); + var adminTest = FailFast(new AdministrationTest(adminClient.Administration).Run(cancellationToken)); + var usersTest = FailFast(new UsersTest(adminClient.Users).Run(cancellationToken)); + instance = await new InstanceManagerTest(adminClient.Instances, adminClient.Users, server.Directory).RunPreInstanceTest(cancellationToken); var instanceClient = adminClient.Instances.CreateClient(instance); - var instanceTests = new InstanceTest(instanceClient, adminClient.Instances).RunTests(cancellationToken); + var instanceTests = FailFast(new InstanceTest(instanceClient, adminClient.Instances).RunTests(cancellationToken)); - await Task.WhenAll(adminTest, instanceTests, usersTest); + await Task.WhenAll(rootTest, adminTest, instanceTests, usersTest); await adminClient.Administration.Restart(cancellationToken); } @@ -220,7 +194,7 @@ namespace Tgstation.Server.Tests var preStartupTime = DateTimeOffset.Now; serverTask = server.Run(cancellationToken); - using (var adminClient = await CreateAdminClient()) + using (var adminClient = await CreateAdminClient(server.Url, cancellationToken)) { var instanceClient = adminClient.Instances.CreateClient(instance); @@ -263,7 +237,7 @@ namespace Tgstation.Server.Tests preStartupTime = DateTimeOffset.Now; serverTask = server.Run(cancellationToken); - using (var adminClient = await CreateAdminClient()) + using (var adminClient = await CreateAdminClient(server.Url, cancellationToken)) { var instanceClient = adminClient.Instances.CreateClient(instance); diff --git a/tests/Tgstation.Server.Tests/RootTest.cs b/tests/Tgstation.Server.Tests/RootTest.cs new file mode 100644 index 0000000000..b94e2c3dc7 --- /dev/null +++ b/tests/Tgstation.Server.Tests/RootTest.cs @@ -0,0 +1,151 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Client; +using Tgstation.Server.Host; + +namespace Tgstation.Server.Tests +{ + class RootTest + { + async Task TestRequestValidation(IServerClient serverClient, CancellationToken cancellationToken) + { + var url = serverClient.Url; + var token = serverClient.Token.Bearer; + // check that 400s are returned appropriately + using var httpClient = new HttpClient(); + using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString())) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(HttpStatusCode.NotAcceptable, response.StatusCode); + } + + using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString())) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Xml)); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(HttpStatusCode.NotAcceptable, response.StatusCode); + } + + using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString())) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + var message = JsonConvert.DeserializeObject(content); + Assert.AreEqual(ErrorCode.BadHeaders, message.ErrorCode); + } + + using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString())) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); + request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/6.0.0"); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + var message = JsonConvert.DeserializeObject(content); + Assert.AreEqual(ErrorCode.BadHeaders, message.ErrorCode); + } + + using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString())) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("*/*")); + request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/6.0.0"); + request.Headers.Authorization = new AuthenticationHeaderValue("bearer", token); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(HttpStatusCode.UpgradeRequired, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + var message = JsonConvert.DeserializeObject(content); + Assert.AreEqual(ErrorCode.ApiMismatch, message.ErrorCode); + } + + using (var request = new HttpRequestMessage(HttpMethod.Post, url.ToString() + Routes.Administration.Substring(1))) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); + request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/7.0.0"); + request.Headers.Authorization = new AuthenticationHeaderValue("bearer", token); + request.Content = new StringContent( + "{ newVersion: 1234 }", + Encoding.UTF8, + MediaTypeNames.Application.Json); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + var message = JsonConvert.DeserializeObject(content); + Assert.AreEqual(ErrorCode.ModelValidationFailure, message.ErrorCode); + } + + using (var request = new HttpRequestMessage(HttpMethod.Post, url.ToString() + Routes.DreamDaemon.Substring(1))) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); + request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/7.0.0"); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } + + using (var request = new HttpRequestMessage(HttpMethod.Post, url.ToString() + Routes.DreamDaemon.Substring(1))) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); + request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/7.0.0"); + request.Headers.Authorization = new AuthenticationHeaderValue("bearer", token); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + var message = JsonConvert.DeserializeObject(content); + Assert.AreEqual(ErrorCode.InstanceHeaderRequired, message.ErrorCode); + } + } + + async Task TestServerInformation(IServerClientFactory clientFactory, IServerClient serverClient, CancellationToken cancellationToken) + { + var serverInfo = await serverClient.Version(default).ConfigureAwait(false); + + Assert.AreEqual(ApiHeaders.Version, serverInfo.ApiVersion); + var assemblyVersion = typeof(IServer).Assembly.GetName().Version.Semver(); + Assert.AreEqual(assemblyVersion, serverInfo.Version); + Assert.AreEqual(10U, serverInfo.MinimumPasswordLength); + Assert.AreEqual(11U, serverInfo.InstanceLimit); + Assert.AreEqual(150U, serverInfo.UserLimit); + + //check that modifying the token even slightly fucks up the auth + var newToken = new Token + { + ExpiresAt = serverClient.Token.ExpiresAt, + Bearer = serverClient.Token.Bearer + '0' + }; + + var badClient = clientFactory.CreateFromToken(serverClient.Url, newToken); + await Assert.ThrowsExceptionAsync(() => badClient.Version(cancellationToken)).ConfigureAwait(false); + } + + public Task Run(IServerClientFactory clientFactory, IServerClient serverClient, CancellationToken cancellationToken) + => Task.WhenAll( + TestRequestValidation(serverClient, cancellationToken), + TestServerInformation(clientFactory, serverClient, cancellationToken)); + } +}